Skip to content

Commit e09a3c4

Browse files
authored
Merge pull request #888 from benoitc/fix/h1-keepalive-pooling
Fix HTTP/1.1 keepalive correctness in connection pooling
2 parents dab8148 + 17434b7 commit e09a3c4

9 files changed

Lines changed: 610 additions & 51 deletions

‎src/hackney_conn.erl‎

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@
157157
request_from :: {pid(), reference()} | undefined,
158158
method :: binary() | undefined,
159159
path :: binary() | undefined,
160+
%% Whether the current request carried Connection: close (the caller asked
161+
%% the server to close). Folded into the keepalive decision at checkin so a
162+
%% requested close is never reused. Reset on every request send.
163+
request_close = false :: boolean(),
160164

161165
%% Response state
162166
version :: {integer(), integer()} | undefined,
@@ -583,9 +587,12 @@ is_no_reuse(Pid) ->
583587

584588
%% @doc Get the flags the pool needs for its checkin decision in one call:
585589
%% whether the connection was SSL upgraded, opted into SSL pooling, must not
586-
%% be reused (proxy tunnels), and the negotiated protocol.
590+
%% be reused (proxy tunnels), the negotiated protocol, whether the response
591+
%% requires the connection to close (keepalive), and whether the socket is
592+
%% proven ready to pool.
587593
-spec checkin_info(pid()) -> #{upgraded_ssl := boolean(), no_reuse := boolean(),
588-
pool_ssl := boolean(), protocol := atom()}.
594+
pool_ssl := boolean(), protocol := atom(),
595+
should_close := boolean(), ready := boolean()}.
589596
checkin_info(Pid) ->
590597
gen_statem:call(Pid, checkin_info).
591598

