From a2b53179048d0e602f0e46984b0c3635a825655d Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 2 Jun 2026 21:49:45 +0200 Subject: [PATCH 1/3] Update quic to 1.6.1 and add IPv6 and 0-RTT to the HTTP/3 client Bump the quic dependency to 1.6.1. Forward a family (inet|inet6) and happy_eyeballs option into the QUIC connect options so callers can select the address family; quic does DNS and Happy Eyeballs and accepts IPv6 literals. This also fixes a gap where ssl_options never reached H3 on the pooled path. Add 0-RTT and session resumption. The session ticket is captured from the new quic_h3 owner messages and cached in the pool keyed by host/port/ transport, then replayed on the next connection. Real request-in-0-RTT is sent before the connection reports connected on the one-shot hackney_h3 path for bodyless requests, with a single 1-RTT retry on rejection; the pooled path uses the ticket for a resumed handshake. Gated by the zero_rtt option, and an explicit session_ticket overrides the cache. New hackney_h3 functions: early_data_accepted/1, get_session_ticket/1, wait_session_ticket/2. New hackney_pool functions: get_h3_session/4, store_h3_session/5, delete_h3_session/4. --- README.md | 5 + guides/http3_guide.md | 52 ++++++ rebar.config | 8 +- src/hackney.erl | 37 ++++- src/hackney_conn.erl | 127 ++++++++++++++- src/hackney_h3.erl | 127 ++++++++++++++- src/hackney_pool.erl | 57 ++++++- test/hackney_h3_zero_rtt_tests.erl | 137 ++++++++++++++++ test/hackney_http3_zero_rtt_SUITE.erl | 220 ++++++++++++++++++++++++++ 9 files changed, 753 insertions(+), 17 deletions(-) create mode 100644 test/hackney_h3_zero_rtt_tests.erl create mode 100644 test/hackney_http3_zero_rtt_SUITE.erl diff --git a/README.md b/README.md index 5a2ee9db..7d51e425 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/guides/http3_guide.md b/guides/http3_guide.md index 7eb8f0ed..791c8ecc 100644 --- a/guides/http3_guide.md +++ b/guides/http3_guide.md @@ -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 diff --git a/rebar.config b/rebar.config index eb2c110c..9682458d 100644 --- a/rebar.config +++ b/rebar.config @@ -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}, @@ -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}. @@ -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 diff --git a/src/hackney.erl b/src/hackney.erl index 68fae8dd..9ff49177 100644 --- a/src/hackney.erl +++ b/src/hackney.erl @@ -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} -> @@ -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, diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index 57eb918d..67a6d55f 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -102,6 +102,10 @@ closed/3 ]). +-ifdef(TEST). +-export([h3_tls_opts/2]). +-endif. + -include("hackney.hrl"). -include("hackney_lib.hrl"). @@ -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 }). %%==================================================================== @@ -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) @@ -603,6 +615,7 @@ idle({call, From}, connect, Data) -> transport = Transport, connect_timeout = Timeout, connect_options = ConnectOpts, + ssl_options = SslOpts, try_http3 = TryHttp3 } = Data, @@ -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, @@ -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. @@ -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)); @@ -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 @@ -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). diff --git a/src/hackney_h3.erl b/src/hackney_h3.erl index 39172cbb..f5feaf3b 100644 --- a/src/hackney_h3.erl +++ b/src/hackney_h3.erl @@ -45,6 +45,10 @@ update_stream_state/3, %% Response parsing parse_response_headers/1, + %% 0-RTT / session resumption + early_data_accepted/1, + get_session_ticket/1, + wait_session_ticket/2, %% Connection close close/1, close/2, @@ -67,13 +71,16 @@ -ifdef(TEST). -export([maybe_strip_redirect_headers/4]). -export([body_within_limit/2, remaining/1]). +-export([build_h3_opts/2]). -endif. -record(state, { h3_conn :: pid() | undefined, conn_ref :: reference(), owner :: pid(), - owner_mon :: reference() + owner_mon :: reference(), + %% Last 0-RTT/resumption session ticket delivered by quic_h3 (opaque term). + session_ticket :: term() | undefined }). -define(CONN_TABLE, hackney_h3_conns). @@ -150,7 +157,7 @@ do_request_with_redirect(Method, Url, Headers, Body, Opts, FollowRedirect, MaxRe end, Timeout = maps:get(timeout, Opts, 30000), MaxBodySize = maps:get(max_body_size, Opts, ?DEFAULT_MAX_BODY_SIZE), - case connect(HostBin, Port, Opts) of + case h3_connect_for_request(HostBin, Port, Body, Opts) of {ok, Conn} -> try case do_request(Conn, Method, HostBin, FullPath, Headers, Body, Timeout, MaxBodySize) of @@ -447,17 +454,46 @@ wait_connected(ConnRef, Timeout, StartTime) -> {error, {transport_error, Code, Msg}}; {h3, ConnRef, {settings, _Settings}} -> %% HTTP/3 SETTINGS frame - ignore and continue waiting + wait_connected(ConnRef, Timeout, StartTime); + {h3, ConnRef, {session_ticket, _Ticket}} -> + %% Resumption ticket - kept in the adapter state; ignore here + wait_connected(ConnRef, Timeout, StartTime); + {h3, ConnRef, {early_data_rejected, _Ids}} -> + %% Not expected before connected on this path; ignore wait_connected(ConnRef, Timeout, StartTime) after Remaining -> close(ConnRef, timeout), {error, timeout} end. +%% @private Connect for a one-shot request, choosing the 0-RTT no-wait path. +%% True request-in-0-RTT only carries HEADERS (quic_h3 serves `request' but not +%% `send_data' in the early_data state), so it is used only for bodyless +%% requests with `zero_rtt' enabled and a `session_ticket' present. Otherwise we +%% wait for `connected' (1-RTT; still a resumed/abbreviated handshake when a +%% ticket is supplied). +h3_connect_for_request(HostBin, Port, Body, Opts) -> + HasBody = Body =/= <<>> andalso Body =/= [], + ZeroRtt = maps:get(zero_rtt, Opts, true) + andalso maps:is_key(session_ticket, Opts) + andalso (not HasBody), + case ZeroRtt of + true -> + %% No-wait: the adapter is started and the request is sent while the + %% connection is in early_data, riding 0-RTT. + connect(HostBin, Port, Opts, self()); + false -> + connect(HostBin, Port, Opts) + end. + do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout, MaxBodySize) -> + do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout, MaxBodySize, false). + +do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout, MaxBodySize, Retried) -> AllHeaders = build_request_headers(Method, Host, Path, Headers), HasBody = Body =/= <<>> andalso Body =/= [], Fin = not HasBody, - case send_request(ConnRef, AllHeaders, Fin) of + Result = case send_request(ConnRef, AllHeaders, Fin) of {ok, StreamId} when HasBody -> case send_data(ConnRef, StreamId, Body, true) of ok -> @@ -469,6 +505,14 @@ do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout, MaxBodySize) -> await_response_loop(ConnRef, StreamId, Timeout, MaxBodySize); {error, _} = Error -> Error + end, + case Result of + {error, early_data_rejected} when not Retried -> + %% Server rejected 0-RTT; the connection is now past the handshake, + %% so retry once on the same connection at 1-RTT. + do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout, MaxBodySize, true); + _ -> + Result end. %% @private Wait for HTTP/3 response. @@ -518,6 +562,17 @@ await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, A {h3, ConnRef, {settings, _Settings}} -> %% HTTP/3 SETTINGS frame - ignore and continue waiting await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, AccBody); + {h3, ConnRef, {connected, _Info}} -> + %% 0-RTT no-wait path: the request was sent before `connected'. + %% Ignore the late connected event and keep awaiting the response. + await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, AccBody); + {h3, ConnRef, {session_ticket, _Ticket}} -> + %% Resumption ticket - kept in the adapter state; ignore here + await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, AccBody); + {h3, ConnRef, {early_data_rejected, _Ids}} -> + %% Server rejected 0-RTT; the request stream was reset. Surface a + %% distinct error so the caller can retry once at 1-RTT. + {error, early_data_rejected}; {h3, ConnRef, {goaway, _StreamId2}} -> %% GOAWAY frame - connection is shutting down {error, goaway} @@ -688,6 +743,34 @@ sockname(ConnRef) -> peercert(ConnRef) -> quic_call(ConnRef, peercert). +%% @doc Whether the server accepted 0-RTT early data, or `unknown' before the +%% handshake completes. +-spec early_data_accepted(reference()) -> boolean() | unknown | {error, term()}. +early_data_accepted(ConnRef) -> + with_pid(ConnRef, + fun(Pid) -> gen_server:call(Pid, early_data_accepted) end, + {error, not_connected}). + +%% @doc Return the last session ticket delivered for this connection, or +%% `undefined' if none has arrived yet. See {@link wait_session_ticket/2} to +%% block until one is available. +-spec get_session_ticket(reference()) -> term() | undefined | {error, term()}. +get_session_ticket(ConnRef) -> + with_pid(ConnRef, + fun(Pid) -> gen_server:call(Pid, get_session_ticket) end, + {error, not_connected}). + +%% @doc Block until a session ticket is delivered to the owner, up to `Timeout' +%% ms. The caller must be the connection owner (the process that called +%% {@link connect/4}). Returns `{ok, Ticket}' or `{error, timeout}'. +-spec wait_session_ticket(reference(), timeout()) -> + {ok, term()} | {error, timeout}. +wait_session_ticket(ConnRef, Timeout) -> + receive + {h3, ConnRef, {session_ticket, Ticket}} -> {ok, Ticket} + after Timeout -> {error, timeout} + end. + quic_call(ConnRef, Op) -> with_pid(ConnRef, fun(Pid) -> gen_server:call(Pid, {quic_op, Op}) end, @@ -740,6 +823,15 @@ handle_call({quic_op, Op}, _From, #state{h3_conn = Conn} = State) end, {reply, Reply, State}; +handle_call(early_data_accepted, _From, #state{h3_conn = Conn} = State) -> + Reply = try quic_h3:early_data_accepted(Conn) + catch _:Reason -> {error, Reason} + end, + {reply, Reply, State}; + +handle_call(get_session_ticket, _From, #state{session_ticket = Ticket} = State) -> + {reply, Ticket, State}; + handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. @@ -783,6 +875,18 @@ handle_info({quic_h3, Conn, {goaway, LastStreamId}}, handle_info({quic_h3, Conn, {goaway_sent, _}}, #state{h3_conn = Conn} = State) -> {noreply, State}; +handle_info({quic_h3, Conn, {session_ticket, Ticket}}, + #state{h3_conn = Conn, conn_ref = Ref, owner = Owner} = State) -> + %% 0-RTT/resumption: keep the latest ticket and forward to the owner so the + %% pooled path (hackney_conn) can cache it. + Owner ! {h3, Ref, {session_ticket, Ticket}}, + {noreply, State#state{session_ticket = Ticket}}; + +handle_info({quic_h3, Conn, {early_data_rejected, StreamIds}}, + #state{h3_conn = Conn, conn_ref = Ref, owner = Owner} = State) -> + Owner ! {h3, Ref, {early_data_rejected, StreamIds}}, + {noreply, State}; + handle_info({quic_h3, Conn, closed}, #state{h3_conn = Conn, conn_ref = Ref, owner = Owner} = State) -> Owner ! {h3, Ref, {closed, normal}}, @@ -833,7 +937,7 @@ build_h3_opts(Host, Opts) -> end end, QuicOpts0 = #{server_name_indication => HostStr}, - QuicOpts = case maps:get(cacerts, Opts, undefined) of + QuicOpts1 = case maps:get(cacerts, Opts, undefined) of undefined -> case maps:get(cacertfile, Opts, undefined) of undefined -> QuicOpts0; @@ -841,6 +945,21 @@ build_h3_opts(Host, Opts) -> end; CACerts -> QuicOpts0#{cacerts => CACerts} end, + %% IPv6: forward the address family (inet|inet6) and happy_eyeballs toggle + %% to quic, which does DNS + RFC 8305 Happy Eyeballs internally. + QuicOpts2 = case maps:get(family, Opts, undefined) of + undefined -> QuicOpts1; + Family -> QuicOpts1#{family => Family} + end, + QuicOpts3 = case maps:get(happy_eyeballs, Opts, undefined) of + undefined -> QuicOpts2; + Happy -> QuicOpts2#{happy_eyeballs => Happy} + end, + %% 0-RTT / resumption: forward an opaque session ticket when supplied. + QuicOpts = case maps:get(session_ticket, Opts, undefined) of + undefined -> QuicOpts3; + Ticket -> QuicOpts3#{session_ticket => Ticket} + end, Base = #{verify => Verify, quic_opts => QuicOpts}, case maps:get(settings, Opts, undefined) of undefined -> Base; diff --git a/src/hackney_pool.erl b/src/hackney_pool.erl index 1113e6b2..c92ad487 100644 --- a/src/hackney_pool.erl +++ b/src/hackney_pool.erl @@ -29,7 +29,10 @@ %% HTTP/3 connection pooling -export([checkout_h3/4, register_h3/5, - unregister_h3/2]). + unregister_h3/2, + get_h3_session/4, + store_h3_session/5, + delete_h3_session/4]). -export([ get_stats/1, @@ -79,7 +82,10 @@ h2_connections = #{}, %% HTTP/3 connections: #{Key => Pid} - one multiplexed QUIC connection per host %% These connections are shared across callers (not checked out exclusively) - h3_connections = #{} + h3_connections = #{}, + %% HTTP/3 0-RTT/resumption session tickets: #{Key => Ticket} keyed by + %% {Host, Port, Transport}. Replayed on the next connect to resume. + h3_sessions = #{} }). -define(DEFAULT_MAX_CONNECTIONS, 50). @@ -220,6 +226,41 @@ unregister_h3(Pid, Options) -> gen_server:cast(Pool, {unregister_h3, Pid}), ok. +%% @doc Look up a cached HTTP/3 0-RTT/resumption session ticket for a host/port. +-spec get_h3_session(Host :: string(), Port :: non_neg_integer(), + Transport :: module(), Options :: list()) -> + {ok, term()} | none. +get_h3_session(Host, Port, Transport, Options) -> + PoolName = proplists:get_value(pool, Options, default), + Pool = find_pool(PoolName, Options), + Key = {Host, Port, Transport}, + try + gen_server:call(Pool, {get_h3_session, Key}) + catch + _:_ -> none + end. + +%% @doc Cache an HTTP/3 session ticket for a host/port for later resumption. +-spec store_h3_session(Host :: string(), Port :: non_neg_integer(), + Transport :: module(), Ticket :: term(), + Options :: list()) -> ok. +store_h3_session(Host, Port, Transport, Ticket, Options) -> + PoolName = proplists:get_value(pool, Options, default), + Pool = find_pool(PoolName, Options), + Key = {Host, Port, Transport}, + gen_server:cast(Pool, {store_h3_session, Key, Ticket}), + ok. + +%% @doc Invalidate a cached HTTP/3 session ticket (e.g. after 0-RTT rejection). +-spec delete_h3_session(Host :: string(), Port :: non_neg_integer(), + Transport :: module(), Options :: list()) -> ok. +delete_h3_session(Host, Port, Transport, Options) -> + PoolName = proplists:get_value(pool, Options, default), + Pool = find_pool(PoolName, Options), + Key = {Host, Port, Transport}, + gen_server:cast(Pool, {delete_h3_session, Key}), + ok. + get_stats(Pool) -> gen_server:call(find_pool(Pool), stats). @@ -528,6 +569,12 @@ handle_call({checkout_h3, Key}, _From, #state{h3_connections = H3Conns} = State) end end; +handle_call({get_h3_session, Key}, _From, #state{h3_sessions = Sessions} = State) -> + case maps:get(Key, Sessions, undefined) of + undefined -> {reply, none, State}; + Ticket -> {reply, {ok, Ticket}, State} + end; + handle_call(unregister_h2_all, _From, State) -> %% Clear all HTTP/2 connections (for testing) {reply, ok, State#state{h2_connections = #{}}}. @@ -602,6 +649,12 @@ handle_cast({unregister_h3, Pid}, State) -> State2 = do_unregister_h3(Pid, State), {noreply, State2}; +handle_cast({store_h3_session, Key, Ticket}, #state{h3_sessions = Sessions} = State) -> + {noreply, State#state{h3_sessions = maps:put(Key, Ticket, Sessions)}}; + +handle_cast({delete_h3_session, Key}, #state{h3_sessions = Sessions} = State) -> + {noreply, State#state{h3_sessions = maps:remove(Key, Sessions)}}; + handle_cast(_Msg, State) -> {noreply, State}. diff --git a/test/hackney_h3_zero_rtt_tests.erl b/test/hackney_h3_zero_rtt_tests.erl new file mode 100644 index 00000000..a1f7b9a4 --- /dev/null +++ b/test/hackney_h3_zero_rtt_tests.erl @@ -0,0 +1,137 @@ +%%% -*- erlang -*- +%%% +%%% This file is part of hackney released under the Apache 2 license. +%%% See the NOTICE for more information. +%%% +%%% Tests for HTTP/3 IPv6 (family) plumbing and 0-RTT / session resumption +%%% wiring: option forwarding into quic_opts and the pool session-ticket cache. + +-module(hackney_h3_zero_rtt_tests). + +-include_lib("eunit/include/eunit.hrl"). + +%%==================================================================== +%% build_h3_opts/2 - IPv6 family + session_ticket forwarding +%%==================================================================== + +build_opts_test_() -> + [ + {"family inet6 is forwarded into quic_opts", fun family_inet6/0}, + {"family inet is forwarded into quic_opts", fun family_inet/0}, + {"no family leaves quic_opts without a family key", fun no_family/0}, + {"happy_eyeballs is forwarded into quic_opts", fun happy_eyeballs/0}, + {"session_ticket is forwarded into quic_opts", fun session_ticket/0}, + {"family and session_ticket can be combined", fun family_and_ticket/0} + ]. + +family_inet6() -> + Opts = hackney_h3:build_h3_opts(<<"example.com">>, #{family => inet6}), + QuicOpts = maps:get(quic_opts, Opts), + ?assertEqual(inet6, maps:get(family, QuicOpts)). + +family_inet() -> + Opts = hackney_h3:build_h3_opts(<<"example.com">>, #{family => inet}), + QuicOpts = maps:get(quic_opts, Opts), + ?assertEqual(inet, maps:get(family, QuicOpts)). + +no_family() -> + Opts = hackney_h3:build_h3_opts(<<"example.com">>, #{}), + QuicOpts = maps:get(quic_opts, Opts), + ?assertNot(maps:is_key(family, QuicOpts)). + +happy_eyeballs() -> + Opts = hackney_h3:build_h3_opts(<<"example.com">>, #{happy_eyeballs => false}), + QuicOpts = maps:get(quic_opts, Opts), + ?assertEqual(false, maps:get(happy_eyeballs, QuicOpts)). + +session_ticket() -> + Ticket = {session_ticket, <<"opaque">>}, + Opts = hackney_h3:build_h3_opts(<<"example.com">>, #{session_ticket => Ticket}), + QuicOpts = maps:get(quic_opts, Opts), + ?assertEqual(Ticket, maps:get(session_ticket, QuicOpts)). + +family_and_ticket() -> + Ticket = make_ref(), + Opts = hackney_h3:build_h3_opts(<<"[::1]">>, + #{family => inet6, session_ticket => Ticket}), + QuicOpts = maps:get(quic_opts, Opts), + ?assertEqual(inet6, maps:get(family, QuicOpts)), + ?assertEqual(Ticket, maps:get(session_ticket, QuicOpts)). + +%%==================================================================== +%% hackney_conn:h3_tls_opts/2 - family/session_ticket from either list +%%==================================================================== + +tls_opts_test_() -> + [ + {"family from connect_options", fun tls_family_connect/0}, + {"family from ssl_options", fun tls_family_ssl/0}, + {"session_ticket from connect_options", fun tls_ticket_connect/0}, + {"session_ticket from ssl_options", fun tls_ticket_ssl/0}, + {"insecure maps to verify_none", fun tls_insecure/0} + ]. + +tls_family_connect() -> + M = hackney_conn:h3_tls_opts([{family, inet6}], []), + ?assertEqual(inet6, maps:get(family, M)). + +tls_family_ssl() -> + M = hackney_conn:h3_tls_opts([], [{family, inet6}]), + ?assertEqual(inet6, maps:get(family, M)). + +tls_ticket_connect() -> + T = make_ref(), + M = hackney_conn:h3_tls_opts([{session_ticket, T}], []), + ?assertEqual(T, maps:get(session_ticket, M)). + +tls_ticket_ssl() -> + T = make_ref(), + M = hackney_conn:h3_tls_opts([], [{session_ticket, T}]), + ?assertEqual(T, maps:get(session_ticket, M)). + +tls_insecure() -> + M = hackney_conn:h3_tls_opts([{insecure, true}], []), + ?assertEqual(verify_none, maps:get(verify, M)). + +%%==================================================================== +%% hackney_pool - H3 session ticket cache +%%==================================================================== + +pool_session_test_() -> + {setup, + fun() -> + {ok, _} = application:ensure_all_started(hackney), + ok + end, + fun(_) -> ok end, + [ + {"get returns none when nothing cached", fun pool_get_none/0}, + {"store then get returns the ticket", fun pool_store_get/0}, + {"delete removes the cached ticket", fun pool_delete/0}, + {"tickets are keyed by host/port/transport", fun pool_keying/0} + ]}. + +pool_get_none() -> + ?assertEqual(none, + hackney_pool:get_h3_session("nope.example", 443, hackney_ssl, [])). + +pool_store_get() -> + T = {ticket, make_ref()}, + ok = hackney_pool:store_h3_session("a.example", 443, hackney_ssl, T, []), + ?assertEqual({ok, T}, + hackney_pool:get_h3_session("a.example", 443, hackney_ssl, [])). + +pool_delete() -> + T = {ticket, make_ref()}, + ok = hackney_pool:store_h3_session("b.example", 443, hackney_ssl, T, []), + ?assertEqual({ok, T}, + hackney_pool:get_h3_session("b.example", 443, hackney_ssl, [])), + ok = hackney_pool:delete_h3_session("b.example", 443, hackney_ssl, []), + ?assertEqual(none, + hackney_pool:get_h3_session("b.example", 443, hackney_ssl, [])). + +pool_keying() -> + T = {ticket, make_ref()}, + ok = hackney_pool:store_h3_session("c.example", 443, hackney_ssl, T, []), + ?assertEqual(none, + hackney_pool:get_h3_session("c.example", 8443, hackney_ssl, [])). diff --git a/test/hackney_http3_zero_rtt_SUITE.erl b/test/hackney_http3_zero_rtt_SUITE.erl new file mode 100644 index 00000000..f4751b08 --- /dev/null +++ b/test/hackney_http3_zero_rtt_SUITE.erl @@ -0,0 +1,220 @@ +%%% -*- erlang -*- +%%% +%%% This file is part of hackney released under the Apache 2 license. +%%% See the NOTICE for more information. +%%% +%%% @doc HTTP/3 0-RTT / resumption and IPv6 tests. +%%% +%%% All cases run deterministically against a local, in-process pure-Erlang +%%% `quic_h3' server on 127.0.0.1 / [::1]. The server issues NewSessionTickets +%%% and (with quic >= 1.6.1) accepts 0-RTT early data, so the full round trip is +%%% exercised without any external server: ticket emission, real request-in-0-RTT +%%% acceptance, the request/5 ticket-consume path, and IPv6. +%%% +%%% Cases skip gracefully when openssl or an IPv6 loopback is unavailable. + +-module(hackney_http3_zero_rtt_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +-export([all/0, init_per_suite/1, end_per_suite/1, + init_per_testcase/2, end_per_testcase/2]). + +-export([basic_get/1, + session_ticket_emitted/1, + real_0rtt_one_shot/1, + request5_resumption/1, + ipv6_loopback/1]). + +-define(SERVER, hackney_h3_0rtt_test_server). + +all() -> + [basic_get, + session_ticket_emitted, + real_0rtt_one_shot, + request5_resumption, + ipv6_loopback]. + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(hackney), + case make_cert() of + {ok, CertDer, KeyDer} -> + [{cert, CertDer}, {key, KeyDer} | Config]; + {error, Reason} -> + {skip, {no_cert, Reason}} + end. + +end_per_suite(_Config) -> + ok. + +init_per_testcase(ipv6_loopback, Config) -> + case ipv6_loopback_available() of + true -> start_server(Config, [inet6, {ip, {0, 0, 0, 0, 0, 0, 0, 1}}]); + false -> {skip, no_ipv6_loopback} + end; +init_per_testcase(_Case, Config) -> + start_server(Config, []). + +end_per_testcase(_Case, _Config) -> + catch quic_h3:stop_server(?SERVER), + ok. + +%%==================================================================== +%% Local, deterministic cases +%%==================================================================== + +%% Plain HTTP/3 GET against the local server (sanity, no ticket). +basic_get(Config) -> + Port = ?config(port, Config), + {ok, Status, _Hdrs, Body} = + hackney_h3:request(get, url("127.0.0.1", Port), [], <<>>, + #{insecure_skip_verify => true}), + ?assertEqual(200, Status), + ?assertEqual(<<"ok">>, Body). + +%% A first connection yields a session ticket forwarded to the owner. +session_ticket_emitted(Config) -> + Port = ?config(port, Config), + {ok, Ref} = hackney_h3:connect(<<"127.0.0.1">>, Port, + #{verify => verify_none}, self()), + {ok, _Ticket} = hackney_h3:wait_session_ticket(Ref, 5000), + ok = hackney_h3:close(Ref). + +%% True request-in-0-RTT: capture a ticket, then on a fresh no-wait connection +%% send a bodyless request before `connected'. The server accepts the early +%% data (quic >= 1.6.1 echoes the early_data extension). +real_0rtt_one_shot(Config) -> + Port = ?config(port, Config), + Ticket = capture_ticket(<<"127.0.0.1">>, Port), + {ok, Ref} = hackney_h3:connect(<<"127.0.0.1">>, Port, + #{verify => verify_none, + session_ticket => Ticket}, self()), + {ok, StreamId, _Streams} = + hackney_h3:send_request(Ref, <<"GET">>, <<"127.0.0.1">>, <<"/">>, [], <<>>), + {ok, Status, _Hdrs, Body} = hackney_h3:await_response(Ref, StreamId), + ?assertEqual(200, Status), + ?assertEqual(<<"ok">>, Body), + ?assertEqual(true, hackney_h3:early_data_accepted(Ref)), + ok = hackney_h3:close(Ref). + +%% request/5 consumes a captured ticket for a bodyless request (0-RTT). +request5_resumption(Config) -> + Port = ?config(port, Config), + Ticket = capture_ticket(<<"127.0.0.1">>, Port), + {ok, Status, _Hdrs, _Body} = + hackney_h3:request(get, url("127.0.0.1", Port), [], <<>>, + #{insecure_skip_verify => true, + session_ticket => Ticket}), + ?assertEqual(200, Status). + +%% Connect over an IPv6 loopback literal, forcing the inet6 family. +ipv6_loopback(Config) -> + Port = ?config(port, Config), + {ok, Ref} = hackney_h3:connect(<<"[::1]">>, Port, + #{verify => verify_none, family => inet6}, self()), + receive + {h3, Ref, {connected, _}} -> ok + after 5000 -> + ct:fail(ipv6_connect_timeout) + end, + {ok, StreamId, _Streams} = + hackney_h3:send_request(Ref, <<"GET">>, <<"[::1]">>, <<"/">>, [], <<>>), + {ok, Status, _Hdrs, _Body} = hackney_h3:await_response(Ref, StreamId), + ?assertEqual(200, Status), + ok = hackney_h3:close(Ref). + +%%==================================================================== +%% Helpers +%%==================================================================== + +start_server(Config, ExtraSocketOpts) -> + Cert = ?config(cert, Config), + Key = ?config(key, Config), + QuicOpts0 = #{max_data => 16 * 1024 * 1024, + max_stream_data_bidi_local => 4 * 1024 * 1024, + max_stream_data_bidi_remote => 4 * 1024 * 1024, + max_stream_data_uni => 4 * 1024 * 1024}, + QuicOpts = case ExtraSocketOpts of + [] -> QuicOpts0; + _ -> QuicOpts0#{extra_socket_opts => ExtraSocketOpts} + end, + Opts = #{cert => Cert, key => Key, quic_opts => QuicOpts, + handler => fun handle/5}, + case quic_h3:start_server(?SERVER, 0, Opts) of + {ok, _Pid} -> + {ok, Port} = quic:get_server_port(?SERVER), + [{port, Port} | Config]; + {error, Reason} -> + {skip, {server_start_failed, Reason}} + end. + +%% First connection: capture and return the session ticket, then close. +capture_ticket(Host, Port) -> + {ok, Ref} = hackney_h3:connect(Host, Port, #{verify => verify_none}, self()), + {ok, Ticket} = hackney_h3:wait_session_ticket(Ref, 5000), + ok = hackney_h3:close(Ref), + Ticket. + +url(Host, Port) -> + "https://" ++ Host ++ ":" ++ integer_to_list(Port) ++ "/". + +%% Bodyless GET handler. +handle(Conn, StreamId, <<"GET">>, _Path, _Headers) -> + Body = <<"ok">>, + quic_h3:send_response(Conn, StreamId, 200, [ + {<<"content-type">>, <<"text/plain">>}, + {<<"content-length">>, integer_to_binary(byte_size(Body))} + ]), + quic_h3:send_data(Conn, StreamId, Body, true); +handle(Conn, StreamId, _Method, _Path, _Headers) -> + quic_h3:send_response(Conn, StreamId, 404, [{<<"content-length">>, <<"0">>}]), + quic_h3:send_data(Conn, StreamId, <<>>, true). + +ipv6_loopback_available() -> + case gen_udp:open(0, [inet6, {ip, {0, 0, 0, 0, 0, 0, 0, 1}}]) of + {ok, S} -> gen_udp:close(S), true; + {error, _} -> false + end. + +%% Self-signed cert via openssl; {error, _} -> suite skips. +make_cert() -> + Dir = test_tmp_dir(), + KeyFile = filename:join(Dir, "h3_0rtt_key.pem"), + CertFile = filename:join(Dir, "h3_0rtt_cert.pem"), + Cmd = lists:flatten(io_lib:format( + "openssl req -x509 -newkey rsa:2048 -keyout ~s -out ~s " + "-days 1 -nodes -subj '/CN=localhost' 2>/dev/null", + [KeyFile, CertFile])), + _ = os:cmd(Cmd), + case {filelib:is_regular(KeyFile), filelib:is_regular(CertFile)} of + {true, true} -> + {ok, CertPem} = file:read_file(CertFile), + {ok, KeyPem} = file:read_file(KeyFile), + [{'Certificate', CertDer, _}] = public_key:pem_decode(CertPem), + {ok, CertDer, decode_key(KeyPem)}; + _ -> + {error, openssl_unavailable} + end. + +decode_key(KeyPem) -> + case public_key:pem_decode(KeyPem) of + [{'RSAPrivateKey', Der, not_encrypted}] -> + public_key:der_decode('RSAPrivateKey', Der); + [{'ECPrivateKey', Der, not_encrypted}] -> + public_key:der_decode('ECPrivateKey', Der); + [{'PrivateKeyInfo', Der, not_encrypted}] -> + public_key:der_decode('PrivateKeyInfo', Der); + [{_Type, Der, not_encrypted}] -> + Der + end. + +test_tmp_dir() -> + Base = case os:getenv("TMPDIR") of + false -> "/tmp"; + "" -> "/tmp"; + T -> T + end, + Dir = filename:join(Base, "hackney_h3_0rtt_test"), + _ = filelib:ensure_dir(filename:join(Dir, "x")), + Dir. From 51ad1afd7cee4e88bc0b512777db88c1a54e4dfb Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 2 Jun 2026 22:49:10 +0200 Subject: [PATCH 2/3] ci: pin ImageOS=ubuntu24 on the arm64 runner The ubuntu-24.04-arm runner reports ImageOS=ubuntu24-arm64, which setup-beam cannot map, failing the job before it builds. Override it to ubuntu24. --- .github/workflows/erlang.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml index cf82e040..07c5954f 100644 --- a/.github/workflows/erlang.yml +++ b/.github/workflows/erlang.yml @@ -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"] From bf2309701931706c16ff3dcddd265b5d454f13b5 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 2 Jun 2026 22:55:54 +0200 Subject: [PATCH 3/3] test: retry WebTransport integration connect on transient timeout The in-process WebTransport handshake over UDP loopback occasionally stalls under CI load and returns connect_timeout, flaking a random integration subtest. Retry the connect a few times in the test helper. --- test/hackney_wt_tests.erl | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test/hackney_wt_tests.erl b/test/hackney_wt_tests.erl index 2b4aff42..8e361520 100644 --- a/test/hackney_wt_tests.erl +++ b/test/hackney_wt_tests.erl @@ -168,7 +168,20 @@ connect(URL) -> connect(URL, []). connect(URL, Extra) -> - hackney:wt_connect(URL, [{verify, verify_none}, {connect_timeout, 10000} | Extra]). + connect(URL, Extra, 3). + +%% The in-process WebTransport handshake over UDP loopback occasionally stalls +%% under load and times out; retry a few times so the integration tests don't +%% flake on a transient connect timeout. +connect(_URL, _Extra, 0) -> + {error, connect_timeout}; +connect(URL, Extra, Retries) -> + case hackney:wt_connect(URL, [{verify, verify_none}, {connect_timeout, 10000} | Extra]) of + {error, connect_timeout} -> + connect(URL, Extra, Retries - 1); + Other -> + Other + end. %% Read until a datagram is seen (a stray default-stream chunk could in %% principle arrive first; datagrams on loopback normally arrive directly).