Skip to content

Releases: Kludex/zttp

v0.0.12 - HTTP/3, and HTTP/2 completed

12 Jun 12:51
32cec31

Choose a tag to compare

🌐 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_STREAM send 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.md covers 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_SENDING for per-stream cancellation (#97), a peer STOP_SENDING surfaced 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 and stream.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 an Upgrade: h2c request continue as stream 1 (#84, #86).
  • HEAD responses carrying content-length no 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.md was 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 - Data is 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_ITEM form (#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 vs h2 comparison 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

10 Jun 08:22
89866fe

Choose a tag to compare

⚡ 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 Stream handle 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_message through a per-stream Stream handle, with connection- and stream-level send windows parking and draining bytes as WINDOW_UPDATE credit 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_resets churn caps trip ENHANCE_YOUR_CALM before a peer can grow the connection without limit (#42).
  • No header-value smuggling. The H2 read path now rejects CR/LF/NUL and 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 :path rejected 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 MemoryError instead 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 for h2/ 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_response signature (#28).

Full changelog: v0.0.10...v0.0.11

v0.0.10

09 Jun 09:19
02fb1d2

Choose a tag to compare

Breaking

  • send_response always sends HTTP/1.1 in the status line (#26). 0.0.9 echoed the request's version, so an HTTP/1.0 request got an HTTP/1.0 response. The response version states the version the message conforms to, not the client's, so it's now fixed at 1.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

09 Jun 08:40
c7e1174

Choose a tag to compare

Breaking

  • send_response drops the version and reason arguments (#24). The new signature is send_response(status, headers=None). The version is taken from the request zttp parsed (falling back to 1.1), and the reason phrase is derived from the status, so callers pass neither. Update send_response(b"1.1", status, reason, headers) to send_response(status, headers). For interim 1xx responses, use send_informational.

Full changelog: v0.0.8...v0.0.9

v0.0.8

09 Jun 07:43
1cba530

Choose a tag to compare

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. Unlike send_response, it does not consume the cycle - the real response still follows. The reason phrase is derived from the status and the version is always 1.1. Status must be 100..199, excluding 101 Switching Protocols (terminal, not interim).
  • Cron-scheduled fuzzing for the parser core (#21).

Full changelog: v0.0.7...v0.0.8

v0.0.7

09 Jun 07:43
58faa0d

Choose a tag to compare

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 declares Py_MOD_GIL_NOT_USED, so importing it on a free-threaded interpreter no longer re-enables the GIL. Windows cp314t is not shipped yet.

Full changelog: v0.0.6...v0.0.7

v0.0.6

08 Jun 17:51
4250057

Choose a tag to compare

What's new

  • Event is now public (#15). The return type of Connection.next_event() is exported from the package - from zttp import Event - instead of only living in the extension stub. It's the union Request | Response | Data | EndOfMessage | NeedData | ConnectionClosed.

Breaking

  • The connection-closed sentinel is renamed for a uniform model with NEED_DATA: the singleton is now CONNECTION_CLOSED (compare with is), and ConnectionClosed is its type (for annotations / the Event union). Update event is zttp.ConnectionClosed to event is zttp.CONNECTION_CLOSED. Both sentinels - NEED_DATA/NeedData and CONNECTION_CLOSED/ConnectionClosed - now follow the same shape.

Full changelog: v0.0.5...v0.0.6

v0.0.5

08 Jun 13:09
dc1072d

Choose a tag to compare

What's new

  • Request.path and Request.query (#13). The request event now exposes the target split at the first ? - path (everything before) and query (everything after, empty if absent), both verbatim bytes. target is unchanged. zttp does not percent-decode; that stays the consumer's job. Saves every consumer from re-splitting event.target themselves.

Full changelog: v0.0.4...v0.0.5

v0.0.4

08 Jun 11:30
ba1b599

Choose a tag to compare

What's new

Send-side correctness

  • Auto-derive bodyless framing (#9). send_response no longer takes a bodyless argument - the connection derives it from the request method and status (HEAD, 1xx, 204, 304, CONNECT 2xx, RFC 9112 6.3), the way h11 does. The client side auto-frames bodyless responses too, so expect_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 LocalProtocolError with its own actionable message - all still catchable by the single LocalProtocolError / ProtocolError class.

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/upgrade are connection methods; expect_continue rides the Request event.

Docs

  • Accumulate parsed bodies into a bytearray in 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

07 Jun 13:43
0f390aa

Choose a tag to compare

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 oversized receive_data raises RemoteProtocolError.
  • 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-ziglang hook.

Documentation

  • Documentation site deployed to Cloudflare, with an mkdocstrings-generated API reference, object cross-references, and a mermaid architecture diagram.

Install

pip install zttp

Full changelog: v0.0.2...v0.0.3