h2

HTTP/2 client and server for Erlang/OTP.

Install

Add to rebar.config:

{deps, [
    {h2, "0.17.0", {git, "https://github.com/benoitc/erlang_h2.git", {tag, "0.17.0"}}}
]}.

Requires Erlang/OTP 24+.

Client

{ok, Conn} = h2:connect("example.com", 443),
{ok, StreamId} = h2:request(Conn, <<"GET">>, <<"/">>, [{<<"host">>, <<"example.com">>}]),
receive
    {h2, Conn, {response, StreamId, Status, _Headers}} ->
        io:format("status: ~p~n", [Status])
end,
receive
    {h2, Conn, {data, StreamId, Body, true}} ->
        io:format("body: ~p~n", [Body])
end,
ok = h2:close(Conn).

Messages delivered to the owner process:

Message Meaning
{h2, Conn, connected} handshake + SETTINGS exchange complete
{h2, Conn, {response, StreamId, Status, Headers}} final response headers (2xx–5xx)
{h2, Conn, {informational, StreamId, Status, Headers}} 1xx interim response (100/103/…)
{h2, Conn, {data, StreamId, Data, EndStream}} response body fragment (an empty final frame marks end-of-stream for body-less responses)
{h2, Conn, {trailers, StreamId, Headers}} response trailers
{h2, Conn, {stream_reset, StreamId, ErrorCode}} peer sent RST_STREAM
{h2, Conn, {goaway, LastStreamId, ErrorCode}} peer is shutting down
{h2, Conn, {closed, Reason}} connection closed

By default these go to the connection owner. Register a per-stream handler (see Bidirectional streaming) to route a single stream's events to a dedicated process instead, so many concurrent calls can be driven independently over one connection.

Options to h2:connect/3:

#{transport    => ssl | tcp,            %% default: ssl
  ssl_opts     => [ssl:tls_client_option()],
  verify       => verify_peer | verify_none,
  cacerts      => [binary()],
  settings     => h2_settings:settings(),
  notify_stream_available => boolean(), %% default: false
  timeout      => timeout()}

Send a request body in chunks:

{ok, Sid} = h2:request(Conn, <<"POST">>, <<"/upload">>, Headers, false),
ok = h2:send_data(Conn, Sid, Chunk1, false),
ok = h2:send_data(Conn, Sid, Chunk2, true).   %% last chunk: EndStream = true

Wait for a free stream slot

The server caps how many streams you may have open at once (SETTINGS_MAX_CONCURRENT_STREAMS). At the cap, h2:request/4,5 and h2:request/3 return {error, max_streams_exceeded}. Use notify_stream_available => true when you want to queue the request and retry instead of failing it.

After a refusal, the connection sends {h2, Conn, stream_available} once, the first time a slot frees: one of your streams ends, or the server raises its limit. Nothing is sent if no request was refused, or after a GOAWAY.

{ok, Conn} = h2:connect("example.com", 443, #{notify_stream_available => true}).
request(Conn, Headers) ->
    case h2:request(Conn, <<"GET">>, <<"/">>, Headers) of
        {error, max_streams_exceeded} ->
            receive
                {h2, Conn, stream_available} -> request(Conn, Headers);
                {h2, Conn, {goaway, _, _}} -> {error, goaway}
            end;
        Other ->
            Other
    end.

Notes:

Server

h2:start_server/2 starts the listener under the h2 application's supervision tree, so make sure the application is started first:

ok = application:ensure_started(h2).
Handler = fun(Conn, StreamId, <<"GET">>, <<"/">>, _Headers) ->
    h2:send_response(Conn, StreamId, 200, [{<<"content-type">>, <<"text/plain">>}]),
    h2:send_data(Conn, StreamId, <<"Hello HTTP/2!">>, true)
end,

{ok, Server} = h2:start_server(8443, #{
    cert    => "server.pem",
    key     => "server.key",
    handler => Handler
}),