@@ -921,7 +928,9 @@ connected({call, From}, is_no_reuse, #conn_data{no_reuse = NoReuse}) ->
921928
{keep_state_and_data, [{reply, From, NoReuse}]};
922929

923930
connected({call, From}, checkin_info, Data) ->
924-
{keep_state_and_data, [{reply, From, checkin_info_map(Data)}]};
931+
%% In connected state: prove the socket is ready before the pool may keep it.
932+
Map = (checkin_info_map(Data))#{ready => socket_ready(Data)},
933+
{keep_state_and_data, [{reply, From, Map}]};
925934

926935
connected({call, From}, {request, Method, Path, Headers, Body, ReqOpts}, #conn_data{protocol = http2} = Data) ->
927936
%% HTTP/2 request - use h2_machine (1xx not applicable for HTTP/2)
@@ -1232,10 +1241,15 @@ streaming_body(internal, {send_headers_only, Method, Path, Headers}, Data) ->
12321241
RequestLine = build_request_line(Method, Path),
12331242
HeaderLines = [[Name, <<": ">>, Value, <<"\r\n">>] || {Name, Value} <- HeadersList],
12341243
HeadersData = [RequestLine, HeaderLines, <<"\r\n">>],
1244+
%% Record whether the caller asked the server to close (the request line is
1245+
%% built here, not via do_send_request/5), for the keepalive decision.
1246+
RequestClose = hackney_keepalive:request_closes(HeadersWithTE),
12351247
case Transport:send(Socket, HeadersData) of
12361248
ok ->
12371249
From = Data#conn_data.request_from,
1238-
{keep_state, Data#conn_data{request_from = undefined}, [{reply, From, ok}]};
1250+
{keep_state, Data#conn_data{request_from = undefined,
1251+
request_close = RequestClose},
1252+
[{reply, From, ok}]};
12391253
{error, Reason} ->
12401254
From = Data#conn_data.request_from,
12411255
{next_state, closed, Data, [{reply, From, {error, Reason}}]}
@@ -1840,7 +1854,9 @@ handle_common({call, From}, is_no_reuse, _State, #conn_data{no_reuse = NoReuse})
18401854
{keep_state_and_data, [{reply, From, NoReuse}]};
18411855

18421856
handle_common({call, From}, checkin_info, _State, Data) ->
1843-
{keep_state_and_data, [{reply, From, checkin_info_map(Data)}]};
1857+
%% Any non-connected state (e.g. already closed) is not poolable.
1858+
Map = (checkin_info_map(Data))#{ready => false},
1859+
{keep_state_and_data, [{reply, From, Map}]};
18441860

18451861
handle_common({call, From}, get_protocol, _State, #conn_data{protocol = Protocol}) ->
18461862
{keep_state_and_data, [{reply, From, Protocol}]};
@@ -1894,14 +1910,18 @@ reset_async(Data) ->
18941910
stream_to = undefined
18951911
}.
18961912

1897-
%% @private Check if connection should be closed based on response headers
1898-
should_close_connection(#conn_data{response_headers = undefined}) ->
1913+
%% @private Whether the connection must close (not be reused) for keepalive
1914+
%% reasons. Distinguishes "no response observed yet" (a fresh or idle pooled
1915+
%% conn, which stays poolable) from a parsed response, whose close decision
1916+
%% follows RFC 7230 via hackney_keepalive:should_close/3.
1917+
should_close_connection(#conn_data{response_headers = undefined, version = undefined}) ->
1918+
%% No request/response cycle has run on this connection: keep it poolable so
1919+
%% a plain checkout/checkin (no request) still returns the live conn to the
1920+
%% pool.
18991921
false;
1900-
should_close_connection(#conn_data{response_headers = Headers}) ->
1901-
case hackney_headers:get_value(<<"connection">>, Headers) of
1902-
undefined -> false;
1903-
Value -> hackney_bstr:to_lower(Value) =:= <<"close">>
1904-
end.
1922+
should_close_connection(#conn_data{version = Version, response_headers = Headers,
1923+
request_close = RequestClose}) ->
1924+
hackney_keepalive:should_close(Version, Headers, RequestClose).
19051925

19061926
%% @private Finish async streaming - close or return to connected based on Connection header
19071927
finish_async_streaming(Data) ->
@@ -1960,6 +1980,10 @@ do_send_request(Method, Path, Headers, Body, Data) ->
19601980
%% Build request headers
19611981
FinalHeaders = build_headers(Method, Headers, Body, Netloc),
19621982

1983+
%% Record whether the caller asked the server to close, for the keepalive
1984+
%% decision at checkin. Reset per request.
1985+
Data1 = Data#conn_data{request_close = hackney_keepalive:request_closes(FinalHeaders)},
1986+
19631987
%% Build request line and headers
19641988
Path1 = case Path of
19651989
<<>> -> <<"/">>;
@@ -1975,7 +1999,7 @@ do_send_request(Method, Path, Headers, Body, Data) ->
19751999
%% Send body if present
19762000
case send_body(Transport, Socket, Body) of
19772001
ok ->
1978-
{ok, Data};
2002+
{ok, Data1};
19792003
{error, Reason} ->
19802004
{error, Reason}
19812005
end;
@@ -2428,6 +2452,16 @@ has_pending_close(Socket) ->
24282452
false
24292453
end.
24302454

2455+
%% @private Whether the socket is proven ready to pool: present, no queued close,
2456+
%% and still connected. Runs in the conn process (peername + receive-after-0,
2457+
%% both fast); consuming a queued close here makes a server-closed idle conn
2458+
%% report not-ready. Does not disable active-once, so no stranded-byte regression.
2459+
socket_ready(#conn_data{socket = undefined}) ->
2460+
false;
2461+
socket_ready(#conn_data{transport = Transport, socket = Socket}) ->
2462+
not has_pending_close(Socket) andalso
2463+
check_socket_health(Transport, Socket) =:= ok.
2464+
24312465
%% @private Drain and return any {tcp/ssl, Socket, Data} bytes queued in the
24322466
%% mailbox. #544 puts idle pooled sockets in {active, once}, which delivers the
24332467
%% next inbound bytes (the start of the response on a reused connection) as a
@@ -2478,16 +2512,24 @@ notify_pool_available_sync(#conn_data{pool_pid = undefined}) ->
24782512
ok;
24792513
notify_pool_available_sync(#conn_data{pool_pid = PoolPid, upgraded_ssl = UpgradedSsl,
24802514
no_reuse = NoReuse, pool_ssl = PoolSsl,
2481-
protocol = Protocol}) ->
2515+
protocol = Protocol} = Data) ->
2516+
%% do_checkin_with_close_flag/3 pools whenever this flag is false, with no
2517+
%% further check, so also close on a keepalive-close response and on an
2518+
%% unready socket - matching the async-cast checkin gate.
24822519
ShouldClose = NoReuse orelse (UpgradedSsl andalso not PoolSsl)
2483-
orelse Protocol =/= http1,
2520+
orelse Protocol =/= http1
2521+
orelse should_close_connection(Data)
2522+
orelse not socket_ready(Data),
24842523
gen_server:call(PoolPid, {checkin_sync, self(), ShouldClose}, 5000).
24852524

2486-
%% @private Flags for the pool's checkin decision, see checkin_info/1.
2525+
%% @private Flags for the pool's checkin decision, see checkin_info/1. The
2526+
%% `ready' flag is added per-state by the checkin_info handlers, since it
2527+
%% depends on the conn being in `connected' with a healthy socket.
24872528
checkin_info_map(#conn_data{upgraded_ssl = UpgradedSsl, no_reuse = NoReuse,
2488-
pool_ssl = PoolSsl, protocol = Protocol}) ->
2529+
pool_ssl = PoolSsl, protocol = Protocol} = Data) ->
24892530
#{upgraded_ssl => UpgradedSsl, no_reuse => NoReuse,
2490-
pool_ssl => PoolSsl, protocol => Protocol}.
2531+
pool_ssl => PoolSsl, protocol => Protocol,
2532+
should_close => should_close_connection(Data)}.
24912533

