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
4 changes: 4 additions & 0 deletions .github/workflows/erlang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ jobs:
linux-arm64:
name: Linux ARM64 OTP-${{matrix.otp}}
runs-on: ubuntu-24.04-arm
# The arm64 runner reports ImageOS=ubuntu24-arm64, which setup-beam does
# not map; pin it to ubuntu24 so it resolves the right prebuilt OTP.
env:
ImageOS: ubuntu24
strategy:
matrix:
otp: ["27.2"]
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ hackney:get(URL, [], <<>>, [{protocols, [http3, http2, http1]}]).
application:set_env(hackney, default_protocols, [http3, http2, http1]).
```

IPv6 works out of the box (Happy Eyeballs); force a family with
`{connect_options, [{family, inet6}]}`. Session resumption and 0-RTT are on by
default and cached per host; disable with `{zero_rtt, false}`. See the
[HTTP/3 Guide](guides/http3_guide.md) for details.

**Note:** HTTP/3 uses QUIC (UDP transport). Some networks may block UDP traffic.

### Multipart
Expand Down
52 changes: 52 additions & 0 deletions guides/http3_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,58 @@ Like HTTP/2, HTTP/3 multiplexes requests as streams on a single QUIC connection:
└─────────────────────────────────────────────────────────────────┘
```

## IPv6

DNS resolution and address-family selection are handled by the `quic` library,
which races IPv6 and IPv4 with RFC 8305 Happy Eyeballs. Hostnames with AAAA
records, IPv6 tuples, and bracketed literals all work without extra options.

```erlang
%% Bracketed IPv6 literal
hackney:get(<<"https://[2606:4700::1111]/">>, [], <<>>, [{protocols, [http3]}]).

%% Force a family with the `family' connect option (inet | inet6)
hackney:get(<<"https://example.com/">>, [], <<>>,
[{protocols, [http3]}, {connect_options, [{family, inet6}]}]).
```

`family` (and an optional `happy_eyeballs` boolean) may be set in
`connect_options` or `ssl_options`; both are forwarded to the QUIC layer.

## 0-RTT and Session Resumption

After the first HTTP/3 connection to a host, hackney caches the server's TLS
session ticket in the pool, keyed by `{host, port, transport}`, and replays it on
the next connection to resume the handshake. For a **bodyless** request this can
also send the request as QUIC 0-RTT (in the first flight), saving a round trip.

This is enabled by default and controlled by the `zero_rtt` request option:

```erlang
%% Default: resumption/0-RTT used automatically when a ticket is cached.
hackney:get(Url, [], <<>>, [{protocols, [http3]}]).

%% Disable it for a request:
hackney:get(Url, [], <<>>, [{protocols, [http3]}, {zero_rtt, false}]).

