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
7 changes: 6 additions & 1 deletion src/hackney.erl
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,12 @@ sync_request_with_redirect(ConnPid, Method, Path, Headers, Body, WithBody, Optio

sync_request_with_redirect_body(ConnPid, Method, Path, HeadersList, FinalBody,
WithBody, Options, URL, FollowRedirect, MaxRedirect, RedirectCount) ->
case hackney_conn:request(ConnPid, Method, Path, HeadersList, FinalBody) of
%% Extract request options for 1xx informational responses
ReqOpts = case proplists:get_value(inform_fun, Options) of
undefined -> [];
InformFun -> [{inform_fun, InformFun}]
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 ->
%% HTTP/2 redirect status
Expand Down
64 changes: 53 additions & 11 deletions src/hackney_conn.erl
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
%% Request/Response (sync)
request/5,
request/6,
request/7,
request_streaming/5,
body/1,
body/2,
Expand Down Expand Up @@ -127,6 +128,7 @@
idle_timeout = ?IDLE_TIMEOUT :: timeout(),
connect_options = [] :: list(),
ssl_options = [] :: list(),
inform_fun :: fun((integer(), binary(), list()) -> any()) | undefined,

%% Pool integration
pool_pid :: pid() | undefined, %% If set, connection is from a pool
Expand Down Expand Up @@ -234,12 +236,20 @@ get_state(Pid) ->
-spec request(pid(), binary(), binary(), list(), binary() | iolist()) ->
{ok, integer(), list()} | {ok, integer(), list(), binary()} | {error, term()}.
request(Pid, Method, Path, Headers, Body) ->
request(Pid, Method, Path, Headers, Body, infinity).
request(Pid, Method, Path, Headers, Body, infinity, []).

-spec request(pid(), binary(), binary(), list(), binary() | iolist(), timeout()) ->
{ok, integer(), list()} | {ok, integer(), list(), binary()} | {error, term()}.
request(Pid, Method, Path, Headers, Body, Timeout) ->
gen_statem:call(Pid, {request, Method, Path, Headers, Body}, Timeout).
request(Pid, Method, Path, Headers, Body, Timeout, []).

%% @doc Make an HTTP request with additional request options.
%% Options:
%% - inform_fun: fun(Status, Reason, Headers) - callback for 1xx responses
-spec request(pid(), binary(), binary(), list(), binary() | iolist(), timeout(), list()) ->
{ok, integer(), list()} | {ok, integer(), list(), binary()} | {error, term()}.
request(Pid, Method, Path, Headers, Body, Timeout, ReqOpts) ->
gen_statem:call(Pid, {request, Method, Path, Headers, Body, ReqOpts}, Timeout).

%% @doc Send an HTTP/3 request and return headers immediately.
%% Returns {ok, Status, Headers} and allows subsequent stream_body/1 calls.
Expand Down Expand Up @@ -483,7 +493,8 @@ init([DefaultOwner, Opts]) ->
ssl_options = maps:get(ssl_options, Opts, []),
pool_pid = maps:get(pool_pid, Opts, undefined),
enable_push = maps:get(enable_push, Opts, false),
no_reuse = maps:get(no_reuse, Opts, false)
no_reuse = maps:get(no_reuse, Opts, false),
inform_fun = maps:get(inform_fun, Opts, undefined)
},

