h1

HTTP/1.1 client and server for Erlang/OTP. Designed so call sites that already use h2 (HTTP/2) or quic_h3 (HTTP/3) can swap protocols without rewrites.

Contents

Install

The hex package is published as erlang_h1 (the short name h1 was already taken on hex.pm). The OTP application and module atom stay h1, so call sites write h1:connect/2 either way.

%% rebar.config — from hex
{deps, [{erlang_h1, "0.1.1"}]}.

%% Or directly from git
{deps, [
    {erlang_h1, {git, "https://github.com/benoitc/erlang_h1.git", {tag, "0.1.1"}}}
]}.

Requires Erlang/OTP 26+.

The h1 application owns the top-level supervisor for listeners. Start it from your *.app.src dependencies or manually:

ok = application:ensure_started(h1).

Quickstart

One-shot GET

ok = application:ensure_started(h1),
{ok, Conn}     = h1:connect("example.com", 80),
{ok, StreamId} = h1:request(Conn, <<"GET">>, <<"/">>, []),
{Status, Body} =
    receive
        {h1, Conn, {response, StreamId, S, _Headers}} ->
            Payload = collect(Conn, StreamId, <<>>),
            {S, Payload}
    end,
ok = h1:close(Conn),
io:format("~p ~p~n", [Status, Body]).

collect(Conn, Id, Acc) ->
    receive
        {h1, Conn, {data, Id, D, false}} -> collect(Conn, Id, <<Acc/binary, D/binary>>);
        {h1, Conn, {data, Id, D, true}}  -> <<Acc/binary, D/binary>>;
        {h1, Conn, {closed, _}}          -> Acc
    end.

A server that echoes "hello"

ok = application:ensure_started(h1),
Handler = fun(Conn, Id, <<"GET">>, _Path, _Hs) ->
    h1:send_response(Conn, Id, 200,
                     [{<<"content-type">>, <<"text/plain">>}]),
    h1:send_data(Conn, Id, <<"hello HTTP/1.1">>, true)
