Skip to content

Commit f5ef2bd

Browse files
committed
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.
1 parent 8ceefd2 commit f5ef2bd

5 files changed

Lines changed: 129 additions & 16 deletions

File tree

src/hackney.erl

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,12 @@ sync_request_with_redirect(ConnPid, Method, Path, Headers, Body, WithBody, Optio
862862

863863
sync_request_with_redirect_body(ConnPid, Method, Path, HeadersList, FinalBody,
864864
WithBody, Options, URL, FollowRedirect, MaxRedirect, RedirectCount) ->
865-
case hackney_conn:request(ConnPid, Method, Path, HeadersList, FinalBody) of
865+
%% Extract request options for 1xx informational responses
866+
ReqOpts = case proplists:get_value(inform_fun, Options) of
867+
undefined -> [];
868+
InformFun -> [{inform_fun, InformFun}]
869+
end,
870+
case hackney_conn:request(ConnPid, Method, Path, HeadersList, FinalBody, infinity, ReqOpts) of
866871
%% HTTP/2 returns body directly - handle 4-tuple first
867872
{ok, Status, RespHeaders, RespBody} when Status >= 301, Status =< 303; Status =:= 307; Status =:= 308 ->
868873
%% HTTP/2 redirect status

src/hackney_conn.erl

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
%% Request/Response (sync)
3333
request/5,
3434
request/6,
35+
request/7,
3536
request_streaming/5,
3637
body/1,
3738
body/2,
@@ -127,6 +128,7 @@
127128
idle_timeout = ?IDLE_TIMEOUT :: timeout(),
128129
connect_options = [] :: list(),
129130
ssl_options = [] :: list(),
131+
inform_fun :: fun((integer(), binary(), list()) -> any()) | undefined,
130132

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

239241
-spec request(pid(), binary(), binary(), list(), binary() | iolist(), timeout()) ->
240242
{ok, integer(), list()} | {ok, integer(), list(), binary()} | {error, term()}.
241243
request(Pid, Method, Path, Headers, Body, Timeout) ->
242-
gen_statem:call(Pid, {request, Method, Path, Headers, Body}, Timeout).
244+
request(Pid, Method, Path, Headers, Body, Timeout, []).
245+
246+
%% @doc Make an HTTP request with additional request options.
247+
%% Options:
248+
%% - inform_fun: fun(Status, Reason, Headers) - callback for 1xx responses
249+
-spec request(pid(), binary(), binary(), list(), binary() | iolist(), timeout(), list()) ->
250+
{ok, integer(), list()} | {ok, integer(), list(), binary()} | {error, term()}.
251+
request(Pid, Method, Path, Headers, Body, Timeout, ReqOpts) ->
252+
gen_statem:call(Pid, {request, Method, Path, Headers, Body, ReqOpts}, Timeout).
243253

244254
%% @doc Send an HTTP/3 request and return headers immediately.
245255
%% Returns {ok, Status, Headers} and allows subsequent stream_body/1 calls.
@@ -483,7 +493,8 @@ init([DefaultOwner, Opts]) ->
483493
ssl_options = maps:get(ssl_options, Opts, []),
484494
pool_pid = maps:get(pool_pid, Opts, undefined),
485495
enable_push = maps:get(enable_push, Opts, false),
486-
no_reuse = maps:get(no_reuse, Opts, false)
496+
no_reuse = maps:get(no_reuse, Opts, false),
497+
inform_fun = maps:get(inform_fun, Opts, undefined)
487498
},
488499

489500
%% 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}) ->
761772
connected({call, From}, is_no_reuse, #conn_data{no_reuse = NoReuse}) ->
762773
{keep_state_and_data, [{reply, From, NoReuse}]};
763774

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

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

772783
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_
781792
%% HTTP/3 request with streaming body reads (returns headers, then stream_body for chunks)
782793
do_h3_request_streaming(From, Method, Path, Headers, Body, Data);
783794

784-
connected({call, From}, {request, Method, Path, Headers, Body}, Data) ->
795+
connected({call, From}, {request, Method, Path, Headers, Body, ReqOpts}, Data) ->
785796
%% HTTP/1.1 request
797+
InformFun = proplists:get_value(inform_fun, ReqOpts, undefined),
786798
NewData = Data#conn_data{
787799
request_from = From,
788800
method = Method,
@@ -795,7 +807,8 @@ connected({call, From}, {request, Method, Path, Headers, Body}, Data) ->
795807
buffer = <<>>,
796808
async = false,
797809
async_ref = undefined,
798-
stream_to = undefined
810+
stream_to = undefined,
811+
inform_fun = InformFun
799812
},
800813
{next_state, sending, NewData, [{next_event, internal, {send_request, Method, Path, Headers, Body}}]};
801814

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