%% Supply a ticket explicitly (overrides the cache):
hackney:get(Url, [], <<>>,
[{protocols, [http3]}, {connect_options, [{session_ticket, Ticket}]}]).
```

Scope:

- **Request-in-0-RTT** (request bytes in the first flight) applies only to
**bodyless** requests via the one-shot `hackney_h3` API. quic carries only
the HEADERS as early data, so a request with a body resumes at 1-RTT.
- The **pooled/multiplexed path** uses the ticket for a resumed (abbreviated)
handshake; requests are sent at 1-RTT since they arrive after connect.
- On 0-RTT rejection, the one-shot path retries once at 1-RTT and the cached
ticket is dropped.

For callers managing tickets directly, `hackney_h3` exposes
`wait_session_ticket/2`, `get_session_ticket/1` and `early_data_accepted/1`.

## Low-Level Stream API

The high-level `hackney:get/post/...` functions cover the common case. For
Expand Down
8 changes: 6 additions & 2 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
{quic_h3, cancel, 3},
{quic_h3, close, 1},
{quic_h3, get_quic_conn, 1},
{quic_h3, early_data_accepted, 1},
{quic, peername, 1},
{quic, sockname, 1},
{quic, peercert, 1},
Expand All @@ -39,7 +40,10 @@
{hackney_h3, parse_response_headers, 1},
{hackney_h3, peername, 1},
{hackney_h3, sockname, 1},
{hackney_h3, peercert, 1}
{hackney_h3, peercert, 1},
{hackney_h3, early_data_accepted, 1},
{hackney_h3, get_session_ticket, 1},
{hackney_h3, wait_session_ticket, 2}
]}.

{cover_enabled, true}.
Expand All @@ -49,7 +53,7 @@

{deps, [
%% Pure Erlang QUIC + HTTP/3 stack
{quic, "1.4.5"},
{quic, "1.6.1"},
%% Pure Erlang HTTP/2 stack
{h2, "0.6.1"},
%% WebTransport client (HTTP/3 and HTTP/2) - powers the wt_* API
Expand Down
37 changes: 35 additions & 2 deletions src/hackney.erl
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,24 @@ try_h3_connection(Host, Port, Transport, Options, PoolHandler) ->
try_new_h3_connection(Host, Port, Transport, Options, PoolHandler) ->
%% Start HTTP/3 connection via hackney_conn
ConnectTimeout = proplists:get_value(connect_timeout, Options, 8000),
BaseConnectOpts = proplists:get_value(connect_options, Options, []),
SslOpts = proplists:get_value(ssl_options, Options, []),
%% Forward the caller's connect_options (e.g. {family, inet6}) and resolve the
%% 0-RTT session ticket (explicit option beats the pool cache).
ConnectOpts0 = [{protocols, [http3]} | proplists:delete(protocols, BaseConnectOpts)],
ConnectOpts = maybe_inject_h3_session(ConnectOpts0, SslOpts, Host, Port, Transport,
Options, PoolHandler),
PoolName = proplists:get_value(pool, Options, default),
ConnOpts = #{
host => Host,
port => Port,
transport => Transport,
connect_timeout => ConnectTimeout,
recv_timeout => proplists:get_value(recv_timeout, Options, 5000),
connect_options => [{protocols, [http3]}],
ssl_options => proplists:get_value(ssl_options, Options, [])
connect_options => ConnectOpts,
ssl_options => SslOpts,
pool_name => PoolName,
pool_handler => PoolHandler
},
case hackney_conn_sup:start_conn(ConnOpts) of
{ok, ConnPid} ->
Expand Down Expand Up @@ -276,6 +286,29 @@ try_new_h3_connection(Host, Port, Transport, Options, PoolHandler) ->
false
end.

%% @private Resolve the 0-RTT session ticket for a new H3 connection.
%% Precedence: an explicit `session_ticket' in connect_options or ssl_options
%% wins; otherwise, unless `zero_rtt' is disabled, a pool-cached ticket for
%% {Host, Port, Transport} is injected. The pool lookup is guarded so a custom
%% pool handler without the callback degrades to no reuse rather than crashing.
maybe_inject_h3_session(ConnectOpts, SslOpts, Host, Port, Transport, Options, PoolHandler) ->
ZeroRtt = proplists:get_value(zero_rtt, Options, true),
Explicit = proplists:get_value(session_ticket, ConnectOpts,
proplists:get_value(session_ticket, SslOpts, undefined)),
case {ZeroRtt, Explicit} of
{false, _} -> ConnectOpts;
{_, T} when T =/= undefined -> ConnectOpts;
_ ->
case erlang:function_exported(PoolHandler, get_h3_session, 4) of
false -> ConnectOpts;
true ->
case PoolHandler:get_h3_session(Host, Port, Transport, Options) of
{ok, Cached} -> [{session_ticket, Cached} | ConnectOpts];
_ -> ConnectOpts
end
end
end.

connect_pool_new(Transport, Host, Port, Options, PoolHandler) ->
MaxPerHost = proplists:get_value(max_per_host, Options, 50),
CheckoutTimeout = proplists:get_value(checkout_timeout, Options,
Expand Down
127 changes: 120 additions & 7 deletions src/hackney_conn.erl
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@
closed/3
]).

-ifdef(TEST).
-export([h3_tls_opts/2]).
-endif.

-include("hackney.hrl").
-include("hackney_lib.hrl").

Expand Down Expand Up @@ -194,7 +198,13 @@
%% Current HTTP/3 stream ID for streaming body mode
h3_stream_id :: non_neg_integer() | undefined,
%% Whether to try HTTP/3 (requires UDP)
try_http3 = false :: boolean()
try_http3 = false :: boolean(),
%% Pool name + handler module, so the connection can cache the H3
%% 0-RTT/resumption session ticket it receives.
pool_name = default :: term(),
pool_handler :: module() | undefined,
%% Last H3 session ticket delivered by hackney_h3 (opaque term).
h3_session_ticket :: term() | undefined
}).

%%====================================================================
Expand Down Expand Up @@ -546,6 +556,8 @@ init([DefaultOwner, Opts]) ->
connect_options = maps:get(connect_options, Opts, []),
ssl_options = maps:get(ssl_options, Opts, []),
pool_pid = maps:get(pool_pid, Opts, undefined),
pool_name = maps:get(pool_name, Opts, default),
pool_handler = maps:get(pool_handler, Opts, undefined),
no_reuse = maps:get(no_reuse, Opts, false),
inform_fun = maps:get(inform_fun, Opts, undefined),
auto_decompress = maps:get(auto_decompress, Opts, false)
Expand Down Expand Up @@ -603,6 +615,7 @@ idle({call, From}, connect, Data) ->
transport = Transport,
connect_timeout = Timeout,
connect_options = ConnectOpts,
ssl_options = SslOpts,
try_http3 = TryHttp3
} = Data,

Expand All @@ -613,7 +626,7 @@ idle({call, From}, connect, Data) ->
case ShouldTryHttp3 of
true ->
%% Try HTTP/3 first
case try_h3_connect(Host, Port, Timeout, ConnectOpts) of
case try_h3_connect(Host, Port, Timeout, ConnectOpts, SslOpts) of
{ok, H3Conn} ->
NewData = Data#conn_data{
h3_conn = H3Conn,
Expand Down Expand Up @@ -1631,6 +1644,23 @@ handle_common(info, {select, _Resource, _Ref, ready_input},
_ = hackney_h3:process(ConnRef),
keep_state_and_data;

%% HTTP/3 0-RTT/resumption: cache the session ticket in the pool so the next
%% connection to this host can resume. Handled here (common to all H3 states)
%% since the ticket can arrive at any point after the handshake.
handle_common(info, {h3, ConnRef, {session_ticket, Ticket}}, _State,
#conn_data{h3_conn = ConnRef} = Data) ->
maybe_store_h3_session(Ticket, Data),
{keep_state, Data#conn_data{h3_session_ticket = Ticket}};

%% HTTP/3 0-RTT rejection. Requests on this path are sent at 1-RTT, so this is
%% not expected; if it arrives, invalidate the cached ticket and fail any
%% matching in-flight stream so the caller can retry.
handle_common(info, {h3, ConnRef, {early_data_rejected, StreamIds}}, _State,
#conn_data{h3_conn = ConnRef, h3_streams = Streams} = Data) ->
maybe_delete_h3_session(Data),
{NewStreams, Actions} = fail_rejected_h3_streams(StreamIds, Streams),
{keep_state, Data#conn_data{h3_streams = NewStreams}, Actions};

%% With trap_exit = true, an EXIT signal from any linked process (other than
%% h2_conn, handled in connected/3) arrives here. Swallow it rather than
%% propagating — avoids tearing down the gen_statem on stray links.
Expand Down Expand Up @@ -2270,9 +2300,9 @@ skip_response_body(Data) ->

%% @private Try HTTP/3 connection via QUIC
%% lsquic handles its own UDP socket creation and DNS resolution.
try_h3_connect(Host, Port, Timeout, ConnectOpts) ->
try_h3_connect(Host, Port, Timeout, ConnectOpts, SslOpts) ->
HostBin = if is_list(Host) -> list_to_binary(Host); true -> Host end,
case hackney_h3:connect(HostBin, Port, h3_tls_opts(ConnectOpts), self()) of
case hackney_h3:connect(HostBin, Port, h3_tls_opts(ConnectOpts, SslOpts), self()) of
{ok, ConnRef} ->
%% Drive event loop until connected
wait_h3_connected(ConnRef, Timeout, erlang:monotonic_time(millisecond));
Expand All @@ -2284,13 +2314,24 @@ try_h3_connect(Host, Port, Timeout, ConnectOpts) ->
%% quic >= 1.4.4 verifies the server certificate. An insecure connection opts
%% out; an explicitly configured CA (cacerts/cacertfile) is used as the trust
%% store; otherwise quic verifies against its own default (OS) trust store.
h3_tls_opts(ConnectOpts) ->
SslOpts = proplists:get_value(ssl_options, ConnectOpts, []),
%% `family' (inet|inet6) and a `session_ticket' (for resumption) are forwarded
%% when present in either the connect options or the ssl options.
h3_tls_opts(ConnectOpts, SslOpts) ->
Insecure = proplists:get_value(insecure, ConnectOpts,
proplists:get_value(insecure, SslOpts, false)),
case Insecure of
Base = case Insecure of
true -> #{verify => verify_none};
false -> h3_ca_opts(SslOpts)
end,
Base1 = case proplists:get_value(family, ConnectOpts,
proplists:get_value(family, SslOpts, undefined)) of
undefined -> Base;
Family -> Base#{family => Family}
end,
case proplists:get_value(session_ticket, ConnectOpts,
proplists:get_value(session_ticket, SslOpts, undefined)) of
undefined -> Base1;
Ticket -> Base1#{session_ticket => Ticket}
end.

