A transport-agnostic HTTP server library for Zig, with seams for HTTP/2 and HTTP/3
  • Zig 89.5%
  • Nix 6.8%
  • Python 3.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 90d698b2fd
All checks were successful
test / test (push) Successful in 8m43s
test / docs (push) Successful in 5m43s
test / interop (push) Successful in 21m15s
An RSA handshake in six milliseconds instead of thirty-one
zig-quic 4cb753b, carrying tls.zig 9a669ab. The handshake for a server
certificate, measured against curl on this machine:

            two days ago   now
  P-256             2 ms   2 ms
  RSA-2048         31 ms   6 ms
  RSA-3072         59 ms  15 ms

Five times, from two bugs and an omission, none of them in this repository.

Half of every signature was squarings of leading zeros: `std.crypto.ff`'s
`Modulus.pow` sizes its exponent buffer by the type's maximum width, and
supporting 4096-bit keys means instantiating `Modulus(4096)` for every key.
Then the Chinese remainder theorem, which every PKCS#1 and PKCS#8 key carries
the components for and neither implementation used -- two exponentiations
modulo numbers half the width of `n` being a quarter of the work of one
modulo `n`. And when that was implemented nothing changed, because
`PrivateKey.parseDer` walks PKCS#8 by hand and had never read the five
integers it needed; the measurement not moving is what found that.

The CRT is only safe with the check that comes with it, and tls.zig makes it:
every private operation recomputes `out^e mod n` and compares it against its
own input before returning, because a signer that gets one of its two halves
wrong emits a result from which `gcd(out^e - m, n)` is one of the primes.

An elliptic curve certificate is still three times cheaper again and still
the right default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141M41NZ9P4WQsMW41VYZGs
2026-09-20 01:49:58 -05:00
.forgejo/workflows Say in the workflow that there are five virtual machine pairs 2026-09-19 03:33:43 -05:00
LICENSES Initial scaffolding 2026-09-15 23:21:57 -05:00
src A window an operator can turn, and a table that follows it 2026-09-19 15:17:26 -05:00
tests A window an operator can turn, and a table that follows it 2026-09-19 15:17:26 -05:00
tools An in-process HTTP/2 suite, a connection fuzz target, and what they found 2026-09-16 14:05:46 -05:00
.gitignore Initial scaffolding 2026-09-15 23:21:57 -05:00
build.zig curl --http3 fetches a megabyte, which is the first outside opinion 2026-09-18 22:56:45 -05:00
build.zig.zon An RSA handshake in six milliseconds instead of thirty-one 2026-09-20 01:49:58 -05:00
build.zig.zon.nix An RSA handshake in six milliseconds instead of thirty-one 2026-09-20 01:49:58 -05:00
flake.lock HTTP/3, the first two pieces: a dependency and a frame layer 2026-09-18 14:49:51 -05:00
flake.nix A second HTTP/3 implementation, and the conformance bug it found 2026-09-19 03:33:13 -05:00
package.nix HTTP/3, the first two pieces: a dependency and a frame layer 2026-09-18 14:49:51 -05:00
README.md An RSA handshake in six milliseconds instead of thirty-one 2026-09-20 01:49:58 -05:00
REUSE.toml HTTP/3, the first two pieces: a dependency and a frame layer 2026-09-18 14:49:51 -05:00

zig-http

An HTTP server library for Zig 0.16 that does not know what it is running on.

The server reads requests from an std.Io.Reader and writes responses to an std.Io.Writer, plus three facts about the channel that HTTP needs and cannot read off it. That is the whole transport interface. A TCP listener and an in-memory one are included; a TLS session, a Unix socket, or a pair of pipes handed over by inetd would each be about fifty lines and nothing above them would know the difference.

HTTP/1.0, HTTP/1.1 and HTTP/2 are implemented, and the same handlers serve all three without being recompiled against anything different. HTTP/2 passes 146 of h2spec's 147 conformance cases; the one exception is documented and deliberate.

The API documentation is at https://jeff.jcollie.page/zig-http/. It is generated from the doc comments, which carry most of the explanation in this library — the framing rules in particular are argued out where they are implemented rather than summarised here.

Status

Early. The wire formats are exercised by 220-odd tests, ten property-based fuzz targets, a real curl, a real h2load and the h2spec conformance suite, and the framing rules are the ones RFC 9112 and RFC 9113 give — but this has not served anything in anger, there is no TLS, and there is no client. Read the What is not here section before depending on it.

Where this lives

The repository lives in three places that carry the same history. The Forgejo instance at https://git.jcollie.dev/jeff/zig-http is the web-visible one:

$ git clone https://git.jcollie.dev/jeff/zig-http.git

it is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-http, and it is also on the Radicle network, where the repository's identifier is

rad:z2deKU3oTWRwJmw7jzmoG4FAgLNiS

and rad clone rad:z2deKU3oTWRwJmw7jzmoG4FAgLNiS fetches it from any node that seeds it. Any of the three is the whole project.

Adding it to a project

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-http.git
const http = b.dependency("zig_http", .{ .target = target }).module("http");
exe.root_module.addImport("http", http);

A server

const std = @import("std");
const http = @import("http");

fn hello(req: *http.server.Request, res: *http.server.Response) anyerror!void {
    try res.setHeader("Content-Type", "text/plain; charset=utf-8");
    try res.sendPrint("you asked for {s}\n", .{req.target.path});
}