16521665
%% @private Receive and parse response status and headers
1653-
%% Note: 1XX informational responses are automatically skipped by the HTTP parser
1666+
%% Handles 1XX informational responses per RFC 7231
16541667
recv_status_and_headers(Data) ->
1655-
recv_status(Data).
1668+
recv_status_and_headers_loop(Data).
1669+
1670+
recv_status_and_headers_loop(Data) ->
1671+
case recv_status(Data) of
1672+
{ok, Status, Headers, NewData} when Status >= 100, Status < 200 ->
1673+
%% 1XX informational response per RFC 7231 Section 6.2
1674+
#conn_data{inform_fun = InformFun, reason = Reason, parser = OldParser,
1675+
stream_to = StreamTo, async_ref = AsyncRef} = NewData,
1676+
HeadersList = hackney_headers:to_list(Headers),
1677+
%% Handle differently for sync vs async mode
1678+
case StreamTo of
1679+
undefined ->
1680+
%% Sync mode - call callback if provided
1681+
case InformFun of
1682+
undefined -> ok;
1683+
Fun when is_function(Fun, 3) ->
1684+
Fun(Status, Reason, HeadersList)
1685+
end;
1686+
_ ->
1687+
%% Async mode - send message to stream_to
1688+
StreamTo ! {hackney_response, AsyncRef, {informational, Status, Reason, HeadersList}}
1689+
end,
1690+
%% Reset parser for next response, preserving any buffered data
1691+
%% The old parser's buffer may contain the start of the next response
1692+
OldBuffer = hackney_http:get(OldParser, buffer),
1693+
NewParser = hackney_http:parser([response]),
1694+
recv_status_and_headers_loop(NewData#conn_data{parser = NewParser, buffer = OldBuffer});
1695+
Other ->
1696+
Other
1697+
end.
16561698

16571699
recv_status(#conn_data{parser = Parser, buffer = Buffer} = Data) ->
16581700
case hackney_http:execute(Parser, Buffer) of

src/hackney_http.erl

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,9 @@ parse_first_line(Buffer, St=#hparser{type=Type,
184184
_ when Type =:= response ->
185185
case parse_response_line(St) of
186186
{error, bad_request} -> {error, bad_request};
187-
{response, Version, StatusInt, Reason, NState} when StatusInt >= 200 ->
188-
{response, Version, StatusInt, Reason, NState};
189-
{response, _Version, _StatusInt, _Reason, _NState} ->
190-
{more, St#hparser{empty_lines=Empty, state=on_junk}}
187+
%% Return all responses including 1xx informational (issue #631)
188+
{response, Version, StatusInt, Reason, NState} ->
189+
{response, Version, StatusInt, Reason, NState}
191190
end;
192191
_ when Type =:= request ->
193192
parse_request_line(St)

test/hackney_redirect_tests.erl

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,53 @@ maybe_strip_auth_on_redirect(CurrentURL, NewURL, Options) ->
314314
end
315315
end.
316316

317+
%% =============================================================================
318+
%% Tests for HTTP 1xx Informational Responses (issue #631)
319+
%% =============================================================================
320+
321+
%% Test that 1xx responses are handled correctly with callback
322+
inform_response_callback_test_() ->
323+
{setup,
324+
fun setup/0,
325+
fun cleanup/1,
326+
[
327+
{"1xx response calls inform_fun callback",
328+
fun test_inform_callback/0},
329+
{"1xx response without callback is silently skipped",
330+
fun test_inform_no_callback/0}
331+
]}.
332+
333+
test_inform_callback() ->
334+
Self = self(),
335+
InformFun = fun(Status, Reason, Headers) ->
336+
Self ! {got_inform, Status, Reason, Headers}
337+
end,
338+
%% Use URL-encoded link value
339+
URL = url(<<"/inform?status=103&link=%3C/style.css%3E%3B%20rel%3Dpreload">>),
340+
{ok, Status, _Headers, Client} = hackney:request(get, URL, [], <<>>,
341+
[{inform_fun, InformFun}]),
342+
hackney:close(Client),
343+
%% Should receive the informational message
344+
receive
345+
{got_inform, InformStatus, _Reason, InformHeaders} ->
346+
?assertEqual(103, InformStatus),
347+
%% Check Link header was received
348+
?assert(proplists:is_defined(<<"link">>, InformHeaders) orelse
349+
proplists:is_defined(<<"Link">>, InformHeaders))
350+
after 1000 ->
351+
?assert(false) %% Timeout - callback not called
352+
end,
353+
%% Final response should be 200
354+
?assertEqual(200, Status).
355+
356+
test_inform_no_callback() ->
357+
%% Without callback, 1xx should be silently skipped
358+
URL = url(<<"/inform?status=103">>),
359+
{ok, Status, _Headers, Client} = hackney:request(get, URL, [], <<>>, []),
360+
hackney:close(Client),
361+
%% Final response should be 200
362+
?assertEqual(200, Status).
363+
317364
%% Test that netloc includes port for non-standard ports
318365
netloc_port_test_() ->
319366
[

test/test_http_resource.erl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
%% GET /cookies/set - set cookies from query params
1212
%% GET /cookies - return cookies as JSON
1313
%% GET /robots.txt - return fixed robots.txt content
14+
%% GET /inform - send 1xx informational response before final response
1415

1516
-module(test_http_resource).
1617

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

121+
%% GET /inform - send 1xx informational response before final response
122+
%% Query params:
123+
%% - status: informational status code (default 103)
124+
%% - link: value for Link header in informational response
125+
handle_request(<<"GET">>, <<"/inform">>, Req0, State) ->
126+
QS = cowboy_req:parse_qs(Req0),
127+
InformStatus = case proplists:get_value(<<"status">>, QS) of
128+
undefined -> 103;
129+
StatusBin -> binary_to_integer(StatusBin)
130+
end,
131+
InformHeaders = case proplists:get_value(<<"link">>, QS) of
132+
undefined -> #{};
133+
LinkValue -> #{<<"link">> => LinkValue}
134+
end,
135+
%% Send informational response first
136+
ok = cowboy_req:inform(InformStatus, InformHeaders, Req0),
137+
%% Then send final response
138+
reply_json(200, #{<<"informed">> => true, <<"inform_status">> => InformStatus}, Req0, State);
139+
120140
%% Fallback - return 404
121141
handle_request(_Method, _Path, Req, State) ->
122142
Req2 = cowboy_req:reply(404, #{

0 commit comments

Comments
 (0)