%% @private Use an explicitly configured CA as the H3 trust store. quic only
Expand Down Expand Up @@ -3113,6 +3154,78 @@ handle_h3_stream_reset(StreamId, ErrorCode, Streams, Data) ->
{keep_state, Data}
end.

%% @private Cache the H3 session ticket in the pool (best effort, guarded so a
%% custom pool handler without the callback degrades to no caching).
maybe_store_h3_session(_Ticket, #conn_data{pool_handler = undefined}) ->
ok;
maybe_store_h3_session(Ticket, #conn_data{pool_handler = PoolHandler, host = Host,
port = Port, transport = Transport,
pool_name = PoolName}) ->
case erlang:function_exported(PoolHandler, store_h3_session, 5) of
true ->
try PoolHandler:store_h3_session(Host, Port, Transport, Ticket,
[{pool, PoolName}])
catch _:_ -> ok
end,
ok;
false ->
ok
end.

%% @private Drop a cached H3 session ticket (e.g. after 0-RTT rejection).
maybe_delete_h3_session(#conn_data{pool_handler = undefined}) ->
ok;
maybe_delete_h3_session(#conn_data{pool_handler = PoolHandler, host = Host,
port = Port, transport = Transport,
pool_name = PoolName}) ->
case erlang:function_exported(PoolHandler, delete_h3_session, 4) of
true ->
try PoolHandler:delete_h3_session(Host, Port, Transport,
[{pool, PoolName}])
catch _:_ -> ok
end,
ok;
false ->
ok
end.

