Features
View Sourceh1 implements HTTP/1.1 as a client and a server on top of Erlang/OTP sockets. This page lists what is supported, what is intentionally left out, and the internal modules a library user may want to know about.
Supported
Messages (RFC 9110 / RFC 9112)
- Request and response parsing in one pure-Erlang streaming parser (
h1_parse_erl). Dual-mode: the same code path handles a request or a response line. - Header names are normalised to lowercase on emit so plain
proplists:get_value(<<"content-length">>, Headers)just works (and matches the h2-on-the-wire rule). - Obs-fold (
\r\n<WS>) tolerated and rewritten to a single space. Content-LengthandTransfer-Encoding: chunkedboth supported on the framing selection path; caller may pass either explicitly, or leth1_message:choose_framing/2pick one based on body shape.- Smuggling guard (§6.1): a message carrying both
Content-LengthandTransfer-Encodingis rejected with{error, conflicting_framing}. No silent strip. - Multiple
Content-Lengthvalues — whether supplied as separate headers or as a comma-list5, 7— are coalesced only when every value parses to the same non-negative integer; any mismatch becomes{error, conflicting_content_length}. Unparseable values become{error, bad_request}. - HTTP/1.0 senders using
Transfer-Encodingare rejected with{error, te_on_http_1_0}— RFC 9112 §6.1 forbids TE on 1.0 and accepting it creates a smuggling surface with downstream proxies. - Chunked body with chunk extensions (
5;ext=1\r\n…) tolerated — extensions parsed and discarded. - Trailers after the final
0\r\nchunk surfaced as a distinct{trailers, StreamId, Headers}event and treated as end-of-stream. Fields forbidden in trailers by RFC 9110 §6.5.1 (Content-Length,Transfer-Encoding,Host, framing/auth headers, etc.) are rejected with{error, forbidden_trailer}. - Response body framing follows RFC 9112 §6.3: HEAD / 1xx / 204 / 304 responses carry no body even if
Content-Lengthis present;Content-Lengthwins over close-delimited otherwise; a response with neitherContent-LengthnorTransfer-Encodingis treated as close-delimited (body extends to socket close, and the connection driver callsh1_parse_erl:finish/1ontcp_closed/ssl_closedto emit the final end-of-stream event). - Limits enforced with
{error, Reason}rather than silent truncation:method_too_long,uri_too_long,header_name_too_long,header_value_too_long,too_many_headers,chunk_size_too_long(chunk-size line capped at 16 hex digits — DoS guard),body_too_large(permax_body_sizeparser option; default?H1_MAX_BODY_SIZE= 8 MB, passinfinityto disable). - Tolerance knobs (parser options):
max_line_length,max_empty_lines,max_header_name_size,max_header_value_size,max_headers,max_body_size. Obs-fold is accepted then re-validated againstmax_header_value_sizeto prevent a folded value from sneaking past the limit.
Connection state machine
h1_connection is a single gen_statem used in both client and server mode.
- Idle and request timers (slowloris guard):
idle_timeout(default 5 min) re-armed on every byte, andrequest_timeout(default 60 s) armed while a request is in flight. Either timer firing stops the connection with{shutdown, idle_timeout}/{shutdown, request_timeout}. Passinfinityto disable. - Pipelining flag (
pipeline): defaults totrue. When set tofalse, a client's secondh1:request/*call while a prior response is still in flight returns{error, pipeline_disabled}instead of queueing. - Host auto-add (client): if the caller didn't include a
host:header, the client adds one from the hostname passed toh1:connect/*. HTTP/1.1 requests reaching the server without aHostheader are answered with 400 and the connection is closed — RFC 9110 §7.2 requires exactly oneHost. - Keep-alive (RFC 9112 §9.3): HTTP/1.1 default is reuse;
Connection: closefrom either side flips the connection toclose_after = trueand shuts after the current exchange drains. - Request pipelining (client): multiple
h1:request/4,5calls may be in flight simultaneously; responses are attached to requests in the order they were sent. Each request gets a monotonicStreamIdso the event shape matches h2. - Pipelined response ordering (server):
h1_server:connection_loop/2only pulls the next{request, ...}event from the mailbox after the current handler process exits, so response bytes for request N are fully flushed before any bytes for request N+1 hit the socket. Expect: 100-continue(RFC 9110 §10.1.1):- Server surfaces it as a stream flag; the handler calls
h1:continue/2to send100 Continueor ignores it and sends the final response directly. - Client sets the header automatically when the caller supplies a body, stages the body, and releases it on receiving
100 Continueor a non-100 response.
- Server surfaces it as a stream flag; the handler calls
- Trailers on chunked messages:
h1:send_trailers/3writes the final0\r\n<trailers>\r\n\r\nblock. - Owner monitoring: when the owner process exits, the connection stops with
{shutdown, owner_down}. controlling_process/2transfers ownership;set_stream_handler/3,4routes per-stream events to a different pid (body streaming to a worker).close/1tolerates any already-exited state (noproc,{shutdown, peer_closed}, etc.) so the caller does not need to trap.
Upgrade / 101 Switching Protocols (RFC 9112 §7.8)
- Client:
h1:upgrade/3,4writes a GET withConnection: Upgrade+Upgrade: <token>, blocks until either a 101 arrives (returning{ok, StreamId, Socket, Buffer, Headers}) or a non-101 response aborts it. - Server: parser detects
Upgrade:+Connection: upgrade, emits{upgrade, StreamId, Proto, Method, Path, Headers}, then pauses the socket so no bytes past the request are consumed. The handler callsh1:accept_upgrade/3to send the 101 and receive{ok, Socket, Buffer}. - On either side, after a successful 101 the
gen_statemstops with{shutdown, upgraded}and the socket iscontrolling_process'd to the caller, along with any leftover buffered bytes. h1_upgradeprovides framing-agnostic capsule send / recv helpers on the handed-back socket for consumers (e.g.masquefor RFC 9298 CONNECT-UDP).
CONNECT tunnels (RFC 9110 §9.3.6, RFC 9112 §3.2.3)
- Classic HTTP/1.1
CONNECT authority HTTP/1.1requests reach the server as a regular{request, StreamId, <<"CONNECT">>, Authority, Headers}event. The request target is in authority-form (host:portor[ipv6]:port) and is surfaced verbatim inPath. - Server handler replies with
h1:accept_connect/3,4. The helper writesHTTP/1.1 200 Connection Established\r\nplus caller-supplied headers plus the terminating CRLF directly, then atomically hands the raw socket off to the caller. Return shape:{ok, Transport, Socket, Buffer}whereTransportisgen_tcporsslandBufferis any bytes the parser had already read past the request. - No
Connection: Upgrade/Upgrade:/Transfer-Encoding: chunkedheaders are injected: bytes past the blank line belong to the tunnel, not to an HTTP response body. Sequencingsend_response/4+ a separate handoff would be wrong becausesend_response/4defaults to chunked framing when noContent-Lengthis set. - After a successful
accept_connect, thegen_statemstops with{shutdown, connected}and leaves the socket open (owned by the caller). The owner also receives{h1, Conn, {connected, StreamId, Transport, Socket, Buffer}}. - Error returns:
{error, unknown_stream}(bogus StreamId),{error, response_already_sent}(afterh1:send_response/4on the same stream),{error, Reason}from the underlyingsend. - Counterpart to
accept_upgrade/3for the 101 Switching Protocols case. Downstream use:erlang_masque's HTTP/1.1 CONNECT-TCP fallback.
Capsules (RFC 9297)
h1_capsule:encode/2andh1_capsule:decode/1implement theCapsule-Protocolwire format (varint type + varint length + payload).h1_varintis a copy of the QUIC varint codec (1/2/4/8-byte forms).h1_upgrade:send_capsule/4/recv_capsule/3,4wrap these forgen_tcpandsslsockets, keeping a caller-provided buffer so partial reads never drop bytes.- Wire-compatible with
h2_capsulein erlang_h2; the two modules exist as separate copies to avoid a cross-library runtime dependency.
API surface
Public module h1:
%% Client
h1:connect/2,3
h1:wait_connected/1,2
h1:request/2,3,4,5
h1:send_data/3,4
h1:send_trailers/3
h1:cancel/2,3
h1:cancel_stream/2,3
h1:set_stream_handler/3,4
h1:unset_stream_handler/2
h1:goaway/1,2
h1:close/1
h1:controlling_process/2
%% Server
h1:start_server/2,3
h1:stop_server/1
h1:server_port/1
h1:send_response/4
%% HTTP/1.1-specific
h1:upgrade/3,4
h1:accept_upgrade/3
h1:accept_connect/3,4
h1:continue/2
h1:pipeline/2
%% Inspection
h1:get_settings/1
h1:get_peer_settings/1The export list mirrors h2 and quic_h3 where semantics overlap. H1-specific primitives (upgrade, accept_upgrade, continue, pipeline) cover what h2 and h3 have no equivalent of.
Events to the owner process
{h1, Conn, connected}
{h1, Conn, {request, StreamId, Method, Path, Headers}} %% server mode
{h1, Conn, {response, StreamId, Status, Headers}} %% client mode
{h1, Conn, {informational, StreamId, Status, Headers}} %% 1xx interim
{h1, Conn, {data, StreamId, Data, EndStream}}
{h1, Conn, {trailers, StreamId, Headers}}
{h1, Conn, {upgrade, StreamId, Proto, Method, Path, Headers}} %% server: peer asked for Upgrade
{h1, Conn, {upgraded, StreamId, Proto, Socket, Buffer, Headers}} %% after 101
{h1, Conn, {connected, StreamId, Transport, Socket, Buffer}} %% after CONNECT 200
{h1, Conn, {stream_reset, StreamId, Reason}}
{h1, Conn, {goaway, LastStreamId, Reason}}
{h1, Conn, {closed, Reason}}Identical shape to h2 and quic_h3 except for upgrade / upgraded (H1-only). Per-stream events (data, trailers, stream_reset) route to the pid registered via h1:set_stream_handler/3,4 if set, otherwise to the connection owner.
Inside the server, h1_server re-routes {h1, Conn, {data, …}} / {trailers, …} as {h1_stream, StreamId, …} messages to the spawned handler process — the same shape body handlers use to collect POST / chunked uploads.
TLS
- Advertised via
{alpn_advertised_protocols, [<<"http/1.1">>]}on the client and{alpn_preferred_protocols, [<<"http/1.1">>]}on the server listen socket. - TLS handshake is deferred to the connection process (not blocking the acceptor).
- Client defaults:
{verify, verify_peer}plus OS trust store viapublic_key:cacerts_get/0plus hostname verification viapublic_key:pkix_verify_hostname_match_fun(https). SNI is set automatically when the connect host is a DNS name (not an IP literal). Callers that explicitly passssl_optscan opt out of any of these — the user list wins on every key.
Intentionally out of scope
- HTTP/0.9. Not parsed, not generated.
Upgrade: h2ccleartext negotiation to HTTP/2 — deprecated by RFC 9113. Use ALPN withh2(orh2cvia prior knowledge) in theh2library instead.- Server push, ETag / caching policy, content coding (
gzip,br, …). The library ships the messages as-is; compression is a caller concern. - Proxy-specific semantics beyond the
UpgradeandCONNECT 200primitives (routing to upstream hosts, forward-proxy ACLs,Proxy-Authorizationhandling). Theaccept_upgradeandaccept_connecthelpers give downstream libraries (masque, custom forward proxies) the socket after a successful handshake; everything beyond that belongs in the proxy library. - WebSocket framing on top of an Upgraded connection —
Upgrade: websocketis parsed and the handshake is exposed, but thewsframe layer is out of scope here. Layer it on top of the handed-back socket with a dedicated library.
Internal modules
| Module | Role |
|---|---|
h1 | Public API (client + server). |
h1_connection | gen_statem owning the socket; one process per connection. |
h1_app / h1_sup | Application callback and top-level supervisor (simple_one_for_one parent of listeners). |
h1_listener | Per-server process: owns the listen socket and the acceptor pool. |
h1_acceptor | Bare accept/1 loop. Spawns an h1_server per accepted socket. |
h1_server | Per-connection loop: spawns one handler process per request, serialises requests for pipelined response order. |
h1_client | Client-side connect + socket handoff to h1_connection. |
h1_parse / h1_parse_erl | Streaming request/response parser with chunked + trailer support. |
h1_message | Request/response/chunk/trailer encoder, framing selection. |
h1_upgrade | RFC 9297 capsule helpers on the post-handoff raw socket. |
h1_capsule / h1_varint | RFC 9297 wire format. |
h1_error | Reason-code mappings. |
Testing
rebar3 eunit— 52 EUnit tests plus 4 PropEr roundtrip properties.rebar3 ct --suite=test/h1_parse_SUITE— 24 streaming-parser cases (request, response, chunked, trailers, limits).rebar3 ct --suite=test/h1_message_SUITE— 12 encoder cases.rebar3 ct --suite=test/h1_capsule_SUITE— 9 capsule encode/decode cases.rebar3 ct --suite=test/h1_connection_SUITE— 13 loopbackgen_statemcases: GET / POST / chunked / trailers / keep-alive / pipelining / Expect / Upgrade / capsule exchange.rebar3 ct --suite=test/h1_e2e_SUITE— 8 cases through the public API over real TCP + TLS.rebar3 ct --suite=test/h1_upgrade_SUITE— 3 end-to-end upgrade cases with capsule framing.rebar3 ct --suite=test/h1_connect_SUITE— 8 end-to-end CONNECT cases (authority-form targets, IPv6, ExtraHeaders round-trip, no-chunked-framing probe, socket ownership after handoff,{shutdown, connected}termination, error paths).rebar3 ct --suite=test/h1_interop_SUITE— curl / python3 / nginx probes, skipped when the binary is absent.rebar3 dialyzer— clean.