end,
{ok, Server} = h1:start_server(8080, #{handler => Handler}),
io:format("listening on ~p~n", [h1:server_port(Server)]).

curl http://127.0.0.1:8080/ returns hello HTTP/1.1.

Client

h1:connect/2,3 opens a connection. The calling process becomes the owner: all protocol events arrive as messages tagged {h1, Conn, Event}.

-spec h1:connect(Host, Port)       -> {ok, pid()} | {error, term()}.
-spec h1:connect(Host, Port, Opts) -> {ok, pid()} | {error, term()}.

Host may be a string, binary, or inet:ip_address(). Opts is a map (see Tuning); transport defaults to plain TCP.

Simple GET

{ok, Conn}     = h1:connect(<<"httpbin.org">>, 80),
{ok, StreamId} = h1:request(Conn, <<"GET">>, <<"/get">>, []).

Host: is auto-added from the connect hostname if omitted.

POST with a known body

Body = <<"{\"hello\":\"world\"}">>,
{ok, _} = h1:request(Conn, <<"POST">>, <<"/post">>, [
    {<<"content-type">>, <<"application/json">>}
], Body).

Content-Length is computed automatically when you pass the body as the 5th argument.

Streaming an upload

When you don't know the body size up front, use chunked transfer:

{ok, Sid} = h1:request(Conn, <<"POST">>, <<"/upload">>, [
    {<<"transfer-encoding">>, <<"chunked">>}
]),
ok = h1:send_data(Conn, Sid, <<"chunk-1 ">>,  false),
ok = h1:send_data(Conn, Sid, <<"chunk-2 ">>,  false),
ok = h1:send_data(Conn, Sid, <<"chunk-3">>,   true).

The last call with EndStream = true writes the trailing 0\r\n\r\n.

Pipelining multiple requests

{ok, S1} = h1:request(Conn, <<"GET">>, <<"/a">>, []),
{ok, S2} = h1:request(Conn, <<"GET">>, <<"/b">>, []),
{ok, S3} = h1:request(Conn, <<"GET">>, <<"/c">>, []),
%% Responses arrive in the same order:
{h1, Conn, {response, S1, _, _}} = receive_next(),
{h1, Conn, {response, S2, _, _}} = receive_next(),
{h1, Conn, {response, S3, _, _}} = receive_next().

Pass pipeline => false in the connect opts if you want explicit serialization — a second h1:request while a response is in flight then returns {error, pipeline_disabled}.

Reading trailers

{ok, Sid} = h1:request(Conn, <<"GET">>, <<"/chunked-with-trailers">>, []),
loop(Conn, Sid).

loop(Conn, Sid) ->
    receive
        {h1, Conn, {response, Sid, Status, Headers}} ->
            io:format("status=~p~n", [Status]),
            loop(Conn, Sid);
        {h1, Conn, {data, Sid, D, false}} ->
            io:format("chunk: ~p~n", [D]),
            loop(Conn, Sid);
        {h1, Conn, {trailers, Sid, Trailers}} ->
            io:format("trailers: ~p~n", [Trailers]);
        {h1, Conn, {data, Sid, _, true}} ->
            io:format("(no trailers)~n")
    end.

{trailers, Sid, _} is an implicit end-of-stream, so a chunked response with trailers does not also emit a final {data, _, _, true} event.

100-continue

Pass Expect: 100-continue in the request headers when sending a body:

{ok, Sid} = h1:request(Conn, <<"PUT">>, <<"/big">>, [
    {<<"expect">>, <<"100-continue">>},
    {<<"content-length">>, integer_to_binary(byte_size(Body))}
], Body).

The client stages the body; it is released onto the wire when a 100 Continue arrives (surfaced as {informational, Sid, 100, _}) or when any non-100 response aborts it.

HEAD, CONNECT, and other methods

{ok, _} = h1:request(Conn, <<"HEAD">>, <<"/big.iso">>, []),
receive
    {h1, Conn, {response, _, _, Hs}} ->
        %% No body follows — the parser honors the HEAD rule even if
        %% Content-Length is present.
        proplists:get_value(<<"content-length">>, Hs)
end.

Closing

ok = h1:close(Conn).

close/1 tolerates an already-exited connection (common if the peer closed first), so you don't need to trap exits just to call it.

Server

h1:start_server/2,3 opens a listener under the h1 application's supervisor. The listener owns one listen socket and an acceptor pool; each accepted connection spawns an h1_server loop that dispatches requests to your handler.

Minimal

Handler = fun(Conn, Id, Method, Path, _Headers) ->
    Body = iolist_to_binary(io_lib:format("~s ~s", [Method, Path])),
    h1:send_response(Conn, Id, 200,
        [{<<"content-type">>, <<"text/plain">>},
         {<<"content-length">>, integer_to_binary(byte_size(Body))}]),
    h1:send_data(Conn, Id, Body, true)
end,
{ok, Server} = h1:start_server(8080, #{handler => Handler}).

Named server + stop

{ok, Server} = h1:start_server(my_http, 8080, #{handler => Handler}),
Port = h1:server_port(Server),
%% later
ok = h1:stop_server(Server).

Handler as a module

-module(my_handler).
-export([handle_request/5]).

handle_request(Conn, Id, <<"GET">>, <<"/">>, _Headers) ->
    h1:send_response(Conn, Id, 200, [{<<"content-length">>, <<"2">>}]),
    h1:send_data(Conn, Id, <<"ok">>, true);
handle_request(Conn, Id, _, _, _) ->
    h1:send_response(Conn, Id, 404, [{<<"content-length">>, <<"0">>}]),
    h1:send_data(Conn, Id, <<>>, true).

%% wire it up
{ok, _} = h1:start_server(8080, #{handler => my_handler}).

Reading a request body

Body and trailer events arrive as {h1_stream, Id, _} messages in the handler process:

echo(Conn, Id, <<"POST">>, _Path, Headers) ->
    Body = collect_body(Id, <<>>),
    Size = integer_to_binary(byte_size(Body)),
    h1:send_response(Conn, Id, 200, [{<<"content-length">>, Size}]),
    h1:send_data(Conn, Id, Body, true).

collect_body(Id, Acc) ->
    receive
        {h1_stream, Id, {data, Bin, true}}  -> <<Acc/binary, Bin/binary>>;
        {h1_stream, Id, {data, Bin, false}} -> collect_body(Id, <<Acc/binary, Bin/binary>>);
        {h1_stream, Id, {trailers, _}}      -> Acc
    end.

Chunked response

When the body length isn't known up front, declare Transfer-Encoding: chunked and stream with send_data/4:

stream(Conn, Id, _Method, _Path, _Headers) ->
    h1:send_response(Conn, Id, 200,
        [{<<"transfer-encoding">>, <<"chunked">>},
         {<<"content-type">>, <<"application/octet-stream">>}]),
    feed_pieces(Conn, Id, 10).

feed_pieces(Conn, Id, 0) ->
    h1:send_data(Conn, Id, <<>>, true);
feed_pieces(Conn, Id, N) ->
    h1:send_data(Conn, Id, integer_to_binary(N), false),
    timer:sleep(50),
    feed_pieces(Conn, Id, N - 1).

Emitting trailers

trailers(Conn, Id, _, _, _) ->
    h1:send_response(Conn, Id, 200,
        [{<<"transfer-encoding">>, <<"chunked">>},
         {<<"trailer">>, <<"x-checksum">>}]),
    h1:send_data(Conn, Id, <<"payload">>, false),
    h1:send_trailers(Conn, Id, [{<<"x-checksum">>, <<"deadbeef">>}]).

100-continue (server side)

When a client sends Expect: 100-continue, the handler sees it in the request headers and can decide whether to accept the body:

expect_continue(Conn, Id, _, _, Headers) ->
    case proplists:get_value(<<"expect">>, Headers) of
        <<"100-continue">> -> h1:continue(Conn, Id);
        _ -> ok
    end,
    Body = collect_body(Id, <<>>),
    h1:send_response(Conn, Id, 201, [{<<"content-length">>, <<"0">>}]),
    h1:send_data(Conn, Id, <<>>, true).

Not calling h1:continue/2 (and instead sending the final response directly) is also legal — it tells the client to abort the upload.

Pipelining

h1_server spawns one handler process per request but blocks the connection loop until that handler exits before accepting the next request. This guarantees pipelined response bytes are written in order, as required by RFC 9112 §9.3. Handlers still get their own mailbox for body streaming. Scale request-rate by raising acceptors (default: one per scheduler).

Upgrade + capsules

The Upgrade / 101 handshake is exposed at the public API. After a successful upgrade the raw socket is transferred to the caller with any leftover bytes the parser had buffered.

Client initiating an upgrade

{ok, Conn} = h1:connect("proxy.example", 443, #{transport => ssl}),
{ok, StreamId, Sock, Buf, RespHeaders} =
    h1:upgrade(Conn, <<"connect-udp">>, [
        {<<"capsule-protocol">>, <<"?1">>}
    ]),
%% From here, Sock is a plain ssl/gen_tcp socket owned by this process.
%% Anything already in Buf was received past the 101 response.

Server accepting an upgrade

The request handler inspects the Upgrade: header and calls accept_upgrade/3 to switch protocols:

handle(Conn, Id, <<"GET">>, _Path, Headers) ->
    case proplists:get_value(<<"upgrade">>, Headers) of
        <<"connect-udp">> ->
            {ok, Sock, Buf} = h1:accept_upgrade(Conn, Id,
                [{<<"capsule-protocol">>, <<"?1">>}]),
            masque_loop(Sock, Buf);
        _ ->
            h1:send_response(Conn, Id, 426,
                [{<<"content-length">>, <<"0">>},
                 {<<"upgrade">>, <<"connect-udp">>}]),
            h1:send_data(Conn, Id, <<>>, true)
    end.

RFC 9297 capsule framing on the raw socket

Once you have the post-handoff socket, h1_upgrade handles capsule encode/decode:

ok = h1_upgrade:send_capsule(gen_tcp, Sock, datagram, <<"udp payload">>),

case h1_upgrade:recv_capsule(gen_tcp, Sock, Buf) of
    {ok, {datagram, Payload}, Rest} ->
        io:format("got datagram ~p~n", [Payload]),
        loop(Sock, Rest);
    {ok, {CustomType, Payload}, Rest} ->
        io:format("capsule type=~p payload=~p~n", [CustomType, Payload]),
        loop(Sock, Rest);
    {error, R} ->
        io:format("capsule decode error: ~p~n", [R])
end.

h1_upgrade is protocol-agnostic — it doesn't know about connect-udp, connect-ip, or any specific capsule type. Consumers like masque layer their own semantics on top.

TLS

Client connecting to a trusted server

Defaults are safe — {verify, verify_peer}, OS trust store via public_key:cacerts_get/0, hostname verification, and SNI driven by the connect hostname.

{ok, Conn} = h1:connect("api.example", 443, #{transport => ssl}).

Client connecting to a self-signed or private server

User-supplied ssl_opts win on every key, so override whatever you need:

{ok, Conn} = h1:connect("localhost", 8443, #{
    transport => ssl,
    ssl_opts  => [{verify, verify_none}]
}).

Or pin a specific CA chain:

{ok, Conn} = h1:connect("internal.corp", 443, #{
    transport => ssl,
    ssl_opts  => [{cacertfile, "/etc/ca/internal-bundle.pem"}]
}).

Server with a certificate

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

cert and key accept either file paths (as string or binary). The acceptor pool does transport_accept only; the TLS handshake itself runs in the per-connection process so one slow handshake never blocks the accept queue. Override handshake_timeout (default 30 s) in the server opts.

Tuning

Connect and server opts

%% h1:connect/3 opts (all optional except where noted)
#{transport              => tcp | ssl,                 %% default: tcp
  ssl_opts               => [ssl:tls_client_option()], %% merged over safe defaults
  connect_timeout        => timeout(),                 %% default: 30_000
  timeout                => timeout(),                 %% wait_connected timeout, default 30_000
  pipeline               => boolean(),                 %% default: true
  max_keepalive_requests => pos_integer(),             %% default: 100
  idle_timeout           => timeout() | infinity,      %% default: 300_000 (5 min)
  request_timeout        => timeout() | infinity,      %% default: 60_000
  max_body_size          => pos_integer() | infinity}. %% default: 8_388_608 (8 MB)

%% h1:start_server/2,3 opts
#{transport              => tcp | ssl,                 %% default: tcp
  cert                   => binary() | string(),       %% required for ssl
  key                    => binary() | string(),       %% required for ssl
  cacerts                => [binary()],
  handler                := fun(...) | module(),       %% required
  acceptors              => pos_integer(),             %% default: erlang:system_info(schedulers)
  handshake_timeout      => timeout(),                 %% default: 30_000
  idle_timeout           => timeout() | infinity,
  request_timeout        => timeout() | infinity,
  max_keepalive_requests => pos_integer(),
  max_body_size          => pos_integer() | infinity}.

Timeouts and slowloris

idle_timeout re-arms on every byte over the connection; it only fires when the peer stops talking entirely. request_timeout is armed while a request is in flight (from send-request on the client / from header receipt on the server) and cleared when the response completes. Either firing stops the connection with {shutdown, idle_timeout} / {shutdown, request_timeout}.

Pass infinity to disable.

Body size cap

max_body_size (default 8 MB) bounds Content-Length and chunked body accumulation per stream. Exceeding it causes the parser to return {error, body_too_large} and the connection to shut. Set to infinity if you truly want unbounded uploads, but prefer a per-route enforcement when possible.

Header and URI limits

Inherited from the parser record in include/h1.hrl:

All are parser options you can override via the connect/server opts map.

Events reference

Messages delivered to the owner (connect caller on the client side; h1_server process on the server side — which in turn forwards {h1_stream, …} messages to the request handler):

Message When Arguments
{h1, Conn, connected} socket ready, parser active
{h1, Conn, {request, Id, Method, Path, Headers}} server mode: peer sent a request
{h1, Conn, {response, Id, Status, Headers}} client mode: final response headers (2xx–5xx)
{h1, Conn, {informational, Id, Status, Headers}} 1xx interim (100 Continue / 103 / …)
{h1, Conn, {data, Id, Data, EndStream}} body fragment EndStream :: boolean()
{h1, Conn, {trailers, Id, Headers}} chunked body trailers; implies end-of-stream
{h1, Conn, {upgrade, Id, Proto, Method, Path, Headers}} server: peer requested Upgrade
{h1, Conn, {upgraded, Id, Proto, Socket, Buffer, Headers}} after 101 handoff
{h1, Conn, {stream_reset, Id, Reason}} stream cancelled
{h1, Conn, {goaway, LastId, Reason}} peer signaled shutdown
{h1, Conn, {closed, Reason}} connection closed

Inside a server handler process, the per-stream events are re-routed:

Message Meaning
{h1_stream, Id, {data, Data, EndStream}} body fragment for request Id
{h1_stream, Id, {trailers, Trailers}} trailers for request Id
{h1_stream, Id, {stream_reset, Reason}} client aborted mid-stream

Error reference

Failures return {error, Reason} from API calls or appear as {closed, Reason} / {shutdown, Reason} when the connection stops. Highlights:

Reason Meaning
conflicting_framingContent-Length and Transfer-Encoding: chunked on the same message (RFC 9112 §6.1 smuggling guard)
conflicting_content_length differing Content-Length values across duplicates or in a comma-list
te_on_http_1_0Transfer-Encoding on an HTTP/1.0 message (not permitted)
chunk_size_too_long chunk-size hex digits exceeded the 16-digit cap (DoS guard)
body_too_large body exceeded max_body_size
forbidden_trailer a trailer tried to carry a forbidden field (Content-Length, Host, etc.)
too_many_headers header count exceeded max_headers
header_name_too_long / header_value_too_long field size over limit
method_too_long / uri_too_long / line_too_long request-line piece over limit
bad_request malformed request line, status line, or version
idle_timeout / request_timeout connection/request was silent past its cap
pipeline_disabledpipeline => false and a prior request was still in flight

Using with Ranch

h1:start_server/2 runs its own acceptor pool. To plug into Ranch instead, hand the socket straight to h1_connection:

-module(h1_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}) ->
    {ok, Socket}  = ranch:handshake(Ref),
    TransportMod  = case Transport of ranch_ssl -> ssl; ranch_tcp -> gen_tcp end,
    {ok, Conn}    = h1_connection:start_link(server, Socket, self(), #{}),
    ok            = TransportMod:controlling_process(Socket, Conn),
    _             = h1_connection:activate(Conn),
    server_loop(Conn, Handler).

server_loop(Conn, Handler) ->
    receive
        {h1, Conn, {request, Id, M, P, H}} ->
            Handler(Conn, Id, M, P, H),
            server_loop(Conn, Handler);
        {h1, Conn, {closed, _}} -> ok;
        _ -> server_loop(Conn, Handler)
    end.

Register it as a Ranch protocol:

{ok, _} = ranch:start_listener(my_h1, ranch_tcp,
    #{socket_opts => [{port, 8080}]},
    h1_ranch_protocol,
    #{handler => fun my_handler:handle/5}).

Ranch owns draining, acceptor-pool sizing, and metrics; h1_connection handles HTTP/1.1 semantics.

For a production-shaped protocol module (per-request handler supervision, pipeline ordering, Upgrade passthrough, TLS ALPN multiplexing of h1 + h2 on one port, graceful drain) see docs/ranch.md.

Modules

Module Purpose
h1 Public API (client + server).
h1_connectiongen_statem per-connection state machine.
h1_client Client-side connect + handshake + TLS defaults.
h1_server Per-connection server loop, handler dispatch.
h1_listener Owns the listen socket + acceptor pool.
h1_acceptor Bare accept/1 loop; TLS handshake in the connection.
h1_upgrade RFC 9297 capsule send/recv helpers on the post-handoff socket.
h1_parse / h1_parse_erl Streaming request/response parser.
h1_message Request/response/chunk/trailer encoder.
h1_capsule / h1_varint RFC 9297 capsule codec.
h1_error Reason-code mappings.

Build and test

rebar3 compile
rebar3 eunit          # 52 tests + 4 PropEr roundtrip properties
rebar3 ct             # 143 CT cases across parse / message / capsule / connection
                      # / e2e / upgrade / interop / compliance
rebar3 dialyzer       # clean
rebar3 ex_doc         # HTML docs under doc/

Interop suite

test/h1_interop_SUITE.erl drives our server with curl (GET / POST / HEAD / chunked) and our client against nginx:alpine and python:3-alpine running under Docker. Each case probes for docker / curl on the host and skips cleanly when absent, so the suite stays green on CI without those tools:

rebar3 ct --suite=test/h1_interop_SUITE

Compliance suite

test/h1_compliance_SUITE.erl codifies RFC 9110 / RFC 9112 vectors (smuggling, chunked, field syntax, request-target forms, body-framing rules, DoS guards) as static fixtures. Curated from RFC worked examples, PortSwigger's HTTP Desync corpus, and the http-garden differential-testing project.

rebar3 ct --suite=test/h1_compliance_SUITE

Status

Useful for embedding HTTP/1.1 into Erlang applications that also want the h2 / h3 event surface. Intentionally out of scope:

See docs/features.md for the full RFC coverage + gap list.

License

Apache License 2.0