24922534
%% @private Start an async request
24932535
do_request_async(From, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, Data) ->

‎src/hackney_keepalive.erl‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
%%% -*- erlang -*-
2+
%%%
3+
%%% This file is part of hackney released under the Apache 2 license.
4+
%%% See the NOTICE for more information.
5+
%%%
6+
%%% Copyright (c) 2012-2024, Benoît Chesneau <benoitc@e-engura.org>
7+
8+
%% @doc HTTP/1.x keepalive semantics.
9+
%%
10+
%% Single source of truth for deciding whether an HTTP/1.x connection must be
11+
%% closed (not reused) after a response, per RFC 7230. The `Connection' header
12+
%% is a list-valued, hop-by-hop field (RFC 7230 3.2.2, 6.1): a recipient may get
13+
%% it as several header lines or as one comma-joined value, and both are
14+
%% equivalent. It is forbidden in HTTP/2 and HTTP/3, so these rules apply only to
15+
%% HTTP/1.x; multiplexed conns are never pooled in `available'.
16+
%%
17+
%% Every function tolerates undefined or malformed header objects so a bad header
18+
%% can never crash the keepalive decision.
19+
-module(hackney_keepalive).
20+
21+
-export([should_close/3,
22+
request_closes/1,
23+
connection_tokens/1]).
24+
25+
%% @doc Whether a parsed HTTP/1.x response means the connection must close.
26+
%%
27+
%% The caller (hackney_conn:should_close_connection/1) guards the "no response
28+
%% observed yet" case; here `Version'/`RespHeaders' describe a response that was
29+
%% actually parsed. Order matters: an explicit `close' wins over version default.
30+
-spec should_close(Version, RespHeaders, RequestClose) -> boolean() when
31+
Version :: {integer(), integer()} | undefined,
32+
RespHeaders :: term(),
33+
RequestClose :: boolean().
34+
should_close(_Version, _RespHeaders, true) ->
35+
%% We asked the server to close (request carried Connection: close).
36+
true;
37+
should_close(Version, RespHeaders, false) ->
38+
Tokens = connection_tokens(RespHeaders),
39+
case lists:member(<<"close">>, Tokens) of
40+
true ->
41+
true;
42+
false ->
43+
case Version of
44+
{1, 1} ->
45+
%% HTTP/1.1 default is keep-alive; an absent Connection header
46+
%% stays persistent and poolable.
47+
false;
48+
{1, 0} ->
49+
%% HTTP/1.0 default is close unless it opts into keep-alive.
50+
not lists:member(<<"keep-alive">>, Tokens);
51+
_ ->
52+
%% Unknown version on a parsed response: close on the safe side.
53+
true
54+
end
55+
end.
56+
57+
%% @doc Whether request headers carry `Connection: close'.
58+
-spec request_closes(term()) -> boolean().
59+
request_closes(ReqHeaders) ->
60+
lists:member(<<"close">>, connection_tokens(ReqHeaders)).
61+
62+
%% @doc Lower-cased, trimmed tokens from every `Connection' header.
63+
%%
64+
%% Defensive at each layer: an undefined or malformed header object yields `[]',
65+
%% and a value that does not convert to a binary is skipped rather than crashing.
66+
-spec connection_tokens(term()) -> [binary()].
67+
connection_tokens(undefined) ->
68+
[];
69+
connection_tokens(Headers) ->
70+
Values = try hackney_headers:lookup(<<"connection">>, Headers)
71+
catch _:_ -> []
72+
end,
73+
lists:flatmap(fun({_Key, Value}) -> value_tokens(Value) end, Values).
74+
75+
%% @private
76+
value_tokens(Value) ->
77+
case (try hackney_bstr:to_binary(Value) catch _:_ -> error end) of
78+
error ->
79+
[];
80+
Bin ->
81+
Lower = hackney_bstr:to_lower(Bin),
82+
Parts = binary:split(Lower, <<",">>, [global]),
83+
Trimmed = [hackney_bstr:trim(P) || P <- Parts],
84+
[T || T <- Trimmed, T =/= <<>>]
85+
end.