pub fn main(init: std.process.Init) !void {
    var threaded: std.Io.Threaded = .init(init.gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var listener = try http.transport.tcp.Listener.listen(
        io,
        .{ .ip4 = .loopback(8080) },
        .{},
    );
    defer listener.deinit(io);

    var server: http.server.Server = .init(
        io,
        init.gpa,
        listener.listener(),
        .initStateless(hello),
        .{},
    );
    try server.serve();
}

zig build run starts a slightly larger version of this — src/main.zig — with four routes chosen to show one thing each: a body whose length is not known in advance, a request body read back, a status set by hand, and a handler with state of its own.

The four layers

The boundaries between them are the point of the library.

protocol — HTTP semantics

Methods, statuses, targets, header fields and dates, with nothing in it about how any of them are written down. RFC 9110 made that separation and this follows it, because it is what makes everything above reusable across versions.

A Method is a string rather than an enumeration, because that is what a method is: RFC 9110 defines a registry anyone may add to, and a server that models it as a closed set cannot tell PROPFIND from nonsense — and so cannot answer the first with 405 and the second with 501.

Target.resolvePath is the security-critical piece: it percent-decodes each segment on its own, refuses a segment that decodes to something containing a / or a NUL, removes . and .. after decoding so that %2e%2e is a dot segment, and refuses a .. with nothing left to leave rather than silently discarding it. It must be applied exactly once; the doc comment says why at length.

transport — where the octets come from

A transport.Connection is a reader, a writer, and Info: the peer address, the local address, whether the channel is confidential, and what ALPN settled on. A transport.Listener is a source of them.

transport.memory is a second real implementation, and it is in src rather than in tests deliberately: a transport-agnostic server whose only transport is a socket is not transport-agnostic, it is a socket server with an interface in front of it. Having two is what keeps the seam honest — and it is why the test suite has no ports, no scheduling and no waiting, and why the fuzzer gets through hundreds of thousands of requests a second.

http1 and http2 — the wire formats

RFC 9112's request line, field lines, Content-Length and chunked coding; RFC 9113's frames and streams and RFC 7541's header compression. These are the only parts of the library that know what any of that looks like, and neither knows about the other.

That the layer boundary above them holds is now demonstrated rather than asserted: there are two implementations behind it, and neither the handler nor the transport knows which one is running.

server — the accept loop and the handler

Request, Response and Handler, none of which mention a version of HTTP. A handler declares how long its body is going to be — .none, .{ .exact = n } or .unknown — and the driver turns that into a Content-Length, a chunked coding, a closed connection, or a sequence of DATA frames. A handler never writes a Content-Length, never sees a chunk, and cannot set Connection; the driver overrules all three, because the first two are framing and the third is an HTTP/1.1 connection-management field that HTTP/2 makes a connection error to send at all.

HTTP/2

On by default. server.Server picks it per connection: by ALPN where the transport negotiated one, and otherwise by the first four octets, which is enough to recognise a client using prior knowledge — RFC 9113 §3.4 chose the PRI method for its connection preface precisely so that test would work.

Since this library has no TLS there is no ALPN, so in practice HTTP/2 arrives one of two ways: a cleartext prior-knowledge client (curl --http2-prior-knowledge, h2load, nghttp), or a TLS terminator in front that sets transport.Connection.Info.alpn to h2.

h2c Upgrade is deliberately not implemented, because RFC 9113 removed it. A client that asks gets an ordinary HTTP/1.1 response rather than a 101; answering would mean speaking a mechanism no current standard defines, and there is a test in tests/nixos/curl.nix that pins the behaviour.

RFC 8441 extended CONNECT is implemented, which is what makes Response.upgrade work over HTTP/2 — see Switching protocols.

Also not implemented, and each for a reason: server push (deprecated; a client PUSH_PROMISE is a connection error and SETTINGS_ENABLE_PUSH is advertised as 0) and RFC 7540 prioritisation (PRIORITY frames are parsed, checked for the one rule that still bites — a stream may not depend on itself — and then discarded; RFC 9113 deprecated the scheme and RFC 9218 is the replacement this does not implement).

Response trailers

res.trailers is a Headers a handler fills any time before the response ends, for what is not knowable until the content has been produced: a checksum, a signature, why a stream stopped early. Each version sends it in the shape it has, and the three are a fair summary of the three protocols.

HTTP/1.1 needs the chunked coding and RFC 9112 §7.1.2's trailer section, which splits the terminator in two: the last chunk, the fields, then the empty line. A response whose length was declared has nowhere to put them, and §6.6.2 of RFC 9110 says so — "a trailer section is only possible when the message framing allows it" — so they are dropped. Dropping is the specified behaviour rather than a shortcut, which is also why §6.5.1 forbids putting anything the recipient needs in one.

HTTP/2 needs RFC 9113 §8.1: a second field block with END_STREAM. HTTP/3 needs RFC 9114 §4.1: a second HEADERS frame, with no flag at all, because the stream ends when QUIC says it does. One layer fewer each time.

/trailers on the demonstration server is the shape of a handler that wants one: five lines of a body nobody declared the length of, then two fields saying how many octets went out and what their checksum was. Neither value exists before the last octet is written, which is the whole reason a trailer section exists, and it is what makes the route worth having in the virtual machine tests — curl reads the same two fields over all three framings and gets the same checksum each time, so the claim that one handler serves three protocols is checked by something that shares no code with this.

The fields §6.5.1 forbids are refused on the way out rather than trusted to a handler that read the section — anything that would change how a message is framed, routed, cached or authenticated arrives too late to be obeyed, and is a way to tell a cache one thing and whatever is behind it another.

A handler is now called concurrently

This is the one thing HTTP/2 asks of a handler that HTTP/1.1 did not, and it is easy to miss because the types did not change. HTTP/2 multiplexes, so up to max_concurrent_streams requests can be inside the same handler, on the same connection, at the same moment. Any state reached through a handler's context is shared across tasks.

src/main.zig uses std.atomic.Value for its request counter for exactly this reason, and the load test asserts the count comes out exactly right — which a lost increment would break.

A client that stops talking still gets its answers

When the peer's sending half ends, the connection stops being read — but the requests already in flight arrived whole and their responses still have somewhere to go, so the reader waits for those stream tasks before the GOAWAY goes out. What it does not wait for is waiting itself: a handler blocked on a request body that will never arrive sees the end of that body, and one blocked on a send window that will never open is told the connection is gone, since a WINDOW_UPDATE can only arrive on a connection still being read. Credit already granted is still spendable.

A connection error is the other case, and it is not graceful. Nothing further can be understood, so a handler still running is producing octets nobody will ever read, and the streams are stopped rather than waited for.

What a task costs is the caller's choice

Each stream gets a task. Under std.Io.Threaded that is an operating system thread, so max_concurrent_streams is a per-connection thread bound; under a fibre-based Io it is nearly free. The default is the conventional 100 and the right number depends on which Io you supply.

The one thing that is wire-visible

/stream in the demonstration server is a handler that does not know how long its body will be. Over HTTP/1.1 that costs a chunked transfer coding, over HTTP/1.0 a closed connection, and over HTTP/2 an ordinary run of DATA frames with no transfer-encoding at all — which HTTP/2 makes a malformed message. One handler, three framings, and it never finds out which.

HTTP/3

Implemented, and it needed more than a driver: a QUIC connection is many independent streams where a transport.Connection is one, so HTTP/3 arrives with a listener of its own rather than being handed a connection by server.Server. protocol did not change, which is the whole reason protocol is where it is — and this is the first time that claim has been tested against a transport that is not a byte stream at all.

$ zig-http --port 8080 --h3-port 8443 --cert cert.pem --key key.pem
$ curl --http3-only --cacert cert.pem https://localhost:8443/
$ nix build .#checks.x86_64-linux.h3       # the same, in a virtual machine
$ nix build .#checks.x86_64-linux.aioquic  # and again, from a second stack

The last of those is where the claim is actually tested. tests/h3.zig drives a request through the driver over a loopback socket, and every assertion in it is this library reading what this library wrote — a QUIC handshake, a QPACK field section and HTTP/3 framing all written against the same understanding as the code they check, so a disagreement that is self-consistent passes. The virtual machine test points a curl built with ngtcp2, nghttp3 and OpenSSL at it instead, over a real network, with a certificate it verifies properly. That found four things the in-process test could not, one of which — a ClientHello too large for one packet — aioquic's QUIC suite could not have found either, because aioquic's fits.

A second one points aioquic at it, which is a different reading of the same RFCs in a different language with a different QPACK, and which can be told to send what curl will not. It found that a malformed field section was being answered with a status code where §4.1.2 requires the stream to be reset, and then something worse: this server advertised a per-stream flow control window sixty-four times the one its transport can hold. quic.Connection keeps one fixed buffer per stream per direction and says in as many words that "a caller advertising more than this in its transport parameters is promising room it does not have" — and this was that caller. A peer taking the advertisement at its word filled the buffer, the connection had nowhere to put the rest, and it closed with INTERNAL_ERROR. Every HTTP/3 test in the repository passed throughout, because every body any of them sent fit in one window.

QUIC itself is zig-quic, which is a dependency rather than part of this: two modules of it, quic for the protocol and quic_io for the loop that owns a UDP socket and routes datagrams to connections.

What it costs, and how to measure it. Every figure here is from a --release=safe build, which is what nix build produces and what the virtual machine tests run. It is worth saying because a plain zig build produces a Debug binary and the first round of these numbers was measured on one: the handshake came out ten times slower and the transfer three, which was enough to point at entirely the wrong thing.

Against a curl on the same machine, a megabyte from /bulk moves at about 40 MB/s and is steady within a few percent; the same handler over HTTP/1.1 moves at about 1.2 GB/s. An upload to /echo runs at about 20 MB/s, which is two megabytes crossing the connection for every one uploaded. A full handshake costs about 2 ms with a P-256 certificate, 6 ms with RSA-2048 and 15 ms with RSA-3072 — so on a short connection the certificate is still most of the cost, and choosing an elliptic curve key is still worth more than anything else on this page.

Those RSA figures were 31 ms and 59 ms two days ago, and the five-fold difference is two bugs and an omission, none of them here:

  • Every private-key operation went through std.crypto.ff's Modulus.pow, which sizes the exponent buffer by the type's maximum width rather than the modulus's. Supporting 4096-bit keys means instantiating Modulus(4096) for all of them, so a 2048-bit key had a 256-byte exponent written into a 512-byte buffer and the ladder walked all 512, spending four squarings on every nibble whether it was significant or not. Half of every signature.
  • The Chinese remainder theorem was not used, though every PKCS#1 and PKCS#8 key carries what it needs. Two exponentiations modulo numbers half the width of n are a quarter of the work of one modulo n.
  • And when that was fixed, nothing changed — because PrivateKey.parseDer walks PKCS#8 by hand and had never read the five integers it needed.

All three are fixed in tls.zig. The first is also fixed in std.crypto.ff itself, carried as a patch in zig-std-crypto-ext along with a second one — a precedence slip that sends a secret three-byte exponent down a path that branches on its bits — and meant for upstream.

Signing with the CRT is only safe with the check that comes with it, and tls.zig now makes it: every private operation recomputes out^e mod n and compares it against its own input before returning. A signer that gets one of its two halves wrong emits a result from which gcd(out^e - m, n) is one of the primes, which is the whole private key from a single bad signature.

That 35 MB/s was six times worse until the body writer stopped sending each write as its own datagram — the story is in the doc comment on BodyWriter in src/http3/Connection.zig, and the short version is that a handler writing sixty-six octet lines was spending a whole packet on each one.

What makes it faster, and what that costs. The per-stream buffers, and the sending one of the two. A download is this server sending, so what bounds it is how much unacknowledged data one stream may hold — §13.3 has a retransmission carry the contents of a lost frame, so an octet stays held until the peer says it arrived. Enlarging the receive buffer alone changes it by nothing measurable.

http3.Config.sizes is where that is chosen, and --h3-window on the demonstration server turns it. Five runs a setting, a megabyte from /bulk against curl on the loopback interface:

$ zig-http --h3-port 8443 --h3-window 4     # 41 MB/s   (the default)
$ zig-http --h3-port 8443 --h3-window 64    # 54 MB/s
$ zig-http --h3-port 8443 --h3-window 256   # 65 MB/s

Two things are worth knowing before turning it up. The first is that the window and the loss-tracking table are one decision, not two: the table has to hold the packets the window puts in the air, and a table that cannot is not a connection that slows down but one that stops. quic.Connection.init asserts the floor, Config.sizes derives the table from the window, and neither can be got wrong quietly any more.

The second is memory, which is charged per connection alive at once rather than per connection permitted: quic_io.Endpoint furnishes a slot when a connection claims it, so raising the window costs nothing until somebody connects. Resident memory for this server, measured with nothing connected and then with one transfer in flight:

--h3-window idle one connection
4 10 MB 12 MB
256 10 MB 21 MB
1024 10 MB 47 MB

So the number to reason about is the peak concurrency rather than max_connections: sixteen streams' worth of buffer per connection, times how many are actually being served. A megabyte-per-stream window costs 37 MB a connection, which is affordable for a handful and not for a thousand.

Chasing it turned up two deadlocks in zig-quic, both fixed there, and both reached by going faster rather than by anything going wrong. recovery.Space remembers sixty-four unacknowledged packets and drops the oldest to make room; a dropped packet used to take its bytes out of the table and leave them counted in flight for ever, so the congestion window closed and never reopened. And its data was never marked to be sent again, so the octets stayed held in the stream's send buffer, where compact frees only the acknowledged prefix — one such hole near the front pins the whole buffer, the stream fills, and the writer waits for room that cannot come. Both look identical from outside: a connection that stops dead with every thread asleep. Sixty-four kilobytes hit them every time and forty-eight never did. Both were worth fixing on their own account — each is a permanent stall reachable by going faster — but neither raised the ceiling, which is the tracking table's and was measured afterwards rather than before.

The driver is smaller than the HTTP/2 one, and what is absent is the point. No connection preface, because QUIC's handshake is one. No flow control, no WINDOW_UPDATE, no stream table, no MAX_CONCURRENT_STREAMS — §4.6 of RFC 9114 hands all of it to QUIC. No RST_STREAM, because QUIC has one. And no writer lock, which was the largest single piece of the HTTP/2 driver: there is no shared byte stream to serialise onto, so the Serializer with its mutex, its control outbox and the deadlock that outbox exists to prevent has no counterpart here.

What is left is three unidirectional streams carrying the connection's own business — §6.2's control stream and the two QPACK streams, in each direction — and a task per request.

QPACK is the static table and the literal representations, with a dynamic table capacity of zero. §3.2.2 of RFC 9204 forbids a peer inserting when the advertised capacity is zero, so no field section can ever depend on an insertion, and the Required Insert Count arithmetic, the eviction, the Known Received Count and the blocked streams are absent rather than approximate. It costs compression and not correctness, which is the same trade the HPACK encoder here already makes.

The Huffman code is shared with HPACK rather than transcribed twice, because §5 of RFC 9204 defines none — it points at RFC 7541 Appendix B and stops. So it lives in field, along with the rules for turning a field section into a Request: RFC 9114 §4.3 restates RFC 9113 §8.3 almost word for word, and the version is the only parameter that differs between the two callers.

A graceful shutdown is §5.2's GOAWAY, and it is the frame that makes restarting a server safe. The identifier it names is the request this endpoint has not started, so everything already in flight is answered and everything after it is refused with H3_REQUEST_REJECTED — a code the client can act on, because it means the request did not happen. A server that simply closed its socket would leave every client with a request outstanding unable to tell whether it took effect, and guessing wrong means either losing a request or making it twice.

The check on an incoming one is §7.2.6's: a second GOAWAY may only name something lower, because an increasing one would be a peer taking back a promise the frame exists to make reliable.

Extended CONNECT is RFC 9220, which is three pages and most of them are about what it does not change from RFC 8441: the same setting identifier, the same :protocol in the request, the same 2xx rather than a 101. So res.upgrade works here as it does over HTTP/2, and a WebSocket can be bootstrapped over HTTP/3.

What differs is what a tunnel is made of, and it is simpler each time. Over HTTP/1.1 an upgrade must refuse when the request body has not been read, because those octets sit in the byte stream in front of the new protocol's first one. Over HTTP/2 that check is unnecessary because DATA frames are framed separately. Here it is unnecessary for a stronger reason: the request body and the tunnel are the same QUIC stream, delivered in order by the transport, so there is nothing to be in front of anything.

One trap, which cost an afternoon and is not in the library. A tunnel is read with one stream call and not with readSliceShort. The latter keeps asking until the destination is full or the stream ends, which is right for a message of known length and wrong here: the first ask returns everything that has arrived, the second blocks on a peer that has not ended its half and is not going to, and the connection dies of its idle timeout with the data long since delivered. It looks exactly like a transport that will not carry a tunnel, and it is a reader asking for more than a tunnel promises.

Not implemented: a dynamic QPACK table, server push, and plain CONNECT, which answers 501.

HTTP/1.1 framing, and what is refused

Almost everything in the HTTP/1.1 driver is about where a message ends, because that is where HTTP/1.1 is dangerous. Two participants that disagree about it see two different sequences of requests in the same bytes, and the one in front — a cache, a load balancer — can be made to hand the one behind a request the client never sent. RFC 9112 §11.2 calls it request smuggling.

Where the standard offers a choice between rejecting a message and guessing at it, this rejects it:

Input Answer
Content-Length and Transfer-Encoding together 400, connection closed
Two Content-Length lines that disagree 400
A Content-Length that is not a plain string of digits 400
Transfer-Encoding whose last coding is not chunked 400
A coding under the chunking that we cannot decode 501
Transfer-Encoding in an HTTP/1.0 request 400
A field line folded onto the next (obs-fold) 400
Whitespace between a field name and its colon 400
A bare CR or LF anywhere in the head 400
Anything but one space between the parts of the request line 400
HTTP/1.1 with no Host, or two that disagree 400
HTTP/0.9, or a major version above 1 505
A head, or a field count, past the configured limit 431
An Expect nobody understands 417
A CONNECT 501 — see below

HTTP/2: what is a stream error and what takes the connection

The distinction matters more here than anywhere else in the library: escalating a stream error kills requests that were fine, and demoting a connection error leaves the two ends disagreeing about a compression context they both have to keep in step.

Input Answer
A preface that is not the preface GOAWAY, PROTOCOL_ERROR
DATA, HEADERS or RST_STREAM on stream 0 GOAWAY, PROTOCOL_ERROR
SETTINGS, PING or GOAWAY on a non-zero stream GOAWAY, PROTOCOL_ERROR
Anything but CONTINUATION between HEADERS and CONTINUATION GOAWAY, PROTOCOL_ERROR
A field block that does not decode GOAWAY, COMPRESSION_ERROR
A frame longer than the size we advertised GOAWAY, FRAME_SIZE_ERROR
An even client stream id, or one that goes backwards GOAWAY, PROTOCOL_ERROR
A frame for a stream the peer itself ended GOAWAY, STREAM_CLOSED
A PUSH_PROMISE from a client GOAWAY, PROTOCOL_ERROR
A CONTINUATION flood, or too many cancelled streams GOAWAY, ENHANCE_YOUR_CALM
A missing, duplicated or misplaced pseudo-header RST_STREAM, PROTOCOL_ERROR
An uppercase field name RST_STREAM, PROTOCOL_ERROR
Connection, Keep-Alive, Transfer-Encoding, Upgrade RST_STREAM, PROTOCOL_ERROR
TE other than trailers RST_STREAM, PROTOCOL_ERROR
content-length disagreeing with the DATA sent RST_STREAM, PROTOCOL_ERROR
More streams at once than we advertised RST_STREAM, REFUSED_STREAM
A field section over SETTINGS_MAX_HEADER_LIST_SIZE 431, on that stream
:protocol without SETTINGS_ENABLE_CONNECT_PROTOCOL RST_STREAM, PROTOCOL_ERROR
A CONNECT with no :protocol 501, on that stream
A PRIORITY frame Ignored (RFC 9113 deprecated it)
An unknown frame type outside a field block Ignored (§5.5)

The 431 in that table is the one row that is not an error code, and it is deliberate. RFC 9113 §10.5.1 allows a status there, and the easy alternative — RST_STREAM with REFUSED_STREAM — would be a lie: that code means "nothing happened here, try another connection", so a client believing it sends the same oversized request again and gets the same answer forever. The field block is decoded to the end whatever is done with it, because the peer's dynamic table has already moved and ours has to move with it.

Two attacks get named defences, because neither is caught by the rules above. CONTINUATION flooding (CVE-2024-27316) is not "a big field block" but "a field block that never ends", so the limits are enforced inside the reassembly loop and each frame's own nine-octet header is charged against the byte budget — which is what stops a flood of empty CONTINUATION frames. Rapid reset (CVE-2023-44487) works because a cancelled stream closes at once and so never counts against max_concurrent_streams; a token budget charges every RST_STREAM the peer sends and refunds every stream that runs to completion.

On the way out, a field value carrying a CR or an LF is refused by Response.setHeader rather than written, and a handler that writes more than the length it declared is refused at the write rather than at the flush — so not one octet past the declared length reaches the wire.

Expect: 100-continue is answered when the handler asks for the first octet of the body and not before, which is the whole point of the expectation: a handler that is going to answer 401 or 413 does it without the client ever sending the gigabyte it was offering. A handler that answers without reading the body closes the connection, because the client is still holding one.

Switching protocols

Response.upgrade stops speaking HTTP on a connection and hands the reader and the writer to the handler. That is RFC 9110 §7.8's Upgrade mechanism, and it is what a WebSocket server needs:

fn chat(req: *http.server.Request, res: *http.server.Response) anyerror!void {
    if (!req.offersUpgrade("websocket")) return res.sendStatus(.upgrade_required);
    try res.setHeader("Sec-WebSocket-Accept", accept_token);

    const stream = try res.upgrade("websocket");
    // No HTTP left. `stream.input` and `stream.output` are yours until you
    // return, and then the connection closes.
}

Three things make it safe to use:

  • Nothing is lost behind the handshake. The stream comes back positioned exactly where the request head ended, so a client that pipelined its first frames without waiting for the 101 still has them read. That is the classic way a protocol switch goes wrong and there is a test for it that delivers the request one octet at a time.
  • It refuses what RFC 9110 §7.8 says to refuse. A protocol the client did not list in Upgrade (with Connection: upgrade alongside it), an HTTP/1.0 client, or a request whose body the handler has not read — that last one because the unread octets sit in front of the new protocol's first octet.
  • A refusal changes nothing. upgrade returns an error with the response untouched, so the handler goes on to send 426 Upgrade Required or whatever else it likes.

It is a method rather than a callback registered with the server, and the reason is lifetime. A callback invoked after the handler returned would be handed a connection whose owner had already moved on, so the server would have to keep it alive on the callback's behalf and the two would have to agree about when it was finished. Returning the stream to the handler that is already running keeps the whole thing in one stack frame.

The same handler over HTTP/2

Over HTTP/2 there is no Upgrade — §8.2.2 forbids the fields outright — so the mechanism is RFC 8441's extended CONNECT: a CONNECT request carrying a :protocol pseudo-header, which a client may only send after the server has advertised SETTINGS_ENABLE_CONNECT_PROTOCOL.

The handler above does not change. req.offersUpgrade("websocket") answers from Upgrade over HTTP/1.1 and from :protocol over HTTP/2, and res.upgrade("websocket") sends 101 Switching Protocols on one and 200 on the other. That is the whole argument for a Request that is not a wire format, and it is the first place in this library where the argument pays for something a handler could not reasonably have written itself.

Three differences are real and worth knowing:

  • A tunnel is one stream, not the connection. Over HTTP/1.1 an upgraded connection is spent: every request that would have been multiplexed with it has to find another socket. Over HTTP/2 the other streams carry on, and there is a test that runs a tunnel and an ordinary request side by side on one connection.
  • There is no Sec-WebSocket-Key handshake. RFC 8441 §5 says so explicitly — the pseudo-header replaces it, because a pseudo-header is something no XMLHttpRequest-style client can forge in the first place, which is what the Sec- prefix was for.
  • The request body is the tunnel, so upgrade does not refuse an unread one. Over HTTP/1.1 an unread body sits in the byte stream in front of the new protocol's first octet; here the DATA frames are the new protocol.

Setting http2.Config.enable_connect_protocol to false switches the whole thing off, and then a client that sends :protocol anyway gets a stream error — which is exactly what RFC 8441 §3 says a non-supporting peer does.

WebSocket framing itself is not here and is not going to be: it is a different wire format that shares nothing with HTTP, and it lives in zig-websocket, which depends on this library and speaks RFC 6455 through the seam described above.

CONNECT is out of scope

It is answered 501 Not Implemented, and that is a decision rather than an omission.

CONNECT asks a server to open a TCP connection to somewhere else and relay octets between it and the client (RFC 9110 §9.3.6). Almost none of that is HTTP. What it needs is a dialling policy, a name resolver, a relay loop, and answers to the questions that make a proxy a proxy: which hosts and ports may be reached, by whom, and what is logged. A library that shipped it without those answers would be shipping an open relay; one that shipped them would be a proxy rather than a server.

The mechanical half is already here for whoever wants to build that — the same Response.upgrade that hands a WebSocket handler its stream would hand a CONNECT handler the same one. What is missing is the proxy, not the seam.

What is not here

  • No TLS. transport.Connection.Info.secure is how a TLS layer would say so, and until something sets it, treat everything sent over this as public. With no TLS there is no ALPN either, so HTTP/2 is reachable only by prior knowledge or from behind a terminator.
  • No client.
  • No dynamic QPACK table, and no CONNECT over HTTP/3. Both argued in the HTTP/3 section.
  • No server push and no RFC 9218 prioritisation. Each is argued in the HTTP/2 section.
  • No WebSocket framing. The handshake is here, over both versions; RFC 6455 itself is zig-websocket.
  • No content codings. A request arriving Content-Encoding: gzip reaches the handler still compressed, which is correct — a content coding is part of the representation, not the framing.
  • Trailer fields are consumed and discarded rather than exposed. They arrive after the handler has usually begun its response, so there is nowhere useful to put them; what matters is that they are consumed exactly, so the next request on the connection starts where it should.
  • No timeouts. A connection is bounded by its buffer sizes and its request count, not by a clock. Put something in front of it that has one.

Building and testing

The toolchain comes from the flake:

$ nix develop
$ zig build test --summary all
$ zig build            # the demonstration server, into zig-out/bin
$ zig build check      # compile the things no test builds

The suite has four parts, and tools/docs_server.zig is a fifth: it serves zig build docs using this library, so reading the documentation exercises it.

src/** holds the unit tests, next to what they test. tests/e2e.zig drives whole requests through the HTTP/1.x driver over the memory transport and asserts on the octets that come back — every case delivered whole, then one octet at a time, then three, because a parser that only works when its input arrives all at once is not a parser. tests/tcp.zig does a handful of the same over a real loopback socket, to check that the pieces underneath are wired together.

tests/h2.zig is the same idea for HTTP/2, with two differences forced by the protocol. The Io is real rather than Io.failing: a driver that gives each stream a task cannot run on one that has no tasks, since a handler reading its own request body waits for DATA frames only the reader task can deliver. And the client is written by hand in tests/h2/frames.zig, which imports nothing from http — so every request in the suite is framed and HPACK-coded by code sharing nothing with the code under test, and a mistake the server and the library's own encoder agreed about cannot hide. tests/h2/oracle.zig holds what must be true of anything the server writes, including the property that matters most: every field block is contiguous and complete, because HPACK state is shared by every stream and an interrupted block leaves the peer's decoder permanently out of step.

Because an HTTP/2 conversation is binary, a failure there prints the whole exchange decoded frame by frame rather than a hex diff. That is dump(), and it was written before any of the tests were.

Fuzzing

tests/fuzz.zig holds ten targets, each a property rather than an example:

  • heads — a head the parser accepted is one whose every piece is well formed, and every field points inside the buffer it came from.
  • paths — no input may produce a path that leaves the root, keeps a dot segment, or holds a NUL.
  • dates — a date this understood survives being written out and read again.
  • fields — the comma-separated list grammar: containsToken, which every framing decision in the driver is made from, must never disagree with a walk of the same list.
  • exchanges — whole requests through the HTTP/1.1 driver, after which the output is parsed back: it must be a whole number of well-framed responses with nothing left over. That is the property request smuggling breaks.
  • hpack — nothing the HPACK decoder returns may point into the field block it was given or into the dynamic table. The block is a buffer the driver reuses; the table is a ring that evicts, and can evict during the very section that references it. Either would be a use-after-free a handler reaches in the ordinary course of its work. The table's recorded size must also equal the sum of its entries, because the peer is keeping the same table by the same rules and a disagreement makes every later index mean something different to each end.
  • h2-requests — a request the server accepted is one no downstream HTTP/1.1 recipient could be made to re-frame: no uppercase name, no CR, LF or NUL in a value, no hop-by-hop field. This is the h2-to-1.1 gateway smuggling class.
  • huffman — every byte string survives coding and reading back, which is the only property that catches a table transcribed one row out of place. A decode-only check would call that a perfectly good decode of something else.
  • framesHeader.parse is total: §4.1 requires unknown types to be discarded and the reserved bit ignored, so there is no nine-octet sequence it may refuse. Parse and write are inverse, and checkHeader has an answer for every header at every maximum frame size.
  • h2-exchanges — whole connections through the HTTP/2 driver, judged by the walker in tests/h2/oracle.zig. It is strictly stronger than exchanges, which has to carve out exceptions because HTTP/1.1 framing is not self-describing: here every octet belongs to a frame whose length is written on the front of it, so the oracle can insist that every field block is contiguous and complete, that no frame arrives for a stream already finished, and that the server never names a stream it could not have opened.

Each is an ordinary test too, so zig build test runs the seeds beside it.

h2-exchanges is the one target that needs a real Io, for the same reason tests/h2.zig does: a driver that gives each stream a task would refuse every stream on one that has no tasks, and the target would fuzz the refusal. It is also where three real defects were found — a RST_STREAM naming a stream identifier no client can own, two RST_STREAM frames for one stream when the reader task and a stream task both decided to reset it, and a leak of one arena chunk per request body, because the arena was copied into the stream and then allocated from through the original. hpack found a fourth: a use-after-free when an insertion evicts the very entry its own name was taken from, which is the case RFC 7541 §4.4 warns about in one sentence that is easy to read past.

exchanges fuzzes more than the request. The handler behind it is a small router, so chunked responses, bodiless statuses, interim responses and the protocol switch are all reached; how many octets a read delivers at a time is part of the input; and so are the driver's limits, because most of the code that enforces a limit only runs when something hits it.

Zig 0.16.0 cannot build a test executable in fuzz mode at all — its own compiler/test_runner.zig hands an error return trace to a function that takes a different type — and leaves the fuzzer's coverage table empty even once that is patched. flake.nix carries the one-word patch, with the whole story in a comment. In the meantime tools/fuzz.zig is a loop written down honestly: it mutates a corpus, hands the result to a target, and says what came back.

$ zig build fuzz-run                                   # a minute of each
$ zig build fuzz-run -- --seconds 300 --target exchanges
$ zig build fuzz-run -- --input fuzz-findings/x.bin --target paths

It has already earned its place four times, and not once by finding a bug in the server.

Twice it found that a property was wrong: resolvePath decodes, so its result is not a request target and cannot be fed back through the parser. /%2541 resolves to /%41, which resolves again to /A. That is not a defect to be fixed — it is what percent-decoding is — but it is exactly the double-decoding traversal bug that keeps getting filed against other people's servers, and it is now written down at the top of the function.

The fourth time it found a layering mistake in a property I had just written: hpack asserted that no decoded value carries a CR. But HPACK is a compression format — RFC 7541 carries whatever octets it is given, and it is RFC 9113 §8.2.1 that forbids the CR, which makes the request malformed rather than the connection broken. Asserting it at the decoder would have conflated a stream error with a connection error and torn down every other request multiplexed alongside it. The property moved to h2-requests, which is the layer that enforces it.

The third time it found that the fuzzer had been lying. Smith.value reads eight bytes as a little-endian u64 and, when that number is outside the range the type allows, returns the range's minimum rather than reducing it. The generator was writing two length-prefixed chunks and then noise, so a target that read one slice and then a u2 found the second chunk's length header where the number should have been — out of range, every time, for every seed. The knob answered zero forever and the branch behind it had never run, while the tool reported millions of iterations a minute. Nothing about that is visible from the outside, which is the whole problem: the fix was checked by making the unreached value an error and confirming the fuzzer now finds it in three thousand inputs and did not before.

The virtual machine tests

nix flake check boots NixOS guests and points real clients at a real server. There are five, and they ask different questions.

tests/nixos/curl.nix asks whether another implementation agrees. Everything in the Zig test suite is this library reading what this library wrote, and a framing mistake that is self-consistent would pass all of it; curl is strict, has been shown every broken server on the internet, and reports a framing disagreement as an error rather than quietly recovering. It now runs every check twice, once over HTTP/1.1 and once with --http2-prior-knowledge.

tests/nixos/h2spec.nix asks whether the HTTP/2 driver conforms. 147 cases from the RFC 9113 and RFC 7541 conformance suite, run --strict, with the JUnit report checked against an allowlist that has exactly one entry and is verified in both directions — a case that starts passing has to leave the list, or the next real regression hides behind an entry that no longer describes anything.

That one entry is worth stating, because it is a design decision rather than a defect. h2spec sends INVALID CONNECTION PREFACE and expects a GOAWAY. This server shares one cleartext port between HTTP/1.1 and HTTP/2 and tells them apart by the first four octets — which is exactly what RFC 9113 §3.4 designed the PRI method to allow. Those four octets are not PRI , so the bytes go to the HTTP/1.1 driver and get an HTTP/1.1 400, which is the right answer to that input on a shared port. h2spec is assuming the port is HTTP/2 only.

A handful of h2spec's other cases race this server rather than test it: they send a request and then, without waiting for the response, send the frame whose answer they are checking, expecting that answer to be the next frame on the connection. Each stream is served by a task of its own, so the response can legitimately arrive in between, and h2spec reports the first frame that was not what it wanted. Being timing, it moves — three local runs in a row named three different cases. A conformance failure is deterministic, so anything unexpected is judged by a second whole pass and only a case that fails in both counts; the flakes are printed rather than swallowed, because a case that starts flaking often is a race worth chasing even though it is not a conformance bug.

tests/nixos/h3.nix asks whether curl agrees about HTTP/3, which is the same question as curl.nix over a transport where nothing about the framing is shared with the byte-stream drivers. It needs a machine rather than a process because HTTP/3 needs a certificate and a certificate needs a name that resolves, which cannot be arranged against 127.0.0.1.

tests/nixos/aioquic.nix asks whether a second HTTP/3 implementation agrees, and it is the one of these that most obviously earned itself. curl's stack is ngtcp2, nghttp3 and OpenSSL; aioquic is Python, and its field compression is Litespeed's lsqpack — a third reading of RFC 9204, in a third language. It also has an API rather than a command line, so it can be told to send what no ordinary client would.

That is what found the last conformance bug here. Asked for a field name with a capital letter in it — which curl lower-cases on the way out, so no assertion built on curl can reach the rule at all — this server answered 400 Bad Request, where §4.2 and §4.1.2 of RFC 9114 between them require a stream error of type H3_MESSAGE_ERROR. A status code is a claim that the request was understood well enough to be answered, which a field section the server has refused to read plainly was not. It also asks what the server's SETTINGS said, which curl never reports, and it checks that the octet count in a trailer field matches the body the client counted for itself.

Beside it is aioquic_conformance.py, which is thirteen rules broken one at a time by a client with no HTTP/3 layer at all — no SETTINGS first, a second SETTINGS, a DATA frame where only control frames belong, a control stream the peer ends, either QPACK stream the peer ends, a second control stream, a request stream opening with DATA, a SETTINGS frame part way through a request, a PUSH_PROMISE from a client, a CANCEL_PUSH for a push nobody promised, a MAX_PUSH_ID that goes backwards, one with something after it, and a field section over the advertised maximum, which is the one of them that is not a connection error — §4.2.2 makes it one request's problem, answered 431, because a reset means "nothing happened, try again" and a client would, for ever, with the same oversized head. Each asserts the connection error code and the reason phrase, since the phrase is the only thing that tells the two H3_FRAME_UNEXPECTED cases apart. This is standing in for h3spec, which would be the real conformance suite and cannot be built: its Haskell quic dependency is marked broken in nixpkgs and its version bounds are not satisfiable there.

tests/nixos/load.nix asks what happens with many clients at once, over both protocols in one process. Six thousand HTTP/1.1 requests through siege, then three thousand HTTP/2 requests through h2load at fifty streams per connection, then bodies in both directions large enough that flow control has to work. Three assertions only a load test can make:

  • Availability is 100%, and the server's own count of what it served matches the client's count of what it asked for, exactly. Under HTTP/2 that count is the sharp end: two requests inside the handler at the same time on the same connection is something only HTTP/2 can do, and a lost atomic increment there is a race the HTTP/1.1 rounds could never reach.
  • The open file descriptor count is identical before and after.
  • Resident memory is compared between two identical rounds rather than against the start, so thread-pool ramp is not mistaken for a per-request leak — and the warm-up runs every kind of load, repeatedly, until the thread count stops moving. That last part is not optional: a task is an operating system thread here, and the pool grows with the peak concurrency it has been asked for, which a fast route never reaches. Warming up on / alone and then measuring a round containing /bulk reports a 36 MB leak that is two hundred thread stacks.

The load test earned its place immediately. It found that active_streams never fell for a normally-completed request — the reader's half of a stream was closed only on RST_STREAM, never on END_STREAM — so every connection silently stopped accepting new streams after exactly max_concurrent_streams of them. Twenty connections times a hundred streams is two thousand; a run of three thousand requests came back with a thousand failures and not one error logged, because from the server's point of view nothing had gone wrong.

All five need KVM, so none runs on an always-on Forgejo runner. The workflow puts them on an ephemeral DigitalOcean tier instead.

API documentation

Generated from the doc comments, which carry most of the explanation in this library:

$ zig build docs         # into zig-out/docs
$ zig build docs-serve   # and read them at http://127.0.0.1:8000/

It has to be served rather than opened: the viewer is a WebAssembly program that fetches its sources at runtime, and a browser refuses that from a file:// page.

tools/docs_server.zig is that server, and it is built on this library. A project that ships an HTTP server and then reaches for somebody else's to serve its own documentation has said something about its library that no README can take back — and it is the most useful test there is besides, being a second real consumer of the API doing the thing most consumers will do (resolve a path, send a file) with a browser as its client.

References cited

Belshe, M., R. Peon, and M. Thomson, eds. 2015. Hypertext Transfer Protocol Version 2 (HTTP/2). RFC 7540. RFC Editor. Obsoleted by RFC 9113; cited for the history of the connection preface.

Berners-Lee, T., R. Fielding, and L. Masinter. 2005. Uniform Resource Identifier (URI): Generic Syntax. RFC 3986. RFC Editor. https://www.rfc-editor.org/info/rfc3986.

Bishop, M., ed. 2022. HTTP/3. RFC 9114. RFC Editor. https://www.rfc-editor.org/info/rfc9114.

Krasic, C., M. Bishop, and A. Frindell, ed. 2022. QPACK: Field Compression for HTTP/3. RFC 9204. RFC Editor. https://www.rfc-editor.org/info/rfc9204. The static table of Appendix A and the field line representations of §4.5 are implemented in src/http3/qpack.zig; §5 defines no Huffman code and defers to RFC 7541, which is why that table is shared rather than transcribed twice.

Iyengar, J., and M. Thomson, eds. 2021. QUIC: A UDP-Based Multiplexed and Secure Transport. RFC 9000. RFC Editor. https://www.rfc-editor.org/info/rfc9000. What HTTP/3 runs on, implemented in zig-quic rather than here — but its §2.1 stream types, §4.6 stream limits and §19.19 application error codes all reach into src/http3.

Thomson, M., and S. Turner, eds. 2021. Using TLS to Secure QUIC. RFC 9001. RFC Editor. https://www.rfc-editor.org/info/rfc9001. Why HTTP/3 has no cleartext form: QUIC carries TLS and there is no version of it that does not, so there is no h3c the way there is an h2c.

Dusseault, L., and J. Snell. 2010. PATCH Method for HTTP. RFC 5789. RFC Editor. https://www.rfc-editor.org/info/rfc5789.

Fette, I., and A. Melnikov. 2011. The WebSocket Protocol. RFC 6455. RFC Editor. https://www.rfc-editor.org/info/rfc6455. The opening handshake in tests/e2e.zig is the worked example from §1.3. The protocol itself is implemented in zig-websocket rather than here.

Fielding, R., M. Nottingham, and J. Reschke, eds. 2022. HTTP Semantics. RFC 9110. RFC Editor. https://www.rfc-editor.org/info/rfc9110.

———, eds. 2022. HTTP/1.1. RFC 9112. RFC Editor. https://www.rfc-editor.org/info/rfc9112.

Hinnant, Howard. 2021. chrono-Compatible Low-Level Date Algorithms. https://howardhinnant.github.io/date_algorithms.html. The source of days_from_civil and civil_from_days in src/protocol/date.zig.

McManus, P. 2018. Bootstrapping WebSockets with HTTP/2. RFC 8441. RFC Editor. https://www.rfc-editor.org/info/rfc8441. The :protocol pseudo-header and SETTINGS_ENABLE_CONNECT_PROTOCOL, which are how Response.upgrade works over HTTP/2.

Nottingham, M., and R. Fielding. 2012. Additional HTTP Status Codes. RFC 6585. RFC Editor. https://www.rfc-editor.org/info/rfc6585. Where 431 Request Header Fields Too Large comes from.

Oku, K. 2017. An HTTP Status Code for Indicating Hints. RFC 8297. RFC Editor. https://www.rfc-editor.org/info/rfc8297. 103 Early Hints, which is what Response.sendInterim exists for.

Peon, R., and H. Ruellan. 2015. HPACK: Header Compression for HTTP/2. RFC 7541. RFC Editor. https://www.rfc-editor.org/info/rfc7541. The Appendix A static table and the Appendix B Huffman code are transcribed in src/http2/hpack/, and the Appendix C worked examples run as tests in src/http2/hpack/rfc7541.zig.

Thomson, M., and C. Benfield, eds. 2022. HTTP/2. RFC 9113. RFC Editor. https://www.rfc-editor.org/info/rfc9113.

Licence

MIT. The project follows the REUSE specification; nix develop -c reuse lint checks it.