Releases: Kludex/zttp
v0.0.12 - HTTP/3, and HTTP/2 completed
🌐 HTTP/3 has landed - and HTTP/2 is now complete
zttp speaks all three HTTP versions. This release adds HTTP/3 over a from-scratch, pure-Zig QUIC stack - transport, TLS 1.3 handshake, loss recovery, QPACK, and the H3 framing - all behind the same sans-IO pull API. And it closes out HTTP/2: every gap surfaced while integrating with uvicorn is now filled, so H2 is feature-complete for a real server.
import zttp
# HTTP/3, fed UDP datagrams instead of a byte stream - same events out
conn = zttp.Connection(zttp.SERVER, protocol=zttp.HTTP3, **credentials)
conn.receive_datagram(datagram, now)
event = conn.next_event() # Request / Data / EndOfMessage, each with its stream_id🚀 Highlights
- HTTP/3, from the wire up. A complete QUIC transport written from scratch in Zig - long/short-header packets, the TLS 1.3 handshake over CRYPTO frames, packet-number spaces, ACKs, loss detection with PTO, and STREAM (re)transmission - plus H3 framing and QPACK. No
aioquic, no OpenSSL: it's all in the core (#20, #68, #70, #73, #85). - HTTP/2 is feature-complete. Inbound flow-control replenishment, SETTINGS/PING auto-ACK,
GOAWAY/RST_STREAMsend APIs,send_informational(100-continue), eager server preface, and h2c upgrade seeding - the full list of gaps from the uvicorn integration (#36) is closed (#59, #60, #84, #86, #88). - Faster across the board. Single-copy Content-Length bodies, interned request/status lines, one-allocation connection construction, and direct refcounting on 3.14+ free-threaded builds (#69, #71, #72, #74, #76).
- Hardened, and documented as such. The Content-Length smuggling guard runs on every H2 end path, H3 pseudo-header values are validated like regular fields, and
THREAT_MODEL.mdcovers all three protocols (#92, #94, #93).
🌐 HTTP/3 (new)
A read-path-first HTTP/3 server on a from-scratch QUIC stack - the most ambitious piece of this release.
- QUIC transport built in pure Zig: the TLS 1.3 key schedule and AEAD ops (#68), the handshake message codec (#70), the handshake driven over QUIC CRYPTO frames (#73), packet/header serializers (#63), and a handshake interoperable with conformant clients (#101).
- Reliability: the STREAM send path on a shared packet builder (#78), retransmission of lost STREAM data (#79), and tail-loss recovery with a PTO timer (#80).
- HTTP/3 layer: the control stream and SETTINGS exchange (#89), advertised limits, GOAWAY for graceful shutdown (#91),
RESET_STREAM/STOP_SENDINGfor per-stream cancellation (#97), a peerSTOP_SENDINGsurfaced as a reset event (#99), the bidirectional stream limit enforced and replenished (MAX_STREAMS, #100), trailers and 1xx interim responses (#104), and the server's QPACK encoder/decoder streams (#102). - Correctness & safety: pseudo-header values are validated like regular fields (#94), and an early round of crash/validation/resource-exhaustion bugs was fixed (#87).
The QUIC + HTTP/3 path is the newest code and the least battle-tested of the three - see THREAT_MODEL.md.
⚡ HTTP/2 (completed)
Every gap found while wiring H2 into uvicorn (#36) is now resolved:
- Inbound flow control is replenished - the connection and stream receive windows refill and advertise
WINDOW_UPDATE, so request bodies and large responses no longer stall past 64 KiB (#60). - SETTINGS and PING are auto-ACKed (#60), and the server advertises its enforced limits in the handshake SETTINGS (#88).
connection.close()sends GOAWAY andstream.reset()sends RST_STREAM for graceful shutdown and per-stream cancellation (#59), with a connection-fatal error emitting exactly one GOAWAY (#88, prior).stream.send_informational()sends 100-continue,send_response(end_stream=...)avoids the empty trailing DATA frame for bodyless responses, and h2c upgrade seeding lets anUpgrade: h2crequest continue as stream 1 (#84, #86).- HEAD responses carrying
content-lengthno longer trip a stream error (#84).
🔒 Security
- The Content-Length-vs-body guard runs on every H2 end-of-stream path - DATA, a bodyless HEADERS frame, and trailers - so a request that lies about its body length can't be downgraded into a smuggled HTTP/1.1 message (#92). A mismatch now resets the stream, not the connection (#107).
- HTTP/3 pseudo-header values are validated with the same rules as regular fields (#94).
- The H2 roundtrip and the HPACK/QPACK encode-decode differential are fuzzed (#96), and
THREAT_MODEL.mdwas refreshed to cover HTTP/2 and HTTP/3 (#93), including a residual-risk note on the h2→h1 downgrade contract for proxy integrators (#106).
🏎️ Performance
- Single-copy Content-Length bodies -
Datais materialized straight from the fed buffer instead of being copied twice (#71). - Interned request-line and status-line strings, and header tuples/lists stored with the direct
SET_ITEMform (#74, #77). - One-allocation connection construction - the engine is built in place (#72).
- Inline refcounting on CPython 3.14+ GIL/free-threaded builds, plus a general hot-path pass (#76, #75, #69).
📊 Benchmarks
- The suite is now statistically robust (round-robin batches, GC disabled, median + spread) and verifies extraction before timing so the comparison is apples-to-apples (#67).
- Benchmarks are grouped under a
benchmarks/directory, one file per protocol, with an HTTP/2 vsh2comparison added alongside the HTTP/1 suite (#103, #105).
🧹 Code health & docs
- Shared ASCII/H2-field/decimal helpers hoisted into common leaves (#82), change-narration comments stripped (#81), and the H2 code-health cleanups from #58 applied (#106).
- The docs were reorganized around protocol-based usage with a single Architecture page (#95), HTTP/2 and HTTP/3 are explained in the usage docs (#98), and the README covers all three protocols (#65, #66).
Full changelog: v0.0.11...v0.0.12
v0.0.11 - HTTP/2, hardened
⚡ HTTP/2 has landed - and it came in hardened
This is the big one: zttp speaks HTTP/2 now, end to end, on the same sans-IO pull API you already know. A pure-Zig core does the framing, HPACK, flow control, and stream state; your code just calls receive_data() and next_event(). And before the ink was dry, it went through two security passes - so the new surface ships locked down, not "we'll harden it later."
import zttp
conn = zttp.Connection(zttp.SERVER, protocol=zttp.HTTP2)
conn.receive_data(data) # same pull API, now multiplexed
event = conn.next_event() # every event carries its stream_id🚀 Highlights
- Full HTTP/2 core, sans-IO and zero-copy. Frame codec, HPACK (Huffman + dynamic table), per-stream state machine, connection + stream flow control, and a bounded event ring - all in pure Zig, no I/O, no syscalls (#5, #18, #19, #32).
- Read and write. Server reads requests, client reads responses, and the write side serializes responses/requests with outbound flow control and a
Streamhandle that routes multiplexed sends correctly (#19, #32). - Hardened from day one. HTTP/2 Rapid Reset (CVE-2023-44487), CONTINUATION floods (CVE-2024-27316), and H2→H1 header smuggling are all defended in the core - see Security below.
- Now on Python 3.10 and 3.11 too (#31).
🌐 HTTP/2
- Server read path with a sans-IO Zig core: preface + SETTINGS handshake, HEADERS/CONTINUATION reassembly, DATA, trailers, and the control frames (RST_STREAM, GOAWAY, SETTINGS, PING, WINDOW_UPDATE). Every event carries a
stream_id(0 for HTTP/1.1, so existing code is untouched) (#5). - Client responses: read HEADERS/DATA back on a stream you opened, including interim
1xx(#18). - Write side, driven from Python:
send_response/send_data/end_messagethrough a per-streamStreamhandle, with connection- and stream-level send windows parking and draining bytes asWINDOW_UPDATEcredit arrives (#19, #32).
🔒 Security
The HTTP/2 surface got a code-level audit and a CVE-driven audit. What shipped:
- Rapid Reset is bounded (CVE-2023-44487). Streams are evicted the moment they fully close, the concurrency count is derived (no drift), and
max_streams/max_stream_resetschurn caps tripENHANCE_YOUR_CALMbefore a peer can grow the connection without limit (#42). - No header-value smuggling. The H2 read path now rejects
CR/LF/NULand edge whitespace in every header and pseudo-header value - the same rule the H1 path and the H2 writer enforce - so a re-serialized request can't be split (#38). - Empty
:pathrejected per RFC 9113 8.3.1, matching the H1 non-empty-target rule (#38). - Allocation failures surface honestly. Out-of-memory on the upgrade and H2 flush paths now raise
MemoryErrorinstead of being swallowed or returning a half-built event (#43). - Fuzzing reaches the H2 engine. The libFuzzer/OSS-Fuzz target builds again and now drives the connection/HPACK/frame/stream machine, with a matching Python oracle and an always-on property test (#44).
🧰 Tooling & platforms
- Python 3.10 and 3.11 are supported (#31).
- Smaller, cleaner wheels: debug info is stripped from the extension and stale artifacts are no longer bundled (#35).
- HTTP/1.1 core regrouped under an
h1/package, making room forh2/next to it (#29). - A libFuzzer + OSS-Fuzz target for the parser (#25), and docs deploy moved to Cloudflare Workers Builds (#27), with a longer wheel-build timeout for the bigger matrix (#33).
📚 Docs
- HTTP/2 is documented across the site and marked done (#34).
- README response example updated to the current
send_responsesignature (#28).
Full changelog: v0.0.10...v0.0.11
v0.0.10
Breaking
send_responsealways sendsHTTP/1.1in the status line (#26).0.0.9echoed the request's version, so anHTTP/1.0request got anHTTP/1.0response. The response version states the version the message conforms to, not the client's, so it's now fixed at1.1- matching h11, httptools, and RFC 9110/9112.
Fixed
- The README logo now uses an absolute URL so it renders on the PyPI project page.
Full changelog: v0.0.9...v0.0.10
v0.0.9
Breaking
send_responsedrops theversionandreasonarguments (#24). The new signature issend_response(status, headers=None). The version is taken from the request zttp parsed (falling back to1.1), and the reason phrase is derived from the status, so callers pass neither. Updatesend_response(b"1.1", status, reason, headers)tosend_response(status, headers). For interim1xxresponses, usesend_informational.
Full changelog: v0.0.8...v0.0.9
v0.0.8
What's new
Connection.send_informational(status, headers=None)(#22). Serialize an interim 1xx response (100 Continue,103 Early Hints) that precedes the final response on the same message cycle. Unlikesend_response, it does not consume the cycle - the real response still follows. The reason phrase is derived from the status and the version is always1.1. Status must be100..199, excluding101 Switching Protocols(terminal, not interim).- Cron-scheduled fuzzing for the parser core (#21).
Full changelog: v0.0.7...v0.0.8
v0.0.7
What's new
- Free-threaded (
cp314t) wheels (#16). zttp now ships free-threaded CPython 3.14 wheels for Linux (manylinux + musllinux, x86_64 + aarch64) and macOS (arm64 + x86_64). The extension declaresPy_MOD_GIL_NOT_USED, so importing it on a free-threaded interpreter no longer re-enables the GIL. Windowscp314tis not shipped yet.
Full changelog: v0.0.6...v0.0.7
v0.0.6
What's new
Eventis now public (#15). The return type ofConnection.next_event()is exported from the package -from zttp import Event- instead of only living in the extension stub. It's the unionRequest | Response | Data | EndOfMessage | NeedData | ConnectionClosed.
Breaking
- The connection-closed sentinel is renamed for a uniform model with
NEED_DATA: the singleton is nowCONNECTION_CLOSED(compare withis), andConnectionClosedis its type (for annotations / theEventunion). Updateevent is zttp.ConnectionClosedtoevent is zttp.CONNECTION_CLOSED. Both sentinels -NEED_DATA/NeedDataandCONNECTION_CLOSED/ConnectionClosed- now follow the same shape.
Full changelog: v0.0.5...v0.0.6
v0.0.5
What's new
Request.pathandRequest.query(#13). The request event now exposes the target split at the first?-path(everything before) andquery(everything after, empty if absent), both verbatim bytes.targetis unchanged. zttp does not percent-decode; that stays the consumer's job. Saves every consumer from re-splittingevent.targetthemselves.
Full changelog: v0.0.4...v0.0.5
v0.0.4
What's new
Send-side correctness
- Auto-derive bodyless framing (#9).
send_responseno longer takes abodylessargument - the connection derives it from the request method and status (HEAD,1xx,204,304,CONNECT2xx, RFC 9112 6.3), the way h11 does. The client side auto-frames bodyless responses too, soexpect_bodyless()is gone. - Content-Length is enforced (#11). Sending more body than declared, or ending a message with bytes still owed, now raises instead of serializing a malformed message. Trailers on a non-chunked message are rejected too. Matches h11's
ContentLengthWriter. - Specific error messages (#12). Each send-side misuse (over-send, under-send, trailers, wrong order) raises
LocalProtocolErrorwith its own actionable message - all still catchable by the singleLocalProtocolError/ProtocolErrorclass.
Connection signals for server loops
conn.should_close(),conn.upgrade(),request.expect_continue(#10). Keep-alive, protocol-upgrade, and 100-continue decisions are derived in Zig from the parsed request, so a server (e.g. uvicorn's zttp backend) no longer re-scans headers in Python.should_close/upgradeare connection methods;expect_continuerides theRequestevent.
Docs
- Accumulate parsed bodies into a
bytearrayin the examples (#8), Installation requirements (#7), logo (#6).
All changes are additive or internal; the read path is unchanged in performance.
Full changelog: v0.0.3...v0.0.4
v0.0.3
zttp is a sans-IO HTTP/1.1 parser for Python with a Zig core.
Warning
zttp is experimental. The API and behaviour may change at any time, and it is not yet ready for production use.
Security
- Two adversarial security audits (a code-level review and a CVE-driven review against real HTTP-parser CVEs across Node, Go, Python, Rust, and C servers). zttp resists the known historical parser-CVE classes.
- Hardening from the audits: parse errors are now terminal (a desynced connection can't be revived via
start_next_cycle), the serializer refuses ambiguous framing (Transfer-Encoding + Content-Length, duplicate/non-digit Content-Length), the version parser is restricted to HTTP/1.x, the status reason-phrase rejects control bytes, and an oversizedreceive_dataraisesRemoteProtocolError. - Added
THREAT_MODEL.md.
Packaging
- Wheels for Windows (amd64) and musllinux in addition to manylinux and macOS - full parity with httptools' platform reach.
- Build migrated to the shared
hatch-ziglanghook.
Documentation
- Documentation site deployed to Cloudflare, with an mkdocstrings-generated API reference, object cross-references, and a mermaid architecture diagram.
Install
pip install zttpFull changelog: v0.0.2...v0.0.3