Skip to content

Latest commit

 

History

History
493 lines (374 loc) · 15.9 KB

File metadata and controls

493 lines (374 loc) · 15.9 KB

HTTP/3 Guide

This guide covers hackney's HTTP/3 support via QUIC.

Overview

Hackney supports HTTP/3, the latest version of HTTP built on QUIC (UDP-based transport). HTTP/3 offers improved performance, especially on lossy networks, with features like connection migration and zero round-trip connection establishment.

Key Features

  • QUIC transport - UDP-based, encrypted by default with TLS 1.3
  • Transparent API - Same hackney:get/post/request functions work for HTTP/3
  • Multiplexing - Multiple streams without head-of-line blocking
  • Alt-Svc discovery - Automatic HTTP/3 endpoint detection from Alt-Svc headers
  • Connection pooling - HTTP/3 connections shared across callers
  • Negative caching - Failed H3 attempts cached to avoid repeated failures

Requirements

HTTP/3 support is provided by the erlang_quic dependency (module quic_h3), which handles the QUIC transport, QPACK header compression, HTTP/3 framing, and control streams. Hackney hosts only a thin adapter (hackney_h3) that translates quic_h3 events into the internal connection protocol. No C dependencies, no external binaries required.

Quick Start

%% HTTP/3 request with explicit protocol selection
{ok, 200, Headers, Body} = hackney:get(
    <<"https://cloudflare.com/cdn-cgi/trace">>,
    [],
    <<>>,
    [{protocols, [http3]}, with_body]
).

%% Body contains: http=http/3

Protocol Selection

Default Behavior

By default, hackney uses HTTP/2 and HTTP/1.1 (not HTTP/3):

%% Default: [http2, http1]
hackney:get(<<"https://example.com/">>).

Enable HTTP/3

Add http3 to the protocols list:

%% Try HTTP/3 first, fall back to HTTP/2, then HTTP/1.1
hackney:get(URL, [], <<>>, [{protocols, [http3, http2, http1]}]).

Force HTTP/3 Only

%% HTTP/3 only - fails if H3 unavailable
hackney:get(URL, [], <<>>, [{protocols, [http3]}]).

Force HTTP/2 Only

hackney:get(URL, [], <<>>, [{protocols, [http2]}]).

Force HTTP/1.1 Only

hackney:get(URL, [], <<>>, [{protocols, [http1]}]).

Detecting the Protocol

Check the negotiated protocol on a connection:

{ok, Conn} = hackney:connect(hackney_ssl, "cloudflare.com", 443,
                              [{protocols, [http3]}]),
Protocol = hackney_conn:get_protocol(Conn).  %% http3 | http2 | http1
hackney:close(Conn).

Or verify via Cloudflare's trace endpoint:

{ok, 200, _, Body} = hackney:get(
    <<"https://cloudflare.com/cdn-cgi/trace">>,
    [], <<>>,
    [{protocols, [http3]}, with_body]
),
%% Body contains "http=http/3" if using HTTP/3

Alt-Svc Discovery

Servers advertise HTTP/3 support via the Alt-Svc response header:

Alt-Svc: h3=":443"; ma=86400

Hackney automatically caches these and uses HTTP/3 on subsequent requests:

%% First request uses HTTP/2 or HTTP/1.1
%% Server returns Alt-Svc: h3=":443"; ma=86400
{ok, _, Headers1, _} = hackney:get(URL, [], <<>>, [{protocols, [http3, http2, http1]}]).

%% Alt-Svc is now cached, second request uses HTTP/3
{ok, _, Headers2, _} = hackney:get(URL, [], <<>>, [{protocols, [http3, http2, http1]}]).

Manual Alt-Svc Cache Management

%% Check if HTTP/3 is cached for a host
hackney_altsvc:lookup(<<"example.com">>, 443).
%% {ok, h3, 443} | none

%% Manually cache HTTP/3 endpoint
hackney_altsvc:cache(<<"example.com">>, 443, 443, 86400).

%% Clear cached entry
hackney_altsvc:clear(<<"example.com">>, 443).

%% Clear all cached entries
hackney_altsvc:clear_all().

Connection Multiplexing

Like HTTP/2, HTTP/3 multiplexes requests as streams on a single QUIC connection:

%% All requests share ONE QUIC connection
{ok, _, _, _} = hackney:get(<<"https://cloudflare.com/">>,
                            [], <<>>, [{protocols, [http3]}]).
{ok, _, _, _} = hackney:get(<<"https://cloudflare.com/cdn-cgi/trace">>,
                            [], <<>>, [{protocols, [http3]}]).

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        hackney_pool                              │
│                                                                  │
│  h3_connections = #{ {Host, Port, Transport} => Pid }           │
│                                                                  │
│  checkout_h3(Host, Port, ...) ->                                │
│      case maps:get(Key, h3_connections) of                      │
│          Pid -> {ok, Pid};      %% Reuse existing               │
│          undefined -> none       %% Create new                   │
│      end                                                         │
│                                                                  │
│  register_h3(Host, Port, ..., Pid) ->                           │
│      h3_connections#{Key => Pid}  %% Store for reuse            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│              hackney_conn (gen_statem process)                   │
│                                                                  │
│  h3_conn = <QUIC connection reference>                          │
│                                                                  │
│  h3_streams = #{                                                │
│      0 => {CallerA, waiting_headers, <<>>},                     │
│      4 => {CallerB, waiting_headers, <<>>},                     │
│      8 => {CallerC, waiting_headers, <<>>}                      │
│  }                                                               │
│                                                                  │
│  Request from CallerA → open_stream() → StreamId=0              │
│  Request from CallerB → open_stream() → StreamId=4              │
│  Request from CallerC → open_stream() → StreamId=8              │
│                                                                  │
│  Response for StreamId=4 arrives:                               │
│      → lookup h3_streams[4] → CallerB                           │
│      → gen_statem:reply(CallerB, {ok, Status, Headers, Body})   │
└─────────────────────────────────────────────────────────────────┘

