@@ -13,7 +13,7 @@ defmodule Quiver.Conn.HTTP3 do
1313
1414 @ behaviour Quiver.Conn
1515
16- @ dialyzer { :nowarn_function , connect: 2 , query_peer_max_streams: 2 }
16+ @ dialyzer { :nowarn_function , connect: 2 , do_connect: 5 , query_peer_max_streams: 2 }
1717
1818 alias Quiver.Error.H3StreamError
1919 alias Quiver.Error.QUICHandshakeFailed
@@ -22,6 +22,11 @@ defmodule Quiver.Conn.HTTP3 do
2222
2323 @ forbidden_headers ~w( connection keep-alive transfer-encoding upgrade proxy-connection)
2424
25+ # Probe budget for a non-final connect candidate, so an unreachable preferred
26+ # address fails over to the next family without burning the full connect timeout.
27+ # Shared with `Quiver.Pool.HTTP3.Connection` via `connect_probe_timeout/0`.
28+ @ connect_probe_timeout 1_500
29+
2530 defstruct [
2631 :h3_conn ,
2732 :host ,
@@ -54,11 +59,10 @@ defmodule Quiver.Conn.HTTP3 do
5459
5560 h3_opts =
5661 opts
57- |> build_h3_opts ( )
62+ |> build_h3_opts ( host )
5863 |> Map . put ( :sync , true )
59- |> Map . put ( :connect_timeout , timeout )
6064
61- case :quic_h3 . connect ( host , port , h3_opts ) do
65+ case do_connect ( resolve_candidates ( host ) , port , h3_opts , timeout , :nxdomain ) do
6266 { :ok , h3_conn } ->
6367 { :ok ,
6468 % __MODULE__ {
@@ -88,6 +92,21 @@ defmodule Quiver.Conn.HTTP3 do
8892 ) }
8993 end
9094
95+ # Dial resolved candidate addresses in turn (IPv6 first), forcing the QUIC direct
96+ # path. Non-final candidates get the short probe budget so an unreachable preferred
97+ # family fails over quickly; the final candidate gets the full connect timeout.
98+ defp do_connect ( [ ] , _port , _h3_opts , _timeout , last_error ) , do: { :error , last_error }
99+
100+ defp do_connect ( [ ip | rest ] , port , h3_opts , timeout , _last_error ) do
101+ attempt_timeout = if rest == [ ] , do: timeout , else: min ( timeout , @ connect_probe_timeout )
102+ h3_opts = Map . put ( h3_opts , :connect_timeout , attempt_timeout )
103+
104+ case :quic_h3 . connect ( ip , port , h3_opts ) do
105+ { :ok , h3_conn } -> { :ok , h3_conn }
106+ { :error , reason } -> do_connect ( rest , port , h3_opts , timeout , reason )
107+ end
108+ end
109+
91110 @ impl Quiver.Conn
92111 def open? ( % __MODULE__ { h3_conn: pid } ) when is_pid ( pid ) , do: Process . alive? ( pid )
93112 def open? ( _ ) , do: false
@@ -239,16 +258,56 @@ defmodule Quiver.Conn.HTTP3 do
239258 end
240259 end
241260
242- defp build_h3_opts ( opts ) do
261+ @ doc false
262+ @ spec connect_probe_timeout ( ) :: pos_integer ( )
263+ def connect_probe_timeout , do: @ connect_probe_timeout
264+
265+ @ doc """
266+ Resolves a host to at most one address per family, IPv6 first (RFC 8305 §4
267+ preference), for the direct-connect path. A literal IP is returned as-is.
268+ Shared with `Quiver.Pool.HTTP3.Connection`.
269+ """
270+ @ spec resolve_candidates ( String . t ( ) ) :: [ :inet . ip_address ( ) ]
271+ def resolve_candidates ( host ) do
272+ hostc = to_charlist ( host )
273+
274+ case :inet . parse_address ( hostc ) do
275+ { :ok , ip } -> [ ip ]
276+ { :error , _ } -> first_addr ( hostc , :inet6 ) ++ first_addr ( hostc , :inet )
277+ end
278+ end
279+
280+ defp first_addr ( hostc , family ) do
281+ case :inet . getaddrs ( hostc , family ) do
282+ { :ok , [ ip | _ ] } -> [ ip ]
283+ _ -> [ ]
284+ end
285+ end
286+
287+ defp build_h3_opts ( opts , host ) do
243288 base = % { verify: Keyword . get ( opts , :verify , :verify_peer ) }
244289
245290 base
246291 |> maybe_put ( :cacerts , Keyword . get ( opts , :cacerts ) )
247292 |> maybe_put ( :settings , Keyword . get ( opts , :h3_settings ) )
248- |> maybe_put ( :quic_opts , Keyword . get ( opts , :quic_opts ) )
293+ |> Map . put ( :quic_opts , build_quic_opts ( Keyword . get ( opts , :quic_opts ) , host ) )
249294 |> maybe_put_datagram ( Keyword . get ( opts , :h3_datagram_enabled , true ) )
250295 end
251296
297+ # Disable Happy-Eyeballs and dial the resolved literal address: the QUIC race
298+ # coordinator transfers connection ownership only after the handshake completes,
299+ # dropping the server's SETTINGS streams. `server_name` carries the hostname for
300+ # SNI / `verify_peer` since the dialed host is an IP. See `do_connect/5`.
301+ defp build_quic_opts ( quic_opts , host ) do
302+ quic_opts
303+ |> normalize_quic_opts ( )
304+ |> Map . put ( :happy_eyeballs , false )
305+ |> Map . put ( :server_name , to_string ( host ) )
306+ end
307+
308+ defp normalize_quic_opts ( opts ) when is_map ( opts ) , do: opts
309+ defp normalize_quic_opts ( _ ) , do: % { }
310+
252311 defp maybe_put ( map , _k , nil ) , do: map
253312 defp maybe_put ( map , _k , :default ) , do: map
254313 defp maybe_put ( map , _k , v ) when is_map ( v ) and map_size ( v ) == 0 , do: map
0 commit comments