Skip to content

Commit f62ce83

Browse files
authored
Merge pull request #911 from benoitc/fix/h2-blocking-send-flow-control
Block HTTP/2 request-body sends on flow control
2 parents fc8bd38 + 3818b68 commit f62ce83

8 files changed

Lines changed: 1126 additions & 49 deletions

NEWS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
# NEWS
22

3+
unreleased
4+
----------
5+
6+
### Fixed
7+
8+
- HTTP/2 request bodies larger than the peer's flow control window no longer
9+
fail with `{error, send_buffer_full}`. Body sends now block until the
10+
server opens the window with WINDOW_UPDATE frames, bounded by the new
11+
`send_timeout` request option (default 30000 ms, `infinity` allowed). If
12+
the window never opens the request fails with `{error, timeout}` instead
13+
of hanging, and the abandoned stream is reset (RST_STREAM) so its buffered
14+
body does not linger on a shared connection. Pass `{send_timeout, nonblock}`
15+
to restore the previous non-blocking behavior. Applies to whole-body and
16+
streamed HTTP/2 request bodies; HTTP/1.1 and HTTP/3 are unchanged.
17+
- HTTP/2 async requests now deliver their response messages. The stream
18+
entry stored the internal call reference where the delivery code expected
19+
the `stream_to` pid, so every async HTTP/2 response was silently dropped
20+
and the caller never received `{hackney_response, Ref, ...}` messages.
21+
- HTTP/2 `{async, once}` now honors `stream_next/1` with the same contract
22+
as HTTP/1.1: status and headers are delivered eagerly, then each
23+
`stream_next/1` delivers exactly one message (a body chunk or `done`).
24+
Previously every frame was pushed eagerly, identical to `{async, true}`.
25+
once-mode streams run h2 manual flow control, so a slow consumer keeps the
26+
peer's window closed and in-flight data stays bounded to one window.
27+
- Connections created with `hackney:connect/4` and `{pool, false}` honor a
28+
`{send_timeout, T}` connect option again (`hackney:send_request/2` has no
29+
per-request options channel). Pooled connections keep the constant default
30+
and take the per-request option instead.
31+
332
4.6.1 - 2026-07-15
433
------------------
534

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,8 @@ hackney:get(URL, [], <<>>, [{follow_redirect, true}, {max_redirect, 5}]).
258258
```erlang
259259
hackney:get(URL, [], <<>>, [
260260
{connect_timeout, 5000}, %% Connection timeout
261-
{recv_timeout, 30000} %% Response timeout
261+
{recv_timeout, 30000}, %% Response timeout
262+
{send_timeout, 30000} %% HTTP/2 body send timeout (flow control)
262263
]).
263264
```
264265

