Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# NEWS

unreleased
----------

### Fixed

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

4.6.1 - 2026-07-15
------------------

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ hackney:get(URL, [], <<>>, [{follow_redirect, true}, {max_redirect, 5}]).
```erlang
hackney:get(URL, [], <<>>, [
{connect_timeout, 5000}, %% Connection timeout
{recv_timeout, 30000} %% Response timeout
{recv_timeout, 30000}, %% Response timeout
{send_timeout, 30000} %% HTTP/2 body send timeout (flow control)
]).
```

Expand Down
35 changes: 35 additions & 0 deletions guides/http2_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,23 @@ receive
end.
```

With `{async, once}` you pull instead: status and headers arrive eagerly,
then each `hackney:stream_next/1` delivers exactly one message (a body chunk
or `done`). On HTTP/2 the stream uses manual flow control, so data you have
not pulled keeps the server's send window closed and in-flight data stays
bounded to one window.

```erlang
{ok, Ref} = hackney:get(URL, [], <<>>, [{async, once}]),
%% ... receive status and headers as above, then:
ok = hackney:stream_next(Ref),
receive
{hackney_response, Ref, done} -> ok;
{hackney_response, Ref, Chunk} ->
process(Chunk) %% call stream_next/1 again for the next chunk
end.
```

## Connection Multiplexing

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.
Expand Down Expand Up @@ -329,6 +346,24 @@ HTTP/2 has built-in flow control to prevent fast senders from overwhelming slow

No configuration is needed for most use cases.

### Sending Large Bodies

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.

```erlang
%% Give a slow server more time to drain a large upload
hackney:post(URL, Headers, LargeBody, [{send_timeout, 120000}]).

%% Wait forever
hackney:post(URL, Headers, LargeBody, [{send_timeout, infinity}]).

%% Opt out of blocking: fail fast with {error, send_buffer_full} when the
%% body exceeds the window plus the connection's send buffer
hackney:post(URL, Headers, Body, [{send_timeout, nonblock}]).
```

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.

## Error Handling

HTTP/2 specific errors:
Expand Down
31 changes: 27 additions & 4 deletions src/hackney.erl
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ connect_direct(Transport, Host, Port, Options) ->
transport => Transport,
connect_timeout => proplists:get_value(connect_timeout, Options, 8000),
recv_timeout => proplists:get_value(recv_timeout, Options, 5000),
%% Single-owner connection: seed the conn-level send_timeout so
%% hackney:send_request/2 (which has no options channel) honors it.
%% Pooled connections deliberately skip this (shared conns keep the
%% constant default; per-request ReqOpts override there).
send_timeout => proplists:get_value(send_timeout, Options, 30000),
connect_options => ConnectOpts,
ssl_options => proplists:get_value(ssl_options, Options, [])
},
Expand Down Expand Up @@ -499,6 +504,8 @@ start_conn_with_socket_internal(Host, Port, Transport, Socket, Options) ->
socket => Socket,
connect_timeout => proplists:get_value(connect_timeout, Options, 8000),
recv_timeout => proplists:get_value(recv_timeout, Options, 5000),
%% Single-owner tunneled connection: same seeding as connect_direct.
send_timeout => proplists:get_value(send_timeout, Options, 30000),
connect_options => ConnectOpts,
ssl_options => proplists:get_value(ssl_options, Options, []),
no_reuse => NoReuse
Expand Down Expand Up @@ -1336,8 +1343,14 @@ sync_request_with_redirect(ConnPid, Method, Path, Headers, Body, WithBody, Optio
%% Check if this is a streaming body request
case FinalBody of
stream ->
%% For streaming body, just send headers and return immediately
case hackney_conn:send_request_headers(ConnPid, Method, Path, HeadersList) of
%% For streaming body, send headers and return immediately. Forward
%% send_timeout so the body chunks use the caller's deadline even on a
%% reused pooled connection.
ReqOpts = case proplists:get_value(send_timeout, Options) of
undefined -> [];
SendTimeout -> [{send_timeout, SendTimeout}]
end,
case hackney_conn:send_request_headers(ConnPid, Method, Path, HeadersList, ReqOpts) of
ok -> {ok, ConnPid};
{error, Reason} -> {error, Reason}
end;
Expand All @@ -1358,10 +1371,16 @@ sync_request_with_redirect_body(ConnPid, Method, Path, HeadersList, FinalBody,
false -> ReqOpts0
end,
%% Pass recv_timeout through to the connection so it's applied per-request
ReqOpts = case proplists:get_value(recv_timeout, Options) of
ReqOpts2 = case proplists:get_value(recv_timeout, Options) of
undefined -> ReqOpts1;
RecvTimeout -> [{recv_timeout, RecvTimeout} | ReqOpts1]
end,
%% Pass send_timeout through so HTTP/2 body sends blocked on flow control
%% use the caller's deadline
ReqOpts = case proplists:get_value(send_timeout, Options) of
undefined -> ReqOpts2;
SendTimeout -> [{send_timeout, SendTimeout} | ReqOpts2]
end,
case hackney_conn:request(ConnPid, Method, Path, HeadersList, FinalBody, infinity, ReqOpts) of
%% HTTP/2 returns body directly - handle 4-tuple first
{ok, Status, RespHeaders, RespBody} when Status >= 301, Status =< 303; Status =:= 307; Status =:= 308 ->
Expand Down Expand Up @@ -1568,10 +1587,14 @@ async_request(ConnPid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowR
{FinalHeaders, FinalBody} = encode_body(Headers, Body, []),
HeadersList = hackney_headers:to_list(FinalHeaders),
%% Build ReqOpts for recv_timeout (fix for issue #832)
ReqOpts = case proplists:get_value(recv_timeout, Options) of
ReqOpts0 = case proplists:get_value(recv_timeout, Options) of
undefined -> [];
RecvTimeout -> [{recv_timeout, RecvTimeout}]
end,
ReqOpts = case proplists:get_value(send_timeout, Options) of
undefined -> ReqOpts0;
SendTimeout -> [{send_timeout, SendTimeout} | ReqOpts0]
end,
%% Note: Issue #646 - ownership transfer to StreamTo (when different from caller)
%% is handled atomically inside hackney_conn:do_request_async
case hackney_conn:request_async(ConnPid, Method, Path, HeadersList, FinalBody, AsyncMode, StreamTo, FollowRedirect, ReqOpts) of
Expand Down
Loading
Loading