‎src/hackney_pool.erl‎

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -874,17 +874,12 @@ h3_connection_key(Host0, Port, Transport, Options) ->
874874
stop_conn(Pid) ->
875875
try hackney_conn:stop(Pid) catch _:_ -> ok end.
876876

877+
%% @private Find a reusable idle connection for `Key', discarding any that are
878+
%% no longer keepalive-ready. Only a conn that is_ready reports `{ok, connected}'
879+
%% is handed out; a closed conn is stopped and dropped (never reanimated). Fresh
880+
%% dialing for an empty bucket is the caller's `none' branch, off the pool's hot
881+
%% path. The SSL alias exists only to mark intent at the SSL checkout site.
877882
find_available(Key, Available) ->
878-
find_available(Key, Available, true).
879-
880-
%% @private Like find_available/2 but never redials a closed connection.
881-
%% Used for SSL buckets: hackney_conn:connect/1 on an upgraded conn would
882-
%% reconnect the raw transport without the upgrade handshake options, so a
883-
%% closed conn is stopped and dropped instead.
884-
find_available_ssl(Key, Available) ->
885-
find_available(Key, Available, false).
886-
887-
find_available(Key, Available, Redial) ->
888883
case maps:find(Key, Available) of
889884
{ok, [Pid | Rest]} ->
890885
Available2 = case Rest of
@@ -896,34 +891,36 @@ find_available(Key, Available, Redial) ->
896891
true ->
897892
%% is_ready checks both state and socket health in one call.
898893
%% The connection can die between is_process_alive/1 above
899-
%% and these gen_statem calls (flaky network); the resulting
894+
%% and this gen_statem call (flaky network); the resulting
900895
%% noproc exit must not crash the pool, so skip and move on.
901896
try hackney_conn:is_ready(Pid) of
902-
{ok, connected} -> {ok, Pid, Available2};
903-
{ok, closed} when Redial ->
904-
%% Connection closed, try reconnect
905-
try hackney_conn:connect(Pid) of
906-
ok -> {ok, Pid, Available2};
907-
_ -> find_available(Key, Available2, Redial)
908-
catch
909-
_:_ -> find_available(Key, Available2, Redial)
910-
end;
911-
{ok, closed} ->
897+
{ok, connected} ->
898+
{ok, Pid, Available2};
899+
_ ->
900+
%% Closed or unusable: discard it rather than redial
901+
%% from inside the pool. Reanimating a closed pid would
902+
%% break the "only keepalive conns are reused" invariant
903+
%% and a redial here would block the pool on connect.
912904
stop_conn(Pid),
913-
find_available(Key, Available2, Redial);
914-
_ -> find_available(Key, Available2, Redial)
905+
find_available(Key, Available2)
915906
catch
916-
_:_ -> find_available(Key, Available2, Redial)
907+
_:_ -> find_available(Key, Available2)
917908
end;
918909
false ->
919-
find_available(Key, Available2, Redial)
910+
find_available(Key, Available2)
920911
end;
921912
{ok, []} ->
922913
none;
923914
error ->
924915
none
925916
end.
926917