%% If socket is provided, start in connected state; otherwise start in idle
Expand Down Expand Up @@ -761,12 +772,12 @@ connected({call, From}, is_upgraded_ssl, #conn_data{upgraded_ssl = Upgraded}) ->
connected({call, From}, is_no_reuse, #conn_data{no_reuse = NoReuse}) ->
{keep_state_and_data, [{reply, From, NoReuse}]};

connected({call, From}, {request, Method, Path, Headers, Body}, #conn_data{protocol = http2} = Data) ->
%% HTTP/2 request - use h2_machine
connected({call, From}, {request, Method, Path, Headers, Body, _ReqOpts}, #conn_data{protocol = http2} = Data) ->
%% HTTP/2 request - use h2_machine (1xx not applicable for HTTP/2)
do_h2_request(From, Method, Path, Headers, Body, Data);

connected({call, From}, {request, Method, Path, Headers, Body}, #conn_data{protocol = http3} = Data) ->
%% HTTP/3 request - use hackney_h3
connected({call, From}, {request, Method, Path, Headers, Body, _ReqOpts}, #conn_data{protocol = http3} = Data) ->
%% HTTP/3 request - use hackney_h3 (1xx not applicable for HTTP/3)
do_h3_request(From, Method, Path, Headers, Body, Data);

connected({call, From}, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo}, #conn_data{protocol = http3} = Data) ->
Expand All @@ -781,8 +792,9 @@ connected({call, From}, {request_streaming, Method, Path, Headers, Body}, #conn_
%% HTTP/3 request with streaming body reads (returns headers, then stream_body for chunks)
do_h3_request_streaming(From, Method, Path, Headers, Body, Data);

connected({call, From}, {request, Method, Path, Headers, Body}, Data) ->
connected({call, From}, {request, Method, Path, Headers, Body, ReqOpts}, Data) ->
%% HTTP/1.1 request
InformFun = proplists:get_value(inform_fun, ReqOpts, undefined),
NewData = Data#conn_data{
request_from = From,
method = Method,
Expand All @@ -795,7 +807,8 @@ connected({call, From}, {request, Method, Path, Headers, Body}, Data) ->
buffer = <<>>,
async = false,
async_ref = undefined,
stream_to = undefined
stream_to = undefined,
inform_fun = InformFun
},
{next_state, sending, NewData, [{next_event, internal, {send_request, Method, Path, Headers, Body}}]};

Expand Down Expand Up @@ -1650,9 +1663,38 @@ send_body(Transport, Socket, Body) when is_binary(Body); is_list(Body) ->
Transport:send(Socket, Body).

%% @private Receive and parse response status and headers
%% Note: 1XX informational responses are automatically skipped by the HTTP parser
%% Handles 1XX informational responses per RFC 7231
recv_status_and_headers(Data) ->
recv_status(Data).
recv_status_and_headers_loop(Data).

recv_status_and_headers_loop(Data) ->
case recv_status(Data) of
{ok, Status, Headers, NewData} when Status >= 100, Status < 200 ->
%% 1XX informational response per RFC 7231 Section 6.2
#conn_data{inform_fun = InformFun, reason = Reason, parser = OldParser,
stream_to = StreamTo, async_ref = AsyncRef} = NewData,
HeadersList = hackney_headers:to_list(Headers),
%% Handle differently for sync vs async mode
case StreamTo of
undefined ->
%% Sync mode - call callback if provided
case InformFun of
undefined -> ok;
Fun when is_function(Fun, 3) ->
Fun(Status, Reason, HeadersList)
end;
_ ->
%% Async mode - send message to stream_to
StreamTo ! {hackney_response, AsyncRef, {informational, Status, Reason, HeadersList}}
end,
%% Reset parser for next response, preserving any buffered data
%% The old parser's buffer may contain the start of the next response
OldBuffer = hackney_http:get(OldParser, buffer),
NewParser = hackney_http:parser([response]),
recv_status_and_headers_loop(NewData#conn_data{parser = NewParser, buffer = OldBuffer});
Other ->
Other
end.

recv_status(#conn_data{parser = Parser, buffer = Buffer} = Data) ->
case hackney_http:execute(Parser, Buffer) of
Expand Down
7 changes: 3 additions & 4 deletions src/hackney_http.erl
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,9 @@ parse_first_line(Buffer, St=#hparser{type=Type,
_ when Type =:= response ->
case parse_response_line(St) of
{error, bad_request} -> {error, bad_request};
{response, Version, StatusInt, Reason, NState} when StatusInt >= 200 ->
{response, Version, StatusInt, Reason, NState};
{response, _Version, _StatusInt, _Reason, _NState} ->
{more, St#hparser{empty_lines=Empty, state=on_junk}}
%% Return all responses including 1xx informational (issue #631)
{response, Version, StatusInt, Reason, NState} ->
{response, Version, StatusInt, Reason, NState}
end;
_ when Type =:= request ->
parse_request_line(St)
Expand Down
47 changes: 47 additions & 0 deletions test/hackney_redirect_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,53 @@ maybe_strip_auth_on_redirect(CurrentURL, NewURL, Options) ->
end
end.

%% =============================================================================
%% Tests for HTTP 1xx Informational Responses (issue #631)
%% =============================================================================

%% Test that 1xx responses are handled correctly with callback
inform_response_callback_test_() ->
{setup,
fun setup/0,
fun cleanup/1,
[
{"1xx response calls inform_fun callback",
fun test_inform_callback/0},
{"1xx response without callback is silently skipped",
fun test_inform_no_callback/0}
]}.

test_inform_callback() ->
Self = self(),
InformFun = fun(Status, Reason, Headers) ->
Self ! {got_inform, Status, Reason, Headers}
end,
%% Use URL-encoded link value
URL = url(<<"/inform?status=103&link=%3C/style.css%3E%3B%20rel%3Dpreload">>),
{ok, Status, _Headers, Client} = hackney:request(get, URL, [], <<>>,
[{inform_fun, InformFun}]),
hackney:close(Client),
%% Should receive the informational message
receive
{got_inform, InformStatus, _Reason, InformHeaders} ->
?assertEqual(103, InformStatus),
%% Check Link header was received
?assert(proplists:is_defined(<<"link">>, InformHeaders) orelse
proplists:is_defined(<<"Link">>, InformHeaders))
after 1000 ->
?assert(false) %% Timeout - callback not called
end,
%% Final response should be 200
?assertEqual(200, Status).

test_inform_no_callback() ->
%% Without callback, 1xx should be silently skipped
URL = url(<<"/inform?status=103">>),
{ok, Status, _Headers, Client} = hackney:request(get, URL, [], <<>>, []),
hackney:close(Client),
%% Final response should be 200
?assertEqual(200, Status).

%% Test that netloc includes port for non-standard ports
netloc_port_test_() ->
[
Expand Down
20 changes: 20 additions & 0 deletions test/test_http_resource.erl
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
%% GET /cookies/set - set cookies from query params
%% GET /cookies - return cookies as JSON
%% GET /robots.txt - return fixed robots.txt content
%% GET /inform - send 1xx informational response before final response

-module(test_http_resource).

Expand Down Expand Up @@ -117,6 +118,25 @@ handle_request(<<"GET">>, <<"/connection-close">>, Req, State) ->
}, <<"{\"connection\": \"close\"}">>, Req),
{ok, Req2, State};

%% GET /inform - send 1xx informational response before final response
%% Query params:
%% - status: informational status code (default 103)
%% - link: value for Link header in informational response
handle_request(<<"GET">>, <<"/inform">>, Req0, State) ->
QS = cowboy_req:parse_qs(Req0),
InformStatus = case proplists:get_value(<<"status">>, QS) of
undefined -> 103;
StatusBin -> binary_to_integer(StatusBin)
end,
InformHeaders = case proplists:get_value(<<"link">>, QS) of
undefined -> #{};
LinkValue -> #{<<"link">> => LinkValue}
end,
%% Send informational response first
ok = cowboy_req:inform(InformStatus, InformHeaders, Req0),
%% Then send final response
reply_json(200, #{<<"informed">> => true, <<"inform_status">> => InformStatus}, Req0, State);

%% Fallback - return 404
handle_request(_Method, _Path, Req, State) ->
Req2 = cowboy_req:reply(404, #{
Expand Down
Loading