Port = h2:server_port(Server),
%% ... later ...
ok = h2:stop_server(Server).

For the common headers-plus-body response, h2:respond/5 sends both in a single call and a single socket write (HEADERS coalesced with DATA), instead of the two round-trips of send_response/4 + send_data/4. It falls back to the granular path automatically when the response cannot be coalesced (oversized headers or body, CONNECT tunnels):

Handler = fun(Conn, StreamId, <<"GET">>, <<"/">>, _Headers) ->
    h2:respond(Conn, StreamId, 200, [{<<"content-type">>, <<"text/plain">>}],
               <<"Hello HTTP/2!">>)
end.

Use the granular send_response/4 + send_data/4 (or send_data/4 in chunks) when the body is streamed or produced incrementally.

Responses with no content

A final response to HEAD, and any 204 or 304, has no content (RFC 9110 §6.4.1). The server ends the stream on its HEADERS, so you can keep the same code path as for GET: a body passed to respond/5, and any later send_data or send_trailers, is dropped and the call returns ok. A content-length you set on a 1xx or 204 is removed; on 304 and HEAD it is kept.

Send 1xx (100, 103) with send_response/4 before the final response. respond/5 refuses them with {error, informational_status}:

Handler = fun(Conn, StreamId, _Method, _Path, _Headers) ->
    ok = h2:send_response(Conn, StreamId, 103, [{<<"link">>, <<"</app.css>; rel=preload">>}]),
    h2:respond(Conn, StreamId, 200, [{<<"content-type">>, <<"text/html">>}], Html)
end.

A 1xx after the final response returns {error, final_response_sent}, and send_data or send_trailers after only a 1xx returns {error, no_final_response}.

Options to h2:start_server/2,3:

#{cert                    := binary() | string(),
  key                     := binary() | string(),
  cacerts                 => [binary()],
  handler                 := fun((Conn, StreamId, Method, Path, Headers) -> any()),
  settings                => h2_settings:settings(),
  acceptors               => pos_integer(),       %% default: schedulers
  backlog                 => pos_integer(),       %% listen queue, default: 1024
  transport               => ssl | tcp,           %% default: ssl
  enable_connect_protocol => boolean()}           %% RFC 8441, default: false

A module handler (handler => {Mod, Args}) is also supported; Mod:handle_request/5 receives the same arguments.

The Headers argument is the request header list with pseudo-headers stripped, except :authority, :scheme, and :protocol, which are kept so adapters can reconstruct the request authority/scheme (a compliant client may send :authority and no host header) and read the Extended CONNECT token. :method and :path are already passed as the separate Method/Path arguments. Read what you need and strip these pseudo-headers before exposing the list as ordinary application headers:

Handler = fun(Conn, StreamId, Method, Path, Headers) ->
    Authority  = proplists:get_value(<<":authority">>, Headers),
    Scheme     = proplists:get_value(<<":scheme">>, Headers),
    AppHeaders = [{K, V} || {<<C, _/binary>> = K, V} <- Headers, C =/= $:],
    my_app:handle(Conn, StreamId, Method, Path, Scheme, Authority, AppHeaders)
end.

Scaling the acceptor pool

Each accepted socket runs its own h2_connection gen_statem, so concurrency on established connections scales with BEAM schedulers. The acceptors opt only sizes the pool of processes blocked in ssl:transport_accept / gen_tcp:accept on the shared listen socket — raise it when the incoming connection rate (not in-flight traffic) is the bottleneck:

h2:start_server(8443, #{cert => ..., key => ..., handler => ...,
                        acceptors => 100}).

Default is erlang:system_info(schedulers) (one per core). 100 is a good ceiling for high connection-rate workloads; going higher rarely helps. Complementary knobs: kernel somaxconn / tcp_max_syn_backlog, OS-level SSL session cache, and a handler that spawns per request (the built-in server loop already does).

CONNECT tunnels (RFC 7540 §8.3)