918+
%% @private SSL-bucket variant. Now identical to find_available/2 (closed conns
919+
%% are always dropped, never redialed); kept as a named alias to mark intent at
920+
%% the SSL checkout site.
921+
find_available_ssl(Key, Available) ->
922+
find_available(Key, Available).
923+
927924
%% @private SSL checkout miss: reuse or dial a TCP connection for the caller
928925
%% to upgrade. It is recorded in in_use under the SSL key so the checkin
929926
%% decision can tell it apart from plain TCP checkouts.
@@ -1031,9 +1028,14 @@ do_checkin(Pid, State) ->
10311028
%% Check if connection is still alive
10321029
case is_process_alive(Pid) of
10331030
true ->
1034-
%% One call fetches every flag the decision needs
1035-
Info = try hackney_conn:checkin_info(Pid) catch _:_ -> #{} end,
1036-
case checkin_poolable(Key, Info) andalso pool_has_idle_room(State) of
1031+
%% One call fetches every flag the decision needs. A failed
1032+
%% checkin_info means we cannot prove the conn keepalive/ready,
1033+
%% so treat it as not poolable (close) rather than pooling blind.
1034+
Poolable = case checkin_info(Pid) of
1035+
{ok, Info} -> checkin_poolable(Key, Info);
1036+
error -> false
1037+
end,
1038+
case Poolable andalso pool_has_idle_room(State) of
10371039
true ->
10381040
checkin_pool(Pid, Key, InUse2, State);
10391041
false ->
@@ -1060,10 +1062,27 @@ do_checkin(Pid, State) ->
10601062
checkin_poolable({_Host, _Port, hackney_ssl, _TlsKey}, Info) ->
10611063
maps:get(no_reuse, Info, true) =:= false andalso
10621064
maps:get(upgraded_ssl, Info, false) =:= true andalso
1063-
maps:get(protocol, Info, undefined) =:= http1;
1065+
maps:get(protocol, Info, undefined) =:= http1 andalso
1066+
keepalive_ready(Info);
10641067
checkin_poolable(_TcpKey, Info) ->
10651068
maps:get(no_reuse, Info, false) =:= false andalso
1066-
maps:get(upgraded_ssl, Info, false) =:= false.
1069+
maps:get(upgraded_ssl, Info, false) =:= false andalso
1070+
keepalive_ready(Info).
1071+
1072+
%% @private Fetch the conn's checkin flags, or `error' if the call fails (the
1073+
%% conn died between is_process_alive/1 and here). Caller treats `error' as
1074+
%% not poolable.
1075+
checkin_info(Pid) ->
1076+
try {ok, hackney_conn:checkin_info(Pid)}
1077+
catch _:_ -> error
1078+
end.
1079+
1080+
%% @private Shared keepalive/readiness gate for checkin: only pool a conn whose
1081+
%% response left it reusable and whose socket is proven ready. Defaults are the
1082+
%% safe side so an unknown flag closes rather than pools.
1083+
keepalive_ready(Info) ->
1084+
maps:get(should_close, Info, true) =:= false andalso
1085+
maps:get(ready, Info, false) =:= true.
10671086

10681087
%% @private Close branch of a checkin: drop the monitor and keep the host's
10691088
%% TCP prewarm warm (the replacement for a closed conn is a TCP conn that

‎test/hackney_integration_tests_async_long_headers.erl‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ dummy_server_loop(LSock, Port, StatusCode) ->
8484
]),
8585
send(Sock, Response),
8686
ok = gen_tcp:shutdown(Sock, read_write),
87-
dummy_server_loop(LSock, RedirectUrl, StatusCode).
87+
%% Recurse with Port (not RedirectUrl): the loop must keep accepting. Pool
88+
%% prewarm reconnects to this host after a closed conn is checked in, so a
89+
%% second accept happens; passing the binary URL here crashed integer_to_list.
90+
dummy_server_loop(LSock, Port, StatusCode).
8891

8992
send(Sock, << Data :128/binary, Rest/binary>>) ->
9093
ok = gen_tcp:send(Sock, Data),

0 commit comments

Comments
 (0)