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
21 changes: 21 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# NEWS

4.4.1 - 2026-06-16
------------------

### Fixed

- Pool checkout no longer fails with `checkout_timeout` when a connection from
a just-completed request has not yet been checked back in. `pool_size` /
`max_connections` now bounds the warm (idle) pool kept for reuse; per-host
concurrency is capped by `max_per_host`. A request beyond the warm pool size
opens an overflow connection that is closed at checkin instead of being
pooled. Set `max_per_host` to cap concurrent connections to a host.

### Dependencies

- webtransport 0.4.0 -> ~> 0.4.1, h2 ~> 0.10.0 -> ~> 0.10.1, quic 1.6.5 ->
~> 1.6.5. The exact webtransport 0.4.0 pin required h2 0.9.0, conflicting
with hackney's own h2 ~> 0.10.0 and breaking installation on strict
resolvers. webtransport 0.4.1 relaxes that requirement; the ranges now
accept any 0.4.x / 0.10.x / 1.6.x patch release without a further bump.
(#879)

4.4.0 - 2026-06-13
------------------

Expand Down
2 changes: 1 addition & 1 deletion guides/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ hackney_load_regulation:current(Host, Port).

| Option | Default | Description |
|--------|---------|-------------|
| `pool_size` / `max_connections` | 50 | Max connections in pool |
| `pool_size` / `max_connections` | 50 | Warm (idle) connections kept for reuse. Concurrency is capped by `max_per_host`; requests above this open overflow connections that close at checkin |
| `timeout` / `keepalive_timeout` | 2000 | Idle timeout (max 2000ms) |
| `prewarm_count` | 4 | Connections to maintain per host |

Expand Down
2 changes: 1 addition & 1 deletion src/hackney.app.src
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{application, hackney,
[
{description, "Simple HTTP client with HTTP/1.1, HTTP/2, and HTTP/3 support"},
{vsn, "4.4.0"},
{vsn, "4.4.1"},
{registered, [hackney_pool]},
{applications, [kernel,
stdlib,
Expand Down
41 changes: 32 additions & 9 deletions src/hackney_pool.erl
Original file line number Diff line number Diff line change
Expand Up @@ -556,14 +556,15 @@ handle_call({checkout, Key, Requester, Opts}, _From, State) ->
{reply, {error, Reason}, State#state{available=Available2}}
end
end;
none when TotalInUse >= MaxConn ->
%% At max connections - return error immediately (no queue)
%% Note: In the new architecture, load_regulation handles waiting
?report_trace("pool: at max connections", [{pool, PoolName}, {in_use, TotalInUse}]),
{reply, {error, checkout_timeout}, State};
none ->
%% No available connection, start a new one
?report_trace("pool: starting new connection", [{pool, PoolName}]),
%% No pooled connection available. Per-host concurrency is already
%% capped by hackney_load_regulation, so start a connection even
%% when in_use has reached max_connections: it is an overflow
%% connection, closed at checkin rather than pooled (see do_checkin).
%% max_connections bounds the warm/idle pool, not the number of
%% concurrent connections.
?report_trace("pool: starting new connection",
[{pool, PoolName}, {overflow, TotalInUse >= MaxConn}]),
case start_connection(Key, Requester, Opts, State) of
{ok, Pid, State2} ->
InUse2 = maps:put(Pid, Key, State2#state.in_use),
Expand Down Expand Up @@ -1030,10 +1031,12 @@ do_checkin(Pid, State) ->
true ->
%% One call fetches every flag the decision needs
Info = try hackney_conn:checkin_info(Pid) catch _:_ -> #{} end,
case checkin_poolable(Key, Info) of
case checkin_poolable(Key, Info) andalso pool_has_idle_room(State) of
true ->
checkin_pool(Pid, Key, InUse2, State);
false ->
%% Not poolable, or the warm pool is already full
%% (overflow connection): close rather than pool.
stop_conn(Pid),
checkin_close(Pid, Key, InUse2, State)
end;
Expand Down Expand Up @@ -1127,7 +1130,16 @@ do_checkin_with_close_flag(Pid, ShouldClose, State) ->
gen_statem:cast(Pid, stop),
checkin_close(Pid, Key, InUse2, State);
_ ->
checkin_pool(Pid, Key, InUse2, State)
%% Pool only if the warm pool has room; otherwise
%% close this overflow connection. Use cast - the
%% connection is blocked waiting for our reply.
case pool_has_idle_room(State) of
true ->
checkin_pool(Pid, Key, InUse2, State);
false ->
gen_statem:cast(Pid, stop),
checkin_close(Pid, Key, InUse2, State)
end
end;
false ->
State#state{in_use=InUse2}
Expand All @@ -1136,6 +1148,17 @@ do_checkin_with_close_flag(Pid, ShouldClose, State) ->
State
end.

%% @private Is there room in the warm/idle pool for one more connection?
%% `available' holds idle pooled connections; their count is capped at
%% max_connections. Concurrency above that is served by overflow connections
%% at checkout, which are closed here instead of pooled.
pool_has_idle_room(#state{available=Available, max_connections=MaxConn}) ->
idle_count(Available) < MaxConn.

%% @private Number of idle (pooled) connections across all host buckets.
idle_count(Available) ->
maps:fold(fun(_, Pids, Acc) -> Acc + length(Pids) end, 0, Available).

%% @private Check that a pooled HTTP/2 conn is alive and in `connected` state.
%% Short timeout so a stuck conn doesn't wedge the pool; any failure → unusable.
h2_conn_usable(Pid) ->
Expand Down
48 changes: 24 additions & 24 deletions test/hackney_pool_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -708,40 +708,40 @@ test_prewarm() ->
%%====================================================================

test_queue_timeout() ->
%% Test that when pool is full, second checkout returns error immediately
%% (no more queuing - load regulation handles waiting)
%% pool_size bounds the warm (idle) pool, not concurrency. Per-host
%% concurrency is capped by max_per_host, so with pool_size=1 a second
%% concurrent request opens an overflow connection instead of failing.
URL = <<"http://localhost:8123/pool">>,
Headers = [],
hackney_pool:start_pool(pool_test, [{pool_size, 1}]),
Opts = [{pool, pool_test}, {connect_timeout, 100}, {checkout_timeout, 5000}],
case hackney:request(post, URL, Headers, stream, Opts) of
{ok, Ref} ->
%% Second request should fail immediately (pool full, no queue)
{error, checkout_timeout} = hackney:request(post, URL, Headers, stream, Opts),

%% Finish first request - body is returned directly with start_response
ok = hackney:finish_send_body(Ref),
{ok, _Status, _Headers, _Body} = hackney:start_response(Ref),
%% Close the connection to release it back to pool
hackney:close(Ref),

%% Now pool is free, next request should succeed
{ok, Ref2} = hackney:request(post, URL, Headers, stream, Opts),
hackney:close(Ref2)
end,
{ok, Ref1} = hackney:request(post, URL, Headers, stream, Opts),
%% Second concurrent request succeeds via an overflow connection
{ok, Ref2} = hackney:request(post, URL, Headers, stream, Opts),

%% Complete both requests
ok = hackney:finish_send_body(Ref1),
{ok, _S1, _H1, _B1} = hackney:start_response(Ref1),
hackney:close(Ref1),
ok = hackney:finish_send_body(Ref2),
{ok, _S2, _H2, _B2} = hackney:start_response(Ref2),
hackney:close(Ref2),

hackney_pool:stop_pool(pool_test).

test_checkout_timeout() ->
%% checkout_timeout now comes from load regulation, the hard per-host
%% concurrency cap. Cap it at 1 so the second concurrent request waits for
%% a slot and times out.
URL = <<"http://localhost:8123/pool">>,
Headers = [],
hackney_pool:start_pool(pool_test_timeout, [{pool_size, 1}]),
Opts = [{max_body, 2048}, {pool, pool_test_timeout}, {connect_timeout, 1000}, {checkout_timeout, 100}],
case hackney:request(post, URL, Headers, stream, Opts) of
{ok, Ref} ->
{error, Error} = hackney:request(post, URL, Headers, stream, Opts),
hackney:close(Ref),
?assertEqual(Error, checkout_timeout)
end,
Opts = [{max_body, 2048}, {pool, pool_test_timeout}, {max_per_host, 1},
{connect_timeout, 1000}, {checkout_timeout, 100}],
{ok, Ref} = hackney:request(post, URL, Headers, stream, Opts),
{error, Error} = hackney:request(post, URL, Headers, stream, Opts),
hackney:close(Ref),
?assertEqual(checkout_timeout, Error),
hackney_pool:stop_pool(pool_test_timeout).

%% Test for issue #544: Server closes idle connection, pool should detect it
Expand Down
Loading