Skip to content

Commit a6f699d

Browse files
committed
Add HTTP/2 full-duplex bidirectional stream API (h2_*)
Add an h2_* API for gRPC-style bidirectional HTTP/2 streams, where the client sends and receives interleaved on one stream. It mirrors the ws_* and wt_* APIs: h2_open returns a pid, h2_send writes DATA frames, h2_recv reads inbound messages, h2_send_trailers and h2_send(_, _, fin) half-close the send side, and h2_consume applies receive backpressure under {flow_control, manual}. Passive recv and {active, true|once} delivery are both supported. The new hackney_h2_stream gen_statem owns one dedicated h2 connection and opens a stream on it routed to itself via the h2 library's per-stream handler, so the bidi data plane bypasses hackney_conn's linear lifecycle. hackney_conn gains an additive open_h2_stream/6 that builds the request headers and registers the handler; existing request paths are untouched.
1 parent 66c0c26 commit a6f699d

5 files changed

Lines changed: 968 additions & 0 deletions

File tree

guides/http2_guide.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,43 @@ Each chunk is sent as a DATA frame and the request stream is closed with
283283
END_STREAM on `finish_send_body/1`. The `h2` connection buffers beyond the
284284
peer's flow-control window and drains as WINDOW_UPDATEs arrive.
285285

286+
## Bidirectional Streaming (gRPC-style)
287+
288+
For full-duplex streams, where the client sends and receives on the same
289+
stream interleaved (as gRPC bidi RPCs do), use the `h2_*` API. It mirrors the
290+
`ws_*` / `wt_*` APIs: `h2_open` returns a pid, `h2_send` writes DATA frames,
291+
`h2_recv` reads inbound messages, and `h2_send_trailers` / `h2_send(_, _, fin)`
292+
half-close the send side. The URL must be `https` (HTTP/2 is negotiated over
293+
ALPN), and each `h2_open` uses its own dedicated connection.
294+
295+
```erlang
296+
{ok, S} = hackney:h2_open(<<"https://host/pkg.Service/BidiMethod">>,
297+
[{<<"content-type">>, <<"application/grpc">>},
298+
{<<"te">>, <<"trailers">>}],
299+
[{ssl_options, [...]}]),
300+
301+
{ok, {response, 200, _Headers}} = hackney:h2_recv(S),
302+
ok = hackney:h2_send(S, Frame1),
303+
{ok, {data, Reply1}} = hackney:h2_recv(S),
304+
ok = hackney:h2_send(S, Frame2), %% keep sending while receiving
305+
{ok, {data, Reply2}} = hackney:h2_recv(S),
306+
ok = hackney:h2_send(S, <<>>, fin), %% half-close the request
307+
{ok, {trailers, Trailers}} = hackney:h2_recv(S),
308+
{ok, done} = hackney:h2_recv(S),
309+
ok = hackney:h2_close(S).
310+
```
311+
312+
`h2_recv/1,2` returns `{response, Status, Headers}`, `{data, Data}`,
313+
`{trailers, Trailers}`, or `done` (the peer ended the stream); after `done` it
314+
returns `{error, closed}`. With `{active, true | once}` the same messages are
315+
delivered to the owner as `{hackney_h2, Pid, Msg}` instead (errors as
316+
`{hackney_h2_error, Pid, Reason}`).
317+
318+
Open with `{flow_control, manual}` to apply receive backpressure: the window is
319+
only replenished when you call `h2_consume(Pid, NBytes)` for the bytes you have
320+
processed. The API carries raw bytes; gRPC message framing is the caller's
321+
responsibility.
322+
286323
## Flow Control
287324

288325
HTTP/2 has built-in flow control to prevent fast senders from overwhelming slow receivers. Hackney handles this automatically:

src/hackney.erl

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@
4242
wt_send_datagram/2,
4343
wt_session_info/1]).
4444

45+
%% HTTP/2 bidirectional (gRPC-style) stream API
46+
-export([h2_open/2, h2_open/3, h2_open/4,
47+
h2_send/2, h2_send/3,
48+
h2_send_trailers/2,
49+
h2_recv/1, h2_recv/2,
50+
h2_consume/2,
51+
h2_setopts/2,
52+
h2_close/1]).
53+
4554
-export([redirect_location/1, location/1]).
4655

4756
-export([get_version/0]).
@@ -1023,6 +1032,131 @@ wt_session_info(WtPid) when is_pid(WtPid) ->
10231032
shutdown_wt(WtPid) ->
10241033
try exit(WtPid, shutdown) catch _:_ -> ok end.
10251034

