h2

HTTP/2 client and server for Erlang/OTP.

Install

Add to rebar.config:

{deps, [
{h2, "0.8.0", {git, "https://github.com/benoitc/erlang_h2.git", {tag, "0.8.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:

MessageMeaning
{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

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(),
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

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.

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.

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 see :protocol in the request Headers argument. Tunnel semantics (no body length, no trailers) apply once the 2xx is sent.

Using with Ranch

The built-in h2:start_server/2 runs its own acceptor pool. To plug into an existing Ranch listener instead, use h2_connection directly — it's a normal gen_statem you hand a socket to. 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, #{handler := Handler} = Opts) ->
{ok, Socket} = ranch:handshake(Ref),
TransportMod = case Transport of ranch_ssl -> ssl; ranch_tcp -> gen_tcp end,
ConnOpts = #{settings => maps:get(settings, Opts, #{}),
enable_connect_protocol => maps:get(enable_connect_protocol, Opts, false)},
{ok, Conn} = h2_connection:start_link(server, Socket, self(), ConnOpts),
ok = TransportMod:controlling_process(Socket, Conn),
_ = h2_connection:activate(Conn),
server_loop(Conn, Handler).
server_loop(Conn, Handler) ->
receive
{h2, Conn, {request, Sid, M, P, H}} ->
spawn(fun() -> Handler(Conn, Sid, M, P, H) end),
server_loop(Conn, Handler);
{h2, Conn, {closed, _}} -> ok;
_ -> server_loop(Conn, Handler)
end.

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_connection handles h2 semantics. Public primitives used: h2_connection:start_link/4 and h2_connection:activate/1.

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. Advertise both protocols on the listener and dispatch by ALPN result. With the Ranch snippet above:

init(Ref, ranch_ssl, Opts) ->
{ok, Socket} = ranch:handshake(Ref),
case ssl:negotiated_protocol(Socket) of
{ok, <<"h2">>} -> start_h2(Socket, Opts);
{ok, <<"http/1.1">>} -> start_http1(Socket, Opts); %% e.g. cowboy / elli
_ -> ssl:close(Socket)
end.

Listener alpn_preferred_protocols becomes [<<"h2">>, <<"http/1.1">>]. One port, TLS picks per connection.

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

ModulePurpose
h2Public API (client + server).
h2_connectiongen_statem per-connection state machine.
h2_serverTLS listener + acceptor pool.
h2_frameFrame encode/decode.
h2_hpackHPACK encoder/decoder.
h2_settingsSETTINGS encode/decode/validate.
h2_errorError 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 streamssend_response + send_datarespond/5cowboy
16 x 16250k req/s388k req/s320k req/s
32 x 32268k req/s420k req/s397k 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