Skip to content

Commit 88348a0

Browse files
authored
Merge pull request #833 from benoitc/fix/recv-timeout-pooled-connections
fix: recv_timeout option now respected for pooled connections
2 parents ed809af + 2b074ea commit 88348a0

4 files changed

Lines changed: 174 additions & 10 deletions

File tree

src/hackney.erl

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,7 @@ do_request(ConnPid, Method, Path, Headers0, Body, Options, URL, Host) ->
807807
Options, URL, FollowRedirect, MaxRedirect, RedirectCount);
808808
_ ->
809809
%% Async request with optional redirect handling
810-
async_request(ConnPid, MethodBin, Path, Headers3, Body, Async, StreamTo, FollowRedirect)
810+
async_request(ConnPid, MethodBin, Path, Headers3, Body, Async, StreamTo, FollowRedirect, Options)
811811
end,
812812

813813
case Result of
@@ -850,10 +850,15 @@ sync_request_with_redirect_body(ConnPid, Method, Path, HeadersList, FinalBody,
850850
undefined -> [];
851851
InformFun -> [{inform_fun, InformFun}]
852852
end,
853-
ReqOpts = case proplists:get_value(auto_decompress, Options, false) of
853+
ReqOpts1 = case proplists:get_value(auto_decompress, Options, false) of
854854
true -> [{auto_decompress, true} | ReqOpts0];
855855
false -> ReqOpts0
856856
end,
857+
%% Pass recv_timeout through to the connection so it's applied per-request
858+
ReqOpts = case proplists:get_value(recv_timeout, Options) of
859+
undefined -> ReqOpts1;
860+
RecvTimeout -> [{recv_timeout, RecvTimeout} | ReqOpts1]
861+
end,
857862
case hackney_conn:request(ConnPid, Method, Path, HeadersList, FinalBody, infinity, ReqOpts) of
858863
%% HTTP/2 returns body directly - handle 4-tuple first
859864
{ok, Status, RespHeaders, RespBody} when Status >= 301, Status =< 303; Status =:= 307; Status =:= 308 ->
@@ -1055,13 +1060,18 @@ maybe_strip_auth_on_redirect(CurrentURL, NewURL, Options) ->
10551060
end
10561061
end.
10571062

1058-
async_request(ConnPid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect) ->
1063+
async_request(ConnPid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, Options) ->
10591064
%% Handle body encoding
10601065
{FinalHeaders, FinalBody} = encode_body(Headers, Body, []),
10611066
HeadersList = hackney_headers:to_list(FinalHeaders),
1067+
%% Build ReqOpts for recv_timeout (fix for issue #832)
1068+
ReqOpts = case proplists:get_value(recv_timeout, Options) of
1069+
undefined -> [];
1070+
RecvTimeout -> [{recv_timeout, RecvTimeout}]
1071+
end,
10621072
%% Note: Issue #646 - ownership transfer to StreamTo (when different from caller)
10631073
%% is handled atomically inside hackney_conn:do_request_async
1064-
case hackney_conn:request_async(ConnPid, Method, Path, HeadersList, FinalBody, AsyncMode, StreamTo, FollowRedirect) of
1074+
case hackney_conn:request_async(ConnPid, Method, Path, HeadersList, FinalBody, AsyncMode, StreamTo, FollowRedirect, ReqOpts) of
10651075
{ok, Ref} ->
10661076
{ok, Ref};
10671077
{error, Reason} ->

src/hackney_conn.erl

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
request_async/6,
4747
request_async/7,
4848
request_async/8,
49+
request_async/9,
4950
stream_next/1,
5051
stop_async/1,
5152
pause_stream/1,
@@ -324,6 +325,11 @@ request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo) ->
324325
request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect) ->
325326
gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect}).
326327