Open a bidirectional byte tunnel through an h2 proxy:

%% Client
{ok, Conn} = h2:connect(ProxyHost, ProxyPort),
{ok, Sid}  = h2:request(Conn, [
    {<<":method">>, <<"CONNECT">>},
    {<<":authority">>, <<"target.example.com:443">>}
]),
receive {h2, Conn, {response, Sid, 200, _}} -> ok end,
ok = h2:send_data(Conn, Sid, <<"raw bytes">>, false),
receive {h2, Conn, {data, Sid, Reply, _}} -> Reply end.

Server handler establishes the tunnel by replying 2xx, then echoes / forwards bytes via set_stream_handler/3:

fun(Conn, Sid, <<"CONNECT">>, _, _Headers) ->
    h2:send_response(Conn, Sid, 200, []),
    h2:set_stream_handler(Conn, Sid, self()),
    tunnel_loop(Conn, Sid)
end.

Tunnel semantics: DATA frames carry raw bytes (no body-length enforcement), END_STREAM is a half-close, trailers are rejected, and Content-Length / Transfer-Encoding on the 2xx response are rejected.

Extended CONNECT (RFC 8441)

Bootstrap WebSockets (or any protocol) over an HTTP/2 stream. Server opts in:

{ok, Server} = h2:start_server(8443, #{
    cert => "server.pem",
    key => "server-key.pem",
    handler => Handler,
    enable_connect_protocol => true   %% advertises SETTINGS_ENABLE_CONNECT_PROTOCOL=1
}).

Client uses the protocol opt; :scheme, :path, :authority are required:

{ok, Conn} = h2:connect("localhost", 8443),
{ok, Sid}  = h2:request(Conn, [
    {<<":method">>, <<"CONNECT">>},
    {<<":scheme">>, <<"https">>},
    {<<":path">>, <<"/chat">>},
    {<<":authority">>, <<"localhost">>}
], #{protocol => <<"websocket">>}),
receive {h2, Conn, {response, Sid, 200, _}} -> ok end,
ok = h2:send_data(Conn, Sid, FrameBytes, false).

If the peer never advertised the setting, h2:request/3 returns {error, extended_connect_disabled}. Server handlers read :protocol from the request Headers argument (alongside :authority/:scheme; see Server). Tunnel semantics (no body length, no trailers) apply once the 2xx is sent.

WebTransport over HTTP/2

h2 carries the connection-level pieces that draft-ietf-webtrans-http2 asks of the HTTP/2 layer; the session protocol lives in erlang-webtransport. Use these when you build WebTransport, or anything else that depends on extension settings or the TLS version.

Watch the peer's SETTINGS

The peer may change SETTINGS_WT_ENABLED (0x2b67) at any time. Ask to be told:

{ok, Conn} = h2:connect("localhost", 8443, #{notify_peer_settings => true}),
receive
    {h2, Conn, {peer_settings, #{16#2b67 := V}}} when V > 1 ->
        h2:goaway(Conn, protocol_error);
    {h2, Conn, {peer_settings, _}} ->
        ok
end.

On a server, give notify_peer_settings => true to h2:start_server/2 and export handle_peer_settings(Conn, Settings) from the handler module.

Check the TLS version

WebTransport over HTTP/2 needs TLS 1.3 on OTP. Check it before you accept a session:

case h2:connection_info(Conn) of
    {ok, #{tls_version := 'tlsv1.3'}} -> accept_session;
    {ok, _}                          -> h2:send_response(Conn, StreamId, 400, [])
end.

The map always has transport => tcp | ssl; TLS connections add tls_version, alpn and, when the client sent one, sni_hostname.

Use the SETTINGS in effect when you send HEADERS

The session request and the response that accepts it must use the peer settings acknowledged at that moment. Get them from the same call:

{ok, Sid, PeerSettings} =
    h2:request(Conn, Headers, #{protocol => <<"webtransport">>,
                                report_peer_settings => true}),

{ok, ClientSettings} =
    h2:send_response(Conn, StreamId, 200, [], #{report_peer_settings => true}).

Without the option, h2:request/3 returns {ok, StreamId} and h2:send_response/5 returns ok, as h2:send_response/4 does.

Bidirectional streaming (gRPC)

A gRPC call is one HTTP/2 stream where both peers send interleaved DATA, the client half-closes with END_STREAM on its last DATA while still receiving, and the server ends with trailers carrying grpc-status. Each call runs in its own process that owns its stream's events without owning the connection, so many calls multiplex one connection.

Pass #{handler => Pid} at request time to route every event for that stream (response, data, trailers, informational, stream_reset) to Pid from creation, race-free:

%% Client call process
{ok, Sid} = h2:request(Conn, [
    {<<":method">>, <<"POST">>},
    {<<":scheme">>, <<"https">>},
    {<<":path">>, <<"/pkg.Service/BidiMethod">>},
    {<<":authority">>, <<"localhost">>},
    {<<"content-type">>, <<"application/grpc">>},
    {<<"te">>, <<"trailers">>}
], #{handler => self(), end_stream => false}),
receive {h2, Conn, {response, Sid, 200, _}} -> ok end,
[ok = h2:send_data(Conn, Sid, Msg, false) || Msg <- OutMsgs],
ok = h2:send_data(Conn, Sid, <<>>, true),     %% half-close, keep receiving
{InMsgs, Trailers} = collect(Conn, Sid).       %% data frames, then {trailers, ...}
%% Server handler: stream out while reading inbound, end with trailers
fun(Conn, Sid, _Method, _Path, _Headers) ->
    ok = h2:set_stream_handler(Conn, Sid, self()),
    ok = h2:send_response(Conn, Sid, 200, [{<<"content-type">>, <<"application/grpc">>}]),
    ok = bidi_loop(Conn, Sid),                  %% send_data / receive {data, ...} interleaved
    ok = h2:send_trailers(Conn, Sid, [{<<"grpc-status">>, <<"0">>}])
end.

h2:set_stream_handler/3,4 works after the stream exists too: any events that arrived before registration are buffered and replayed to the handler in order, never dropped to the owner.

Receive backpressure. By default the stream receive window is replenished on dispatch, so a slow handler's mailbox can grow unbounded. Pass #{flow_control => manual} (at request time or in set_stream_handler/4) and call h2:consume/3 after processing, so WINDOW_UPDATE tracks consumer progress:

{ok, Sid} = h2:request(Conn, Headers, #{handler => self(), flow_control => manual}),
%% ... after handling N received bytes:
ok = h2:consume(Conn, Sid, N).

Receive windows for large downloads. settings.initial_window_size only sizes each stream's window; every stream also shares one connection window, which stays at 65,535 bytes unless you set connection_window_size (65535..2147483647). With the default, a download moves at most 64 KB per round trip. Raise both for large bodies over high-latency links; the same option is accepted by h2:start_server/2,3 and h2:serve_socket/2:

{ok, Conn} = h2:connect(Host, 443, #{settings => #{initial_window_size => 8 * 1024 * 1024},
                                     connection_window_size => 32 * 1024 * 1024}).

An invalid value makes h2_connection:start_link/3,4 return {error, {invalid_option, {connection_window_size, V}}}.

Send backpressure. h2:send_data/4 buffers when the peer's window is shut and returns {error, send_buffer_full} once the per-stream cap is reached (non-blocking; back off and retry). For a blocking send, h2:send_data/5 with #{block => Timeout} waits until the window accepts the data, returning ok or {error, timeout}:

case h2:send_data(Conn, Sid, Msg, false, #{block => 5000}) of
    ok              -> ok;
    {error, timeout} -> h2:cancel(Conn, Sid)
end.

Cancel and teardown. h2:cancel/2,3 sends RST_STREAM; the peer's stream handler receives {stream_reset, Sid, Code} (deadline/cancel map to CANCEL). A stream handler is also told about connection-wide {goaway, ...} and {closed, Reason}, so a call process learns of disconnect and can clean up without owning the connection.

The message framing (the 5-byte length prefix and protobuf) is the application's concern; h2 carries the HTTP/2 stream, flow control, and trailers. See test/h2_grpc_tests.erl for a full loopback example and test/h2_grpc_interop_SUITE.erl / test/h2_grpc_client_interop_SUITE.erl for interop against real gRPC peers.

Client certificates (mutual TLS)

Use this when a handler authorizes clients by their TLS certificate. Ask for client certificates when you start the server, then read the certificate from the connection:

{ok, Server} = h2:start_server(8443, #{
    cert    => "server.pem",
    key     => "server-key.pem",
    verify  => verify_peer,
    cacerts => [ClientCaDer],
    %% let clients without a certificate in, as anonymous
    ssl_opts => [{fail_if_no_peer_cert, false}],
    handler => fun(Conn, StreamId, _Method, _Path, _Headers) ->
        Status = case h2:peercert(Conn) of
            {ok, _Der}           -> 200;
            {error, no_peercert} -> 403
        end,
        h2:send_response(Conn, StreamId, Status, []),
        h2:send_data(Conn, StreamId, <<>>, true)
    end
}).

h2:peercert/1 returns the DER certificate, or {error, no_peercert} when the client sent none or the connection is cleartext. On a client connection it returns the server's certificate.

Using with Ranch

The built-in h2:start_server/2 runs its own acceptor pool. To plug into an existing Ranch listener instead, hand the accepted socket to h2:serve_socket/2. A minimal Ranch protocol module:

-module(h2_ranch_protocol).
-behaviour(ranch_protocol).
-export([start_link/3]).

start_link(Ref, Transport, Opts) ->
    {ok, spawn_link(fun() -> init(Ref, Transport, Opts) end)}.

init(Ref, _Transport, Opts) ->
    {ok, Socket} = ranch:handshake(Ref),
    {ok, Conn}   = h2:serve_socket(Socket, Opts),
    %% Ranch counts this process as the connection; outlive the h2 one.
    MRef = erlang:monitor(process, Conn),
    receive {'DOWN', MRef, process, Conn, _} -> ok end.

Opts is the same map h2:start_server/2 takes — serve_socket/2 reads handler, settings, and enable_connect_protocol from it.

Wire it up:

{ok, _} = ranch:start_listener(my_h2, ranch_ssl,
    #{socket_opts => [{port, 8443},
                      {certfile, "cert.pem"}, {keyfile, "key.pem"},
                      {alpn_preferred_protocols, [<<"h2">>]}]},
    h2_ranch_protocol,
    #{handler => fun my_app:handle/5}).

Ranch owns draining, acceptor-pool sizing, and metrics; h2 handles h2 semantics.

Coexisting with HTTP/1.1

This library is HTTP/2-only. Where you need HTTP/1.1 too, the pattern is always "different library, same socket boundary".

Client fallback. h2:connect/2,3 surfaces ALPN mismatches explicitly — no silent fall-through to assumed-h2 (RFC 9113 §3.3):

case h2:connect(Host, 443) of
    {ok, Conn}                               -> h2_flow(Conn);
    {error, {alpn_mismatch, <<"http/1.1">>}} -> http1_flow(Host);
    {error, alpn_not_negotiated}             -> http1_flow(Host);
    Err                                      -> Err
end.

Dual-stack server. Own the listener yourself, advertise both protocols, and hand the h2 connections to h2:serve_socket/2 — same map start_server/2 takes, over a socket you already accepted and handshook:

{ok, Listen} = ssl:listen(8443, [{certfile, "cert.pem"}, {keyfile, "key.pem"},
                                 {alpn_preferred_protocols, [<<"h2">>, <<"http/1.1">>]},
                                 {active, false}, {mode, binary}]),

accept(Listen, Opts) ->
    {ok, Sock} = ssl:transport_accept(Listen),
    spawn(fun() -> dispatch(Sock, Opts) end),
    accept(Listen, Opts).

dispatch(Sock, Opts) ->
    {ok, Socket} = ssl:handshake(Sock, 30000),
    case ssl:negotiated_protocol(Socket) of
        {ok, <<"h2">>}       -> h2:serve_socket(Socket, Opts);
        {ok, <<"http/1.1">>} -> my_http1:serve(Socket);
        _                    -> ssl:close(Socket)
    end.

One port, TLS picks per connection. Handshake in the per-connection process, not the accept loop, so a stalled client can't block the queue.

serve_socket/2 returns {ok, Pid} and takes ownership of the socket — don't read, write, or close it afterwards. Pid is spawned linked to the caller: a clean disconnect exits normal, a connection failure propagates unless you trap exits, and your death tears the connection down. Call it from the process whose lifetime the connection should follow. ALPN is not re-checked; you already resolved it. A plain gen_tcp socket works too and is served as prior-knowledge h2c.

Cleartext Upgrade: h2c from HTTP/1.1. Deprecated by RFC 9113 and not supported. Use prior-knowledge h2c instead — both peers agree out of band that the connection is plaintext h2:

{ok, Conn}   = h2:connect(Host, Port, #{transport => tcp}).
{ok, Server} = h2:start_server(Port, #{transport => tcp, handler => Handler}).

Modules

Module Purpose
h2 Public API (client + server).
h2_connection gen_statem per-connection state machine.
h2_server TLS listener + acceptor pool.
h2_frame Frame encode/decode.
h2_hpack HPACK encoder/decoder.
h2_settings SETTINGS encode/decode/validate.
h2_error Error code mappings.

Performance

Each connection is one h2_connection gen_statem that owns the socket; each request is handled in its own process, so in-flight concurrency scales with BEAM schedulers.

For the common request/response, prefer h2:respond/5 (status + headers + body in one call and one coalesced socket write) over h2:send_response/4 + h2:send_data/4 (two gen_statem round-trips and two writes). Use the granular pair when the body is produced or streamed incrementally.

Indicative h2c throughput, h2load on a 14-core machine with a "Hello, World!" handler, against cowboy 2.14 on the same box:

clients x streams send_response + send_data respond/5 cowboy
16 x 16 250k req/s 388k req/s 320k req/s
32 x 32 268k req/s 420k req/s 397k req/s

Numbers are machine-specific; treat them as relative. Reproduce with the harness under bench/ (start_h2.sh, raw-client and fprof/decode microbenches).

Tuning knobs: a handler that uses respond/5, acceptors (connection-accept rate), and backlog (listen queue). HPACK decode is cheapest once a connection's dynamic table is warm (repeated headers decode as indexed references); the cold path (first request, or high header churn) decodes literal values through a table-driven Huffman state machine.

Build and test

rebar3 compile
rebar3 eunit          # 310 tests + 800 PropEr properties
rebar3 ct             # 81 compliance + 6 h2spec interop cases
rebar3 dialyzer       # clean
rebar3 xref           # clean
rebar3 ex_doc         # HTML docs

Interop

External-peer interop tests live in test/h2_interop_SUITE.erl and drive the server from h2spec. Install h2spec locally, then:

# macOS
brew install summerwind/h2spec/h2spec
# Linux: download a release tarball from
# https://github.com/summerwind/h2spec/releases

rebar3 ct --suite=test/h2_interop_SUITE

Without h2spec on PATH the suite skips cleanly. The generic and HPACK groups are run in observe mode — failures are logged with full output for triage rather than treated as hard failures. Library-level conformance (which does not need h2spec) stays in h2_compliance_SUITE.

Status

Production-oriented: spec-correct and deterministic under the in-tree CT suite. Intentionally out of scope:

License

Apache License 2.0