guides/http2_guide.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,23 @@ receive
130130
end.
131131
```
132132

133+
With `{async, once}` you pull instead: status and headers arrive eagerly,
134+
then each `hackney:stream_next/1` delivers exactly one message (a body chunk
135+
or `done`). On HTTP/2 the stream uses manual flow control, so data you have
136+
not pulled keeps the server's send window closed and in-flight data stays
137+
bounded to one window.
138+
139+
```erlang
140+
{ok, Ref} = hackney:get(URL, [], <<>>, [{async, once}]),
141+
%% ... receive status and headers as above, then:
142+
ok = hackney:stream_next(Ref),
143+
receive
144+
{hackney_response, Ref, done} -> ok;
145+
{hackney_response, Ref, Chunk} ->
146+
process(Chunk) %% call stream_next/1 again for the next chunk
147+
end.
148+
```
149+
133150
## Connection Multiplexing
134151

135152
HTTP/2 allows multiple concurrent requests on a single connection. Unlike HTTP/1.1 where each request needs its own connection, HTTP/2 multiplexes requests as independent "streams" on a shared connection.
@@ -329,6 +346,24 @@ HTTP/2 has built-in flow control to prevent fast senders from overwhelming slow
329346

330347
No configuration is needed for most use cases.
331348

349+
### Sending Large Bodies
350+
351+
A request body larger than the server's flow control window cannot be sent in one shot: the send waits for the server to open the window with WINDOW_UPDATE frames. Hackney blocks the request until the body is fully handed to the connection, up to `send_timeout` (default 30000 ms). If the server never opens the window, the request fails with `{error, timeout}` instead of hanging.
352+
353+
```erlang
354+
%% Give a slow server more time to drain a large upload
355+
hackney:post(URL, Headers, LargeBody, [{send_timeout, 120000}]).
356+
357+
%% Wait forever
358+
hackney:post(URL, Headers, LargeBody, [{send_timeout, infinity}]).
359+
360+
%% Opt out of blocking: fail fast with {error, send_buffer_full} when the
361+
%% body exceeds the window plus the connection's send buffer
362+
hackney:post(URL, Headers, Body, [{send_timeout, nonblock}]).
363+
```
364+
365+
The option applies to whole-body requests and to streamed bodies sent with `hackney:send_body/2`. HTTP/1.1 and HTTP/3 requests ignore it.
366+
332367
## Error Handling
333368

334369
HTTP/2 specific errors:

src/hackney.erl

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ connect_direct(Transport, Host, Port, Options) ->
140140
transport => Transport,
141141
connect_timeout => proplists:get_value(connect_timeout, Options, 8000),
142142
recv_timeout => proplists:get_value(recv_timeout, Options, 5000),
143+
%% Single-owner connection: seed the conn-level send_timeout so
144+
%% hackney:send_request/2 (which has no options channel) honors it.
145+
%% Pooled connections deliberately skip this (shared conns keep the
146+
%% constant default; per-request ReqOpts override there).
147+
send_timeout => proplists:get_value(send_timeout, Options, 30000),
143148
connect_options => ConnectOpts,
144149
ssl_options => proplists:get_value(ssl_options, Options, [])
145150
},
@@ -499,6 +504,8 @@ start_conn_with_socket_internal(Host, Port, Transport, Socket, Options) ->
499504
socket => Socket,
500505
connect_timeout => proplists:get_value(connect_timeout, Options, 8000),
501506
recv_timeout => proplists:get_value(recv_timeout, Options, 5000),
507+
%% Single-owner tunneled connection: same seeding as connect_direct.
508+
send_timeout => proplists:get_value(send_timeout, Options, 30000),
502509
connect_options => ConnectOpts,
503510
ssl_options => proplists:get_value(ssl_options, Options, []),
504511
no_reuse => NoReuse
@@ -1336,8 +1343,14 @@ sync_request_with_redirect(ConnPid, Method, Path, Headers, Body, WithBody, Optio
13361343
%% Check if this is a streaming body request
13371344
case FinalBody of
13381345
stream ->
1339-
%% For streaming body, just send headers and return immediately
1340-
case hackney_conn:send_request_headers(ConnPid, Method, Path, HeadersList) of
1346+
%% For streaming body, send headers and return immediately. Forward
1347+
%% send_timeout so the body chunks use the caller's deadline even on a
1348+
%% reused pooled connection.
1349+
ReqOpts = case proplists:get_value(send_timeout, Options) of
1350+
undefined -> [];
1351+
SendTimeout -> [{send_timeout, SendTimeout}]
1352+
end,
1353+
case hackney_conn:send_request_headers(ConnPid, Method, Path, HeadersList, ReqOpts) of
13411354
ok -> {ok, ConnPid};
13421355
{error, Reason} -> {error, Reason}
13431356
end;
@@ -1358,10 +1371,16 @@ sync_request_with_redirect_body(ConnPid, Method, Path, HeadersList, FinalBody,
13581371
false -> ReqOpts0
13591372
end,
13601373
%% Pass recv_timeout through to the connection so it's applied per-request
1361-
ReqOpts = case proplists:get_value(recv_timeout, Options) of
1374+
ReqOpts2 = case proplists:get_value(recv_timeout, Options) of
13621375
undefined -> ReqOpts1;
13631376
RecvTimeout -> [{recv_timeout, RecvTimeout} | ReqOpts1]
13641377
end,
1378+
%% Pass send_timeout through so HTTP/2 body sends blocked on flow control
1379+
%% use the caller's deadline
1380+
ReqOpts = case proplists:get_value(send_timeout, Options) of
1381+
undefined -> ReqOpts2;
1382+
SendTimeout -> [{send_timeout, SendTimeout} | ReqOpts2]
1383+
end,
13651384
case hackney_conn:request(ConnPid, Method, Path, HeadersList, FinalBody, infinity, ReqOpts) of
13661385
%% HTTP/2 returns body directly - handle 4-tuple first
13671386
{ok, Status, RespHeaders, RespBody} when Status >= 301, Status =< 303; Status =:= 307; Status =:= 308 ->
@@ -1568,10 +1587,14 @@ async_request(ConnPid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowR
15681587
{FinalHeaders, FinalBody} = encode_body(Headers, Body, []),
15691588
HeadersList = hackney_headers:to_list(FinalHeaders),
15701589
%% Build ReqOpts for recv_timeout (fix for issue #832)
1571-
ReqOpts = case proplists:get_value(recv_timeout, Options) of
1590+
ReqOpts0 = case proplists:get_value(recv_timeout, Options) of
15721591
undefined -> [];
15731592
RecvTimeout -> [{recv_timeout, RecvTimeout}]
15741593
end,
1594+
ReqOpts = case proplists:get_value(send_timeout, Options) of
1595+
undefined -> ReqOpts0;
1596+
SendTimeout -> [{send_timeout, SendTimeout} | ReqOpts0]
1597+
end,
15751598
%% Note: Issue #646 - ownership transfer to StreamTo (when different from caller)
15761599
%% is handled atomically inside hackney_conn:do_request_async
15771600
case hackney_conn:request_async(ConnPid, Method, Path, HeadersList, FinalBody, AsyncMode, StreamTo, FollowRedirect, ReqOpts) of

0 commit comments

Comments
 (0)