diff --git a/NEWS.md b/NEWS.md index 4fdfe3b1..2ce14456 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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 ------------------ diff --git a/guides/design.md b/guides/design.md index 3a832904..4a762194 100644 --- a/guides/design.md +++ b/guides/design.md @@ -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 | diff --git a/src/hackney.app.src b/src/hackney.app.src index 55f21aed..9dc14222 100644 --- a/src/hackney.app.src +++ b/src/hackney.app.src @@ -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, diff --git a/src/hackney_pool.erl b/src/hackney_pool.erl index e8377964..77ccee70 100644 --- a/src/hackney_pool.erl +++ b/src/hackney_pool.erl @@ -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), @@ -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; @@ -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} @@ -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) -> diff --git a/test/hackney_pool_tests.erl b/test/hackney_pool_tests.erl index b940502c..085145a7 100644 --- a/test/hackney_pool_tests.erl +++ b/test/hackney_pool_tests.erl @@ -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