1035+
%%====================================================================
1036+
%% HTTP/2 bidirectional (gRPC-style) stream API
1037+
%%====================================================================
1038+
1039+
%% @doc Open a full-duplex HTTP/2 stream (gRPC-style bidirectional streaming).
1040+
%% Establishes a dedicated HTTP/2 connection (ALPN, so an https URL) and opens
1041+
%% one stream on it. Returns a pid driven with h2_send/h2_recv etc. The method
1042+
%% defaults to POST.
1043+
%%
1044+
%% Options: connect_timeout, recv_timeout, connect_options, ssl_options,
1045+
%% {flow_control, auto | manual}, {active, true | false | once},
1046+
%% {max_recv_buffer, bytes | infinity}.
1047+
-spec h2_open(binary() | string(), list()) -> {ok, pid()} | {error, term()}.
1048+
h2_open(URL, Opts) ->
1049+
h2_open(post, URL, [], Opts).
1050+
1051+
-spec h2_open(binary() | string(), list(), list()) -> {ok, pid()} | {error, term()}.
1052+
h2_open(URL, Headers, Opts) ->
1053+
h2_open(post, URL, Headers, Opts).
1054+
1055+
-spec h2_open(atom() | binary() | string(), binary() | string(), list(), list()) ->
1056+
{ok, pid()} | {error, term()}.
1057+
h2_open(Method, URL, Headers, Opts) ->
1058+
#hackney_url{
1059+
transport = Transport,
1060+
scheme = Scheme,
1061+
host = Host,
1062+
port = Port,
1063+
path = Path0,
1064+
qs = Query
1065+
} = hackney_url:parse_url(URL),
1066+
case Transport of
1067+
hackney_ssl ->
1068+
Path = case Query of
1069+
<<>> -> Path0;
1070+
_ -> <<Path0/binary, "?", Query/binary>>
1071+
end,
1072+
H2Opts = #{
1073+
method => h2_method_bin(Method),
1074+
host => Host,
1075+
port => Port,
1076+
transport => Transport,
1077+
path => Path,
1078+
headers => Headers,
1079+
connect_timeout => proplists:get_value(connect_timeout, Opts, 8000),
1080+
recv_timeout => proplists:get_value(recv_timeout, Opts, infinity),
1081+
connect_options => proplists:get_value(connect_options, Opts, []),
1082+
ssl_options => proplists:get_value(ssl_options, Opts, []),
1083+
flow_control => proplists:get_value(flow_control, Opts, auto),
1084+
active => proplists:get_value(active, Opts, false),
1085+
max_recv_buffer => proplists:get_value(max_recv_buffer, Opts, 16#4000000)
1086+
},
1087+
case hackney_h2_stream:start_link(H2Opts) of
1088+
{ok, Pid} ->
1089+
Timeout = maps:get(connect_timeout, H2Opts),
1090+
try hackney_h2_stream:connect(Pid, Timeout) of
1091+
ok ->
1092+
{ok, Pid};
1093+
{error, Reason} ->
1094+
shutdown_h2(Pid),
1095+
{error, Reason}
1096+
catch
1097+
exit:{timeout, _} ->
1098+
shutdown_h2(Pid),
1099+
{error, connect_timeout};
1100+
exit:{noproc, _} ->
1101+
{error, {h2_process_died, noproc}}
1102+
end;
1103+
{error, Reason} ->
1104+
{error, Reason}
1105+
end;
1106+
_ ->
1107+
{error, {scheme_not_supported, Scheme}}
1108+
end.
1109+
1110+
%% @doc Send a DATA frame on the stream (no END_STREAM).
1111+
-spec h2_send(pid(), iodata()) -> ok | {error, term()}.
1112+
h2_send(Pid, Data) when is_pid(Pid) ->
1113+
hackney_h2_stream:send(Pid, Data).
1114+
1115+
%% @doc Send a DATA frame, optionally half-closing the send side (`fin').
1116+
-spec h2_send(pid(), iodata(), fin | nofin) -> ok | {error, term()}.
1117+
h2_send(Pid, Data, Fin) when is_pid(Pid) ->
1118+
hackney_h2_stream:send(Pid, Data, Fin).
1119+
1120+
%% @doc Send trailing HEADERS, half-closing the send side (gRPC trailers).
1121+
-spec h2_send_trailers(pid(), list()) -> ok | {error, term()}.
1122+
h2_send_trailers(Pid, Trailers) when is_pid(Pid) ->
1123+
hackney_h2_stream:send_trailers(Pid, Trailers).
1124+
1125+
%% @doc Receive the next inbound message: {response, Status, Headers} |
1126+
%% {data, Data} | {trailers, Trailers} | done. After done, returns
1127+
%% {error, closed}. Passive mode only.
1128+
-spec h2_recv(pid()) -> {ok, hackney_h2_stream:h2_msg()} | {error, term()}.
1129+
h2_recv(Pid) when is_pid(Pid) ->
1130+
hackney_h2_stream:recv(Pid).
1131+
1132+
-spec h2_recv(pid(), timeout()) -> {ok, hackney_h2_stream:h2_msg()} | {error, term()}.
1133+
h2_recv(Pid, Timeout) when is_pid(Pid) ->
1134+
hackney_h2_stream:recv(Pid, Timeout).
1135+
1136+
%% @doc Acknowledge N consumed bytes (manual flow control only).
1137+
-spec h2_consume(pid(), non_neg_integer()) -> ok | {error, term()}.
1138+
h2_consume(Pid, NBytes) when is_pid(Pid) ->
1139+
hackney_h2_stream:consume(Pid, NBytes).
1140+
1141+
%% @doc Set options. Supported: [{active, true | false | once}].
1142+
-spec h2_setopts(pid(), list()) -> ok | {error, term()}.
1143+
h2_setopts(Pid, Opts) when is_pid(Pid) ->
1144+
hackney_h2_stream:setopts(Pid, Opts).
1145+
1146+
%% @doc Cancel the stream and tear down its connection.
1147+
-spec h2_close(pid()) -> ok.
1148+
h2_close(Pid) when is_pid(Pid) ->
1149+
hackney_h2_stream:close(Pid).
1150+
1151+
%% @private Normalize an HTTP method to an uppercase binary.
1152+
h2_method_bin(M) when is_binary(M) -> M;
1153+
h2_method_bin(M) when is_atom(M) -> list_to_binary(string:to_upper(atom_to_list(M)));
1154+
h2_method_bin(M) when is_list(M) -> list_to_binary(string:to_upper(M)).
1155+
1156+
%% @private Signal the HTTP/2 stream process to shut down, ignoring errors.
1157+
shutdown_h2(Pid) ->
1158+
try exit(Pid, shutdown) catch _:_ -> ok end.
1159+
10261160
%% @private Reject CR/LF/NUL in the authority, request path, or any
10271161
%% caller-supplied header used in the WebTransport CONNECT request
10281162
%% (GHSA-f9vr analog).

src/hackney_conn.erl

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
send_body_chunk/2,
4343
finish_send_body/1,
4444
start_response/1,
45+
%% HTTP/2 bidirectional stream (handler-routed, for hackney_h2_stream)
46+
open_h2_stream/6,
4547
%% Async streaming
4648
request_async/6,
4749
request_async/7,
@@ -361,6 +363,18 @@ finish_send_body(Pid) ->
361363
start_response(Pid) ->
362364
safe_call(Pid, start_response, infinity).
363365

366+
%% @doc Open an HTTP/2 stream whose events are routed to HandlerPid (the gRPC
367+
%% bidi model), returning the underlying h2_connection pid and stream id so the
368+
%% handler can drive send_data/send_trailers/consume directly. Used by
369+
%% hackney_h2_stream; the stream is not tracked in this gen_statem.
370+
-spec open_h2_stream(pid(), binary(), binary(), list(), pid(), map()) ->
371+
{ok, pid(), pos_integer()} | {error, term()}.
372+
open_h2_stream(Pid, Method, Path, Headers, HandlerPid, Opts) ->
373+
case valid_request_target(Path) of
374+
ok -> safe_call(Pid, {open_h2_stream, Method, Path, Headers, HandlerPid, Opts}, infinity);
375+
Err -> Err
376+
end.
377+
364378
%% @doc Get the full response body.
365379
-spec body(pid()) -> {ok, binary()} | {error, term()}.
366380
body(Pid) ->
@@ -989,6 +1003,25 @@ connected({call, From}, {send_headers, Method, Path, Headers}, #conn_data{protoc
9891003
%% chunks via send_body_chunk/finish_send_body. Mirrors do_h3_send_headers/5.
9901004
do_h2_send_headers(From, Method, Path, Headers, Data);
9911005

1006+
connected({call, From}, {open_h2_stream, Method, Path, Headers, HandlerPid, Opts},
1007+
#conn_data{protocol = http2, h2_conn = H2Conn} = Data) ->
1008+
%% Open a stream routed to HandlerPid (gRPC bidi). The handler owns the
1009+
%% stream end to end; we do not track it in h2_streams. Returns the
1010+
%% h2_connection pid + stream id so the handler drives it directly.
1011+
{_, _, H2Headers} = build_h2_request_headers(Method, Path, Headers, Data),
1012+
FlowControl = maps:get(flow_control, Opts, auto),
1013+
StreamOpts = #{handler => HandlerPid, flow_control => FlowControl},
1014+
Reply = try
1015+
case h2_connection:send_request_headers(H2Conn, H2Headers, false, StreamOpts) of
1016+
{ok, StreamId} -> {ok, H2Conn, StreamId};
1017+
{error, _} = E -> E
1018+
end
1019+
catch
1020+
exit:{ExitReason, _} -> {error, {closed, ExitReason}};
1021+
exit:ExitReason -> {error, {closed, ExitReason}}
1022+
end,
1023+
{keep_state_and_data, [{reply, From, Reply}]};
1024+
9921025
connected({call, From}, {send_headers, Method, Path, Headers}, Data) ->
9931026
%% Send only headers for streaming body mode (HTTP/1.1)
9941027
NewData = Data#conn_data{

0 commit comments

Comments
 (0)