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
42 changes: 42 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# NEWS

4.2.0 - 2026-06-03
------------------

### Added

- IPv6 for HTTP/3. The `family` connect option (`inet` | `inet6`) is forwarded
to QUIC, which resolves DNS and races addresses with Happy Eyeballs (RFC
8305). IPv6 literals such as `https://[::1]/` work too. `family` may be set in
`connect_options` or `ssl_options`.
- 0-RTT and session resumption for HTTP/3. The server's session ticket is cached
in the pool per `{host, port, transport}` and replayed on the next
connection; a bodyless one-shot request is then sent as 0-RTT, otherwise the
ticket gives a resumed handshake. Enabled by default and controlled by the
`zero_rtt` option, with an explicit `session_ticket` taking precedence over
the cache. New `hackney_h3` helpers: `early_data_accepted/1`,
`get_session_ticket/1`, `wait_session_ticket/2`.

### Fixed

- Recover from an expired cross-signed root instead of failing the handshake
(e.g. Let's Encrypt's ISRG Root X2 cross-signed by the expired ISRG Root X1).
For HTTP/1.1 and HTTP/2 the verification function rewrites `cert_expired` to
`root_cert_expired` so OTP's cross-sign recovery runs; for HTTP/3 and
WebTransport the same recovery is in quic 1.6.2. A genuinely expired leaf or
intermediate still fails, and partial chains keep working.
- HTTP/3 connections from the pool now apply `ssl_options` (`cacerts`,
`insecure`) that previously did not reach the QUIC layer.
- A pooled connection that stops between checkout and the request call no
longer leaks `exit:{normal, _}` (or `exit:noproc`) to the caller. The
request, body and streaming calls now return `{error, closed}` instead
(issue #861).
- A proxy host given as an atom (e.g. `localhost`) or a binary is accepted
again for `{ProxyHost, Port}`, `{connect, ...}` and `{socks5, ...}` proxy
options, instead of being silently ignored. Regression from a too-strict
`is_list/1` guard (issue #858).

### Dependencies

- quic 1.4.5 -> 1.6.2.
- h2 0.6.1 -> 0.8.0.
- webtransport 0.2.6 -> 0.3.0.

4.1.0 - 2026-05-29
------------------

Expand Down
2 changes: 1 addition & 1 deletion src/hackney.app.src
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{application, hackney,
[
{description, "Simple HTTP client with HTTP/1.1, HTTP/2, and HTTP/3 support"},
{vsn, "4.1.0"},
{vsn, "4.2.0"},
{registered, [hackney_pool]},
{applications, [kernel,
stdlib,
Expand Down
19 changes: 13 additions & 6 deletions src/hackney.erl
Original file line number Diff line number Diff line change
Expand Up @@ -1705,17 +1705,17 @@ get_proxy_config(Scheme, TargetHost, Options) ->
false;
ProxyUrl when is_binary(ProxyUrl); is_list(ProxyUrl) ->
parse_proxy_option(ProxyUrl, Scheme, Options);
{ProxyHost, ProxyPort} when is_list(ProxyHost), is_integer(ProxyPort) ->
{ProxyHost, ProxyPort} when (is_list(ProxyHost) orelse is_atom(ProxyHost) orelse is_binary(ProxyHost)), is_integer(ProxyPort) ->
%% Simple tuple: use HTTP proxy for http, CONNECT for https
ProxyAuth = proplists:get_value(proxy_auth, Options),
ProxyTransport = proplists:get_value(proxy_transport, Options, tcp),
{proxy_type_for_scheme(Scheme), ProxyHost, ProxyPort, ProxyAuth, ProxyTransport};
{connect, ProxyHost, ProxyPort} when is_list(ProxyHost), is_integer(ProxyPort) ->
{proxy_type_for_scheme(Scheme), normalize_proxy_host(ProxyHost), ProxyPort, ProxyAuth, ProxyTransport};
{connect, ProxyHost, ProxyPort} when (is_list(ProxyHost) orelse is_atom(ProxyHost) orelse is_binary(ProxyHost)), is_integer(ProxyPort) ->
%% Explicit CONNECT tunnel
ProxyAuth = proplists:get_value(proxy_auth, Options),
ProxyTransport = proplists:get_value(proxy_transport, Options, tcp),
{connect, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport};
{socks5, ProxyHost, ProxyPort} when is_list(ProxyHost), is_integer(ProxyPort) ->
{connect, normalize_proxy_host(ProxyHost), ProxyPort, ProxyAuth, ProxyTransport};
{socks5, ProxyHost, ProxyPort} when (is_list(ProxyHost) orelse is_atom(ProxyHost) orelse is_binary(ProxyHost)), is_integer(ProxyPort) ->
%% SOCKS5 proxy
User = proplists:get_value(socks5_user, Options),
Pass = proplists:get_value(socks5_pass, Options, <<>>),
Expand All @@ -1724,11 +1724,18 @@ get_proxy_config(Scheme, TargetHost, Options) ->
_ -> {User, Pass}
end,
ProxyTransport = proplists:get_value(proxy_transport, Options, tcp),
{socks5, ProxyHost, ProxyPort, Auth, ProxyTransport};
{socks5, normalize_proxy_host(ProxyHost), ProxyPort, Auth, ProxyTransport};
_ ->
false
end.

%% @private Accept a proxy host given as a string, a binary or an atom
%% (e.g. `localhost'), as hackney 1.x did, and normalise it to a string.
%% A new is_list/1 guard regressed atom hosts to a silent fall-through (#858).
normalize_proxy_host(H) when is_list(H) -> H;
normalize_proxy_host(H) when is_atom(H) -> atom_to_list(H);
normalize_proxy_host(H) when is_binary(H) -> binary_to_list(H).

%% Parse proxy URL and determine type based on target scheme
parse_proxy_option(ProxyUrl, Scheme, Options) ->
case parse_proxy_url(ProxyUrl) of
Expand Down
41 changes: 31 additions & 10 deletions src/hackney_conn.erl
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,31 @@ request(Pid, Method, Path, Headers, Body, Timeout) ->
{ok, integer(), list()} | {ok, integer(), list(), binary()} | {error, term()}.
request(Pid, Method, Path, Headers, Body, Timeout, ReqOpts) ->
case valid_request_target(Path) of
ok -> gen_statem:call(Pid, {request, Method, Path, Headers, Body, ReqOpts}, Timeout);
ok -> safe_call(Pid, {request, Method, Path, Headers, Body, ReqOpts}, Timeout);
Err -> Err
end.

%% @private gen_statem:call that converts a callee which has already stopped,
%% or stops while the call is in flight, into `{error, closed}' instead of
%% letting `exit:{normal, _}' / `exit:noproc' reach the caller. A pooled
%% connection can stop between checkout and the call (issue #861); the brief
%% linger in the `closed' state narrows the window but cannot close it. Other
%% exits (e.g. timeout) propagate unchanged.
safe_call(Pid, Msg) ->
safe_call(Pid, Msg, infinity).

safe_call(Pid, Msg, Timeout) ->
try
gen_statem:call(Pid, Msg, Timeout)
catch
exit:noproc -> {error, closed};
exit:{noproc, _} -> {error, closed};
exit:normal -> {error, closed};
exit:{normal, _} -> {error, closed};
exit:shutdown -> {error, closed};
exit:{shutdown, _} -> {error, closed}
end.

%% @private GHSA-j9wq: the request target (path + query) is written verbatim
%% into the HTTP/1.1 request line and the HTTP/2 / HTTP/3 :path pseudo-header.
%% Raw CR, LF or NUL bytes let a caller-controlled URL inject extra header
Expand All @@ -297,7 +318,7 @@ valid_request_target(_) ->
{ok, integer(), list()} | {error, term()}.
request_streaming(Pid, Method, Path, Headers, Body) ->
case valid_request_target(Path) of
ok -> gen_statem:call(Pid, {request_streaming, Method, Path, Headers, Body}, infinity);
ok -> safe_call(Pid, {request_streaming, Method, Path, Headers, Body}, infinity);
Err -> Err
end.

Expand All @@ -307,24 +328,24 @@ request_streaming(Pid, Method, Path, Headers, Body) ->
-spec send_request_headers(pid(), binary(), binary(), list()) -> ok | {error, term()}.
send_request_headers(Pid, Method, Path, Headers) ->
case valid_request_target(Path) of
ok -> gen_statem:call(Pid, {send_headers, Method, Path, Headers}, infinity);
ok -> safe_call(Pid, {send_headers, Method, Path, Headers}, infinity);
Err -> Err
end.

%% @doc Send a chunk of the request body.
-spec send_body_chunk(pid(), iodata()) -> ok | {error, term()}.
send_body_chunk(Pid, Data) ->
gen_statem:call(Pid, {send_body_chunk, Data}, infinity).
safe_call(Pid, {send_body_chunk, Data}, infinity).

%% @doc Finish sending the request body.
-spec finish_send_body(pid()) -> ok | {error, term()}.
finish_send_body(Pid) ->
gen_statem:call(Pid, finish_send_body, infinity).
safe_call(Pid, finish_send_body, infinity).

%% @doc Start receiving the response after sending the full body.
-spec start_response(pid()) -> {ok, integer(), list(), pid()} | {error, term()}.
start_response(Pid) ->
gen_statem:call(Pid, start_response, infinity).
safe_call(Pid, start_response, infinity).

%% @doc Get the full response body.
-spec body(pid()) -> {ok, binary()} | {error, term()}.
Expand All @@ -333,13 +354,13 @@ body(Pid) ->

-spec body(pid(), timeout()) -> {ok, binary()} | {error, term()}.
body(Pid, Timeout) ->
gen_statem:call(Pid, body, Timeout).
safe_call(Pid, body, Timeout).

%% @doc Stream the response body in chunks.
%% Returns {ok, Data} for each chunk, {done, Pid} when complete.
-spec stream_body(pid()) -> {ok, binary()} | done | {error, term()}.
stream_body(Pid) ->
gen_statem:call(Pid, stream_body).
safe_call(Pid, stream_body).

%% @doc Send an HTTP request asynchronously.
%% Returns {ok, Ref} immediately. Response is sent as messages:
Expand All @@ -366,15 +387,15 @@ request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo) ->
{ok, pid()} | {error, term()}.
request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect) ->
case valid_request_target(Path) of
ok -> gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect});
ok -> safe_call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect});
Err -> Err
end.

-spec request_async(pid(), binary(), binary(), list(), binary() | iolist(), true | once, pid(), boolean(), list()) ->
{ok, pid()} | {error, term()}.
request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts) ->
case valid_request_target(Path) of
ok -> gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts});
ok -> safe_call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts});
Err -> Err
end.

Expand Down
24 changes: 23 additions & 1 deletion test/hackney_conn_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ hackney_conn_test_() ->
{"set_owner on closed connection returns invalid_state (#850)",
fun test_set_owner_closed_returns_error/0},
{"set_owner_async stops a closed pooled connection (#850)",
fun test_set_owner_async_closed_pooled_stops/0}
fun test_set_owner_async_closed_pooled_stops/0},
{"request on a dead connection returns {error, closed} (#861)",
fun test_request_dead_conn_returns_error/0},
{"body on a dead connection returns {error, closed} (#861)",
fun test_body_dead_conn_returns_error/0}
]}.

%% Integration tests - use embedded Cowboy server
Expand Down Expand Up @@ -264,6 +268,24 @@ test_set_owner_async_closed_pooled_stops() ->
?assertNot(is_process_alive(Pid)),
gen_tcp:close(ListenSock).

%% #861: a pooled connection can stop between checkout and the call, so a
%% request to an already-dead connection must return {error, closed} rather
%% than letting exit:{normal,_}/noproc crash the caller.
test_request_dead_conn_returns_error() ->
Pid = dead_conn_pid(),
?assertEqual({error, closed},
hackney_conn:request(Pid, <<"GET">>, <<"/">>, [], <<>>)).

test_body_dead_conn_returns_error() ->
Pid = dead_conn_pid(),
?assertEqual({error, closed}, hackney_conn:body(Pid)).

%% A pid that has already terminated normally.
dead_conn_pid() ->
{Pid, Ref} = spawn_monitor(fun() -> ok end),
receive {'DOWN', Ref, process, Pid, _} -> ok after 1000 -> ok end,
Pid.

%% Start a hackney_conn already in `connected` state via a pre-established
%% local socket pair (no server needed). Extra opts are merged in.
connected_conn(Extra) ->
Expand Down
15 changes: 15 additions & 0 deletions test/hackney_proxy_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,21 @@ get_proxy_config_test_() ->
]),
?assertEqual({socks5, "socks.local", 1080, {<<"user">>, <<"pass">>}, tcp}, Result)
end},
{"SOCKS5 tuple accepts an atom host (#858)",
fun() ->
Result = hackney:get_proxy_config(http, "example.com", [{proxy, {socks5, localhost, 1080}}]),
?assertEqual({socks5, "localhost", 1080, undefined, tcp}, Result)
end},
{"SOCKS5 tuple accepts a binary host (#858)",
fun() ->
Result = hackney:get_proxy_config(http, "example.com", [{proxy, {socks5, <<"socks.local">>, 1080}}]),
?assertEqual({socks5, "socks.local", 1080, undefined, tcp}, Result)
end},
{"plain tuple accepts an atom host (#858)",
fun() ->
Result = hackney:get_proxy_config(http, "example.com", [{proxy, {localhost, 8080}}]),
?assertEqual({http, "localhost", 8080, undefined, tcp}, Result)
end},
{"HTTP URL proxy for HTTP scheme returns http type",
fun() ->
Result = hackney:get_proxy_config(http, "example.com", [{proxy, "http://proxy.local:8080"}]),
Expand Down
Loading