From f5ef2bde61f320db5405723f74a10894b3d88c8d Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Mon, 19 Jan 2026 10:30:54 +0100 Subject: [PATCH] feat: support HTTP 1xx informational responses (#631) Add support for HTTP 1xx informational responses per RFC 7231 Section 6.2. - Add `inform_fun` option to receive callbacks when 1xx responses arrive - For sync requests: callback(Status, Reason, HeadersList) is invoked - For async requests: `{hackney_response, AsyncRef, {informational, Status, Reason, Headers}}` message is sent to stream_to process - If no callback is set, 1xx responses are silently skipped - Parser now returns 1xx responses instead of treating them as junk This enables use cases like HTTP 103 Early Hints for preloading resources. --- src/hackney.erl | 7 +++- src/hackney_conn.erl | 64 +++++++++++++++++++++++++++------ src/hackney_http.erl | 7 ++-- test/hackney_redirect_tests.erl | 47 ++++++++++++++++++++++++ test/test_http_resource.erl | 20 +++++++++++ 5 files changed, 129 insertions(+), 16 deletions(-) diff --git a/src/hackney.erl b/src/hackney.erl index 0b63a1ee..b3e30403 100644 --- a/src/hackney.erl +++ b/src/hackney.erl @@ -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 diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index 0dccb2e7..50cbc1dd 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -32,6 +32,7 @@ %% Request/Response (sync) request/5, request/6, + request/7, request_streaming/5, body/1, body/2, @@ -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 @@ -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. @@ -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 @@ -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) -> @@ -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, @@ -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}}]}; @@ -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 diff --git a/src/hackney_http.erl b/src/hackney_http.erl index d2d19931..53df08d7 100644 --- a/src/hackney_http.erl +++ b/src/hackney_http.erl @@ -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) diff --git a/test/hackney_redirect_tests.erl b/test/hackney_redirect_tests.erl index 79bdbc79..78355d43 100644 --- a/test/hackney_redirect_tests.erl +++ b/test/hackney_redirect_tests.erl @@ -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_() -> [ diff --git a/test/test_http_resource.erl b/test/test_http_resource.erl index 119b7553..a2b25b69 100644 --- a/test/test_http_resource.erl +++ b/test/test_http_resource.erl @@ -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). @@ -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, #{