%% @private Fail any in-flight stream whose 0-RTT data the server rejected.
%% Returns the updated stream map plus gen_statem reply actions for sync
%% callers; async callers are notified via their StreamTo mailbox directly.
fail_rejected_h3_streams(StreamIds, Streams) ->
Ids = case is_list(StreamIds) of
true -> StreamIds;
false -> sets:to_list(StreamIds)
end,
lists:foldl(fun(StreamId, {AccStreams, AccActions}) ->
fail_rejected_h3_stream(StreamId, AccStreams, AccActions)
end, {Streams, []}, Ids).

fail_rejected_h3_stream(StreamId, Streams, Actions) ->
Err = {error, early_data_rejected},
case maps:get(StreamId, Streams, undefined) of
undefined ->
{Streams, Actions};
{From, waiting_headers} ->
%% Sync request awaiting headers - reply via the state machine.
{maps:remove(StreamId, Streams), [{reply, From, Err} | Actions]};
{From, {sending_body, _}} ->
{maps:remove(StreamId, Streams), [{reply, From, Err} | Actions]};
{From, {waiting_headers_streaming, _}} ->
{maps:remove(StreamId, Streams), [{reply, From, Err} | Actions]};
{_, {streaming_body_async, _AsyncMode, StreamTo, Ref, _Status, _Headers}} ->
StreamTo ! {hackney_response, Ref, Err},
{maps:remove(StreamId, Streams), Actions};
{_, {waiting_headers_async, _AsyncMode, StreamTo, Ref}} ->
StreamTo ! {hackney_response, Ref, Err},
{maps:remove(StreamId, Streams), Actions};
{_, {async, _AsyncMode, StreamTo, Ref, _SubState}} ->
StreamTo ! {hackney_response, Ref, Err},
{maps:remove(StreamId, Streams), Actions};
_ ->
{maps:remove(StreamId, Streams), Actions}
end.

%% @private Handle HTTP/3 connection closed
handle_h3_conn_closed(Reason, Data) ->
handle_h3_termination({connection_closed, Reason}, Data).
Expand Down
Loading
Loading