328+
-spec request_async(pid(), binary(), binary(), list(), binary() | iolist(), true | once, pid(), boolean(), list()) ->
329+
{ok, reference()} | {error, term()}.
330+
request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts) ->
331+
gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts}).
332+
327333
%% @doc Request the next message in {async, once} mode.
328334
-spec stream_next(pid()) -> ok | {error, term()}.
329335
stream_next(Pid) ->
@@ -794,13 +800,19 @@ connected({call, From}, is_upgraded_ssl, #conn_data{upgraded_ssl = Upgraded}) ->
794800
connected({call, From}, is_no_reuse, #conn_data{no_reuse = NoReuse}) ->
795801
{keep_state_and_data, [{reply, From, NoReuse}]};
796802

797-
connected({call, From}, {request, Method, Path, Headers, Body, _ReqOpts}, #conn_data{protocol = http2} = Data) ->
803+
connected({call, From}, {request, Method, Path, Headers, Body, ReqOpts}, #conn_data{protocol = http2} = Data) ->
798804
%% HTTP/2 request - use h2_machine (1xx not applicable for HTTP/2)
799-
do_h2_request(From, Method, Path, Headers, Body, Data);
805+
%% Allow recv_timeout to be overridden per-request (fix for issue #832)
806+
RecvTimeout = proplists:get_value(recv_timeout, ReqOpts, Data#conn_data.recv_timeout),
807+
NewData = Data#conn_data{recv_timeout = RecvTimeout},
808+
do_h2_request(From, Method, Path, Headers, Body, NewData);
800809

801-
connected({call, From}, {request, Method, Path, Headers, Body, _ReqOpts}, #conn_data{protocol = http3} = Data) ->
810+
connected({call, From}, {request, Method, Path, Headers, Body, ReqOpts}, #conn_data{protocol = http3} = Data) ->
802811
%% HTTP/3 request - use hackney_h3 (1xx not applicable for HTTP/3)
803-
do_h3_request(From, Method, Path, Headers, Body, Data);
812+
%% Allow recv_timeout to be overridden per-request (fix for issue #832)
813+
RecvTimeout = proplists:get_value(recv_timeout, ReqOpts, Data#conn_data.recv_timeout),
814+
NewData = Data#conn_data{recv_timeout = RecvTimeout},
815+
do_h3_request(From, Method, Path, Headers, Body, NewData);
804816

805817
connected({call, From}, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo}, #conn_data{protocol = http2} = Data) ->
806818
%% HTTP/2 async request
@@ -826,6 +838,8 @@ connected({call, From}, {request, Method, Path, Headers, Body, ReqOpts}, Data) -
826838
%% HTTP/1.1 request
827839
InformFun = proplists:get_value(inform_fun, ReqOpts, undefined),
828840
AutoDecompress = proplists:get_value(auto_decompress, ReqOpts, false),
841+
%% Allow recv_timeout to be overridden per-request (fix for issue #832)
842+
RecvTimeout = proplists:get_value(recv_timeout, ReqOpts, Data#conn_data.recv_timeout),
829843
NewData = Data#conn_data{
830844
request_from = From,
831845
method = Method,
@@ -840,7 +854,8 @@ connected({call, From}, {request, Method, Path, Headers, Body, ReqOpts}, Data) -
840854
async_ref = undefined,
841855
stream_to = undefined,
842856
inform_fun = InformFun,
843-
auto_decompress = AutoDecompress
857+
auto_decompress = AutoDecompress,
858+
recv_timeout = RecvTimeout
844859
},
845860
{next_state, sending, NewData, [{next_event, internal, {send_request, Method, Path, Headers, Body}}]};
846861

@@ -849,9 +864,27 @@ connected({call, From}, {request_async, Method, Path, Headers, Body, AsyncMode,
849864
do_request_async(From, Method, Path, Headers, Body, AsyncMode, StreamTo, false, Data);
850865

851866
connected({call, From}, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect}, Data) ->
852-
%% Start a new async request with redirect option
867+
%% Start a new async request with redirect option (HTTP/1.1)
853868
do_request_async(From, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, Data);
854869

870+
connected({call, From}, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts}, #conn_data{protocol = http2} = Data) ->
871+
%% HTTP/2 async request with ReqOpts (fix for issue #832)
872+
RecvTimeout = proplists:get_value(recv_timeout, ReqOpts, Data#conn_data.recv_timeout),
873+
NewData = Data#conn_data{recv_timeout = RecvTimeout},
874+
do_h2_request_async(From, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, NewData);
875+
876+
connected({call, From}, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, _FollowRedirect, ReqOpts}, #conn_data{protocol = http3} = Data) ->
877+
%% HTTP/3 async request with ReqOpts (fix for issue #832, redirect not yet implemented for H3)
878+
RecvTimeout = proplists:get_value(recv_timeout, ReqOpts, Data#conn_data.recv_timeout),
879+
NewData = Data#conn_data{recv_timeout = RecvTimeout},
880+
do_h3_request_async(From, Method, Path, Headers, Body, AsyncMode, StreamTo, NewData);
881+
882+
connected({call, From}, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts}, Data) ->
883+
%% HTTP/1.1 async request with ReqOpts (fix for issue #832)
884+
RecvTimeout = proplists:get_value(recv_timeout, ReqOpts, Data#conn_data.recv_timeout),
885+
NewData = Data#conn_data{recv_timeout = RecvTimeout},
886+
do_request_async(From, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, NewData);
887+
855888
connected({call, From}, {send_headers, Method, Path, Headers}, #conn_data{protocol = http3} = Data) ->
856889
%% HTTP/3 streaming body - send headers only via QUIC
857890
do_h3_send_headers(From, Method, Path, Headers, Data);

test/delay_handler.erl

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
%%% -*- erlang -*-
2+
%%%
3+
%%% Test resource that delays before responding
4+
%%% Used to test timeout behavior
5+
6+
-module(delay_handler).
7+
8+
-export([init/2]).
9+
10+
init(Req0, State) ->
11+
%% Extract delay seconds from path binding
12+
Seconds = cowboy_req:binding(seconds, Req0),
13+
DelayMs = binary_to_integer(Seconds) * 1000,
14+
15+
%% Sleep to simulate slow response
16+
timer:sleep(DelayMs),
17+
18+
%% Return response
19+
Body = <<"{\"delayed\": true}">>,
20+
Req = cowboy_req:reply(200, #{
21+
<<"content-type">> => <<"application/json">>
22+
}, Body, Req0),
23+
{ok, Req, State}.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
%%% -*- erlang -*-
2+
%%%
3+
%%% Test for recv_timeout option with connection pooling
4+
%%%
5+
%%% Bug: When using connection pooling, the recv_timeout option passed in
6+
%%% hackney:request/5 is ignored. The connection uses its original timeout
7+
%%% from when it was created.
8+
%%%
9+
%%% Expected: Each request should respect its own recv_timeout option
10+
%%% Actual: Pooled connections use the timeout they were created with
11+
12+
-module(hackney_recv_timeout_tests).
13+
14+
-include_lib("eunit/include/eunit.hrl").
15+
16+
-define(PORT, 8125).
17+
-define(URL(Path), "http://127.0.0.1:" ++ integer_to_list(?PORT) ++ Path).
18+
19+
%%====================================================================
20+
%% Test Setup
21+
%%====================================================================
22+
23+
recv_timeout_test_() ->
24+
{setup,
25+
fun setup/0,
26+
fun teardown/1,
27+
[
28+
{"recv_timeout works without pooling", fun test_timeout_no_pool/0},
29+
{"recv_timeout should work with pooling", fun test_timeout_with_pool/0}
30+
]}.
31+
32+
setup() ->
33+
error_logger:tty(false),
34+
{ok, _} = application:ensure_all_started(cowboy),
35+
{ok, _} = application:ensure_all_started(hackney),
36+
37+
%% Start test server with delay endpoint
38+
Host = '_',
39+
Routes = [
40+
{"/delay/:seconds", delay_handler, []},
41+
{"/[...]", test_http_resource, []}
42+
],
43+
Dispatch = cowboy_router:compile([{Host, Routes}]),
44+
{ok, _} = cowboy:start_clear(recv_timeout_test_server,
45+
[{port, ?PORT}],
46+
#{env => #{dispatch => Dispatch}}),
47+
ok.
48+
49+
teardown(_) ->
50+
cowboy:stop_listener(recv_timeout_test_server),
51+
error_logger:tty(true),
52+
ok.
53+
54+
%%====================================================================
55+
%% Tests
56+
%%====================================================================
57+
58+
test_timeout_no_pool() ->
59+
%% Without pooling, recv_timeout should work correctly
60+
%% Request to /delay/2 with 100ms timeout should timeout
61+
Url = ?URL("/delay/2"),
62+
Opts = [
63+
{pool, false}, % Disable pooling
64+
{recv_timeout, 100} % 100ms timeout
65+
],
66+
67+
Result = hackney:request(get, Url, [], <<>>, Opts),
68+
69+
%% Should timeout
70+
?assertEqual({error, timeout}, Result).
71+
72+
test_timeout_with_pool() ->
73+
%% With pooling, recv_timeout should still work for each request
74+
PoolName = recv_timeout_test_pool,
75+
76+
%% Create a dedicated pool for this test
77+
ok = hackney_pool:start_pool(PoolName, [{pool_size, 1}]),
78+
79+
try
80+
Url = ?URL("/delay/2"),
81+
82+
%% First request: create connection with long timeout (10000ms)
83+
%% This should succeed (delay is only 2 seconds)
84+
{ok, 200, _, _} = hackney:request(get, ?URL("/get"), [], <<>>,
85+
[{pool, PoolName}, {recv_timeout, 10000}]),
86+
87+
%% Second request: try to use same pooled connection with 100ms timeout
88+
%% This SHOULD timeout because the delay is 2 seconds
89+
ShortTimeoutOpts = [{pool, PoolName}, {recv_timeout, 100}],
90+
91+
Result = hackney:request(get, Url, [], <<>>, ShortTimeoutOpts),
92+
93+
%% Expected: {error, timeout}
94+
%% Bug: {ok, 200, _, _} - request succeeds because timeout is ignored
95+
?assertEqual({error, timeout}, Result)
96+
after
97+
hackney_pool:stop_pool(PoolName)
98+
end.

0 commit comments

Comments
 (0)