Low-Level Stream API

The high-level hackney:get/post/... functions cover the common case. For servers that send streamed responses, or when you want to drive several requests concurrently on the same QUIC connection, use the hackney_h3 adapter directly.

Connect

{ok, ConnRef} = hackney_h3:connect(<<"cloudflare.com">>, 443, #{}, self()).

receive
    {h3, ConnRef, {connected, _Info}} -> ok
after 5000 ->
    error(connect_timeout)
end.

hackney_h3:connect/4 registers the calling process as the owner of the connection. All events for the connection arrive as messages of the form {h3, ConnRef, Event}.

Send a request

send_request/3 opens a request stream and sends the HEADERS frame in one shot. Pass Fin = true when the request has no body, false if you will follow up with send_data/4:

Headers = [
    {<<":method">>, <<"GET">>},
    {<<":scheme">>, <<"https">>},
    {<<":authority">>, <<"cloudflare.com">>},
    {<<":path">>, <<"/cdn-cgi/trace">>}
],
{ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, true).

For requests with a body:

{ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, false),
ok = hackney_h3:send_data(ConnRef, StreamId, <<"chunk-1">>, false),
ok = hackney_h3:send_data(ConnRef, StreamId, <<"chunk-2">>, true).  %% Fin

Receive the response

The owner process receives a response as a sequence of events tagged with the StreamId:

recv(ConnRef, StreamId, Status, Headers, Body) ->
    receive
        {h3, ConnRef, {stream_headers, StreamId, RespHeaders, _Fin}} ->
            {<<":status">>, S} = lists:keyfind(<<":status">>, 1, RespHeaders),
            recv(ConnRef, StreamId, binary_to_integer(S),
                 [H || {K, _} = H <- RespHeaders, K =/= <<":status">>],
                 Body);
        {h3, ConnRef, {stream_data, StreamId, Chunk, true}} ->
            {ok, Status, Headers, <<Body/binary, Chunk/binary>>};
        {h3, ConnRef, {stream_data, StreamId, Chunk, false}} ->
            recv(ConnRef, StreamId, Status, Headers, <<Body/binary, Chunk/binary>>);
        {h3, ConnRef, {stream_reset, StreamId, ErrorCode}} ->
            {error, {stream_reset, ErrorCode}};
        {h3, ConnRef, {closed, Reason}} ->
            {error, {closed, Reason}}
    after 30000 ->
        {error, timeout}
    end.

The Fin = true flag on a stream_data event marks end-of-stream. For header-only responses (HEAD, 204, 304) the adapter still emits a final {stream_data, StreamId, <<>>, true} so this loop terminates the same way.

Concurrent streams on one connection

Since each request gets its own StreamId, you can have several in flight on the same QUIC connection and demultiplex on the StreamId in your receive:

{ok, S1} = hackney_h3:send_request(ConnRef, headers(<<"/">>), true),
{ok, S2} = hackney_h3:send_request(ConnRef, headers(<<"/cdn-cgi/trace">>), true),
{ok, S3} = hackney_h3:send_request(ConnRef, headers(<<"/robots.txt">>), true),

%% Collect responses as they complete; order is not guaranteed.
collect(ConnRef, sets:from_list([S1, S2, S3]), #{}).

collect(_ConnRef, Pending, Acc) when map_size(Acc) =:= sets:size(Pending) ->
    Acc;
collect(ConnRef, Pending, Acc) ->
    receive
        {h3, ConnRef, {stream_headers, SId, Hs, _}} ->
            collect(ConnRef, Pending, Acc#{SId => {Hs, <<>>}});
        {h3, ConnRef, {stream_data, SId, Chunk, true}} ->
            #{SId := {Hs, Body}} = Acc,
            collect(ConnRef, Pending, Acc#{SId => {Hs, <<Body/binary, Chunk/binary>>}});
        {h3, ConnRef, {stream_data, SId, Chunk, false}} ->
            #{SId := {Hs, Body}} = Acc,
            collect(ConnRef, Pending, Acc#{SId => {Hs, <<Body/binary, Chunk/binary>>}})
    end.

Cancel a stream

Use reset_stream/3 to abort a single in-flight request without tearing down the connection:

ok = hackney_h3:reset_stream(ConnRef, StreamId, 16#0102).  %% H3_REQUEST_CANCELLED

Close

hackney_h3:close(ConnRef, normal).

Event reference

Event Meaning
{connected, Info} QUIC + H3 handshake complete
{stream_headers, StreamId, Headers, Fin} Response headers (or trailers when Fin = true)
{stream_data, StreamId, Bin, Fin} Response body chunk; Fin = true ends the stream
{stream_reset, StreamId, ErrorCode} Peer reset the stream
{goaway, LastStreamId} Peer is shutting down; finish in-flight streams
{closed, Reason} Connection closed
{transport_error, Code, Reason} QUIC transport error

UDP Blocking and Fallback

Some networks block UDP traffic, which prevents HTTP/3 from working. Hackney handles this with negative caching:

%% If HTTP/3 fails, host is marked as blocked for 5 minutes
%% Subsequent requests skip HTTP/3 and use HTTP/2 or HTTP/1.1

%% Check if host is marked as H3-blocked
hackney_altsvc:is_h3_blocked(<<"example.com">>, 443).  %% true | false

%% Manually mark as blocked (e.g., for testing)
hackney_altsvc:mark_h3_blocked(<<"example.com">>, 443).

HTTP/3 vs HTTP/2 Differences

Feature HTTP/3 HTTP/2
Transport QUIC (UDP) TCP
TLS Built-in (TLS 1.3) Separate layer
Head-of-line blocking Per-stream only Connection-wide
Connection migration Supported Not supported
0-RTT resumption Supported Not supported

Header Format

Both HTTP/2 and HTTP/3 use lowercase header names:

%% HTTP/3 headers (same as HTTP/2)
[{<<":status">>, <<"200">>},
 {<<"content-type">>, <<"text/html">>},
 {<<"server">>, <<"cloudflare">>}]

Error Handling

case hackney:get(URL, [], <<>>, [{protocols, [http3]}]) of
    {ok, Status, Headers, Body} ->
        ok;
    {error, {quic_error, Code, Reason}} ->
        %% QUIC-level error
        io:format("QUIC error ~p: ~s~n", [Code, Reason]);
    {error, timeout} ->
        %% Connection timeout (possibly UDP blocked)
        io:format("Timeout - UDP may be blocked~n");
    {error, Reason} ->
        io:format("Error: ~p~n", [Reason])
end.

Performance Tips

Use HTTP/3 for Unreliable Networks

HTTP/3's per-stream flow control and connection migration work well on mobile or lossy networks:

%% Good for mobile apps
Opts = [{protocols, [http3, http2, http1]}, {connect_timeout, 10000}].

Connection Reuse

HTTP/3 connections are expensive to establish. Use pooling:

%% Good: connections are reused via pool
[hackney:get(URL, [], <<>>, [{pool, default}, {protocols, [http3]}])
 || _ <- lists:seq(1, 100)].

%% Bad: new QUIC handshake each time
[hackney:get(URL, [], <<>>, [{pool, false}, {protocols, [http3]}])
 || _ <- lists:seq(1, 100)].

Compatibility

Server Requirements

HTTP/3 requires servers that support:

  • QUIC (RFC 9000)
  • HTTP/3 (RFC 9114)

Major CDNs with HTTP/3 support:

  • Cloudflare
  • Google
  • Fastly
  • Akamai

Checking Server Support

# Using curl
curl -v --http3 https://cloudflare.com/ 2>&1 | grep -i http/3

# Check Alt-Svc header
curl -v https://cloudflare.com/ 2>&1 | grep -i alt-svc

Fallback

If HTTP/3 is unavailable, hackney falls back to HTTP/2 or HTTP/1.1:

%% Works regardless of H3 support (if http2/http1 in protocols)
{ok, _, _, _} = hackney:get(URL, [], <<>>,
                            [{protocols, [http3, http2, http1]}]).

Examples

Elixir

# Start hackney
Application.ensure_all_started(:hackney)

# HTTP/3 request
{:ok, status, headers, body} = :hackney.get(
  "https://cloudflare.com/cdn-cgi/trace",
  [],
  "",
  [{:protocols, [:http3]}, :with_body]
)

# Verify HTTP/3
String.contains?(body, "http=http/3")  # true

Force Protocol

%% HTTP/3 only - fails if server doesn't support it or UDP blocked
{ok, _, _, _} = hackney:get(URL, [], <<>>, [
    with_body,
    {protocols, [http3]}
]).

%% HTTP/2 only - never uses HTTP/3
{ok, _, _, _} = hackney:get(URL, [], <<>>, [
    with_body,
    {protocols, [http2]}
]).

Troubleshooting

HTTP/3 Not Being Used

  1. Check if http3 is in protocols list

  2. Check if host is marked as blocked:

    hackney_altsvc:is_h3_blocked(Host, Port).
  3. Verify server supports HTTP/3:

    curl -v --http3 https://example.com/

Connection Timeouts

UDP may be blocked by firewalls. Try:

  1. Use fallback protocols: {protocols, [http3, http2, http1]}
  2. Check if other HTTP/3 sites work (e.g., cloudflare.com)
  3. Check firewall/network settings for UDP port 443

Next Steps