DEV Community

TOKUJI
TOKUJI

Posted on

How a from-scratch HTTP/2 server actually works (part 1) — connection to first request

RFC 9113 is 80 pages. What does it take to turn those 80 pages into working code?

BlackBull is a pure-Python ASGI web framework whose HTTP/2 stack is built directly on TCP — no h2 library, no C extensions in the protocol layer. Because the frame layer and the server integration live in the same source files, you can trace a single RFC requirement end-to-end: from the 9-byte header parse through stream state validation to the application queue that withholds flow-control credit for back-pressure. Nearly every block carries the RFC section number it implements, so you can grep for §6.9.1 and land on the dual-window logic, then follow the call chain in both directions.

A note on h2: The most widely used Python package for HTTP/2 is the
h2 library — a sans-I/O
state machine where you call receive_data(bytes) and react to a list of
events (RequestReceived, DataReceived, WindowUpdated…). h2 is a
frame parser. It does not tell you how to wire those events into an event
loop, manage stream state across concurrent tasks, propagate back-pressure to
handlers, or shut down a connection cleanly — those concerns live in a
separate codebase (Daphne, Hypercorn, etc.).


1. The connection preface — how HTTP/2 begins (§3.4, §6.5)

Before any frames flow, the client must prove it speaks HTTP/2. RFC 9113 §3.4 specifies a 24-byte magic string:

PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
Enter fullscreen mode Exit fullscreen mode

BlackBull handles this in ConnectionActor — the per-TCP-connection supervisor that detects the protocol and spawns the appropriate handler. The preface string is the same regardless of TLS; what differs is how the server knows to expect it. When TLS negotiates ALPN h2, the protocol is already decided — ConnectionActor._dispatch() reads exactly 24 bytes and validates them:

# ConnectionActor._dispatch() — ALPN-h2 path
preface = await self._reader.readexactly(24)
expected = _HTTP2_PREFACE_FIRST_LINE + _HTTP2_PREFACE_REMAINDER
if preface != expected:
    # Send a raw GOAWAY before closing so a legitimate HTTP/2 peer
    # gets a clean diagnosis rather than a mysterious TCP RST.
    ...
    raise ValueError(f'Invalid HTTP/2 preface: {preface!r}')
Enter fullscreen mode Exit fullscreen mode

On a cleartext connection there is no ALPN, so the same dispatch method sniffs the first line: if it matches PRI * HTTP/2.0\r\n, the remaining 8 bytes are read and the same validation runs. If it doesn't match, the connection falls through to HTTP/1.1.

Once the preface is validated, HTTP2Actor takes over. Its run() method immediately sends the server's SETTINGS frame (§6.5):

# HTTP2Actor.run() — sends SETTINGS as the very first frame
await self.send_frame(self.factory.settings(
    enable_connect_protocol=cfg.h2_enable_websocket,
    initial_window_size=cfg.h2_initial_window_size,
    max_concurrent_streams=self.max_concurrent_streams,
))
Enter fullscreen mode Exit fullscreen mode

SETTINGS is how both endpoints agree on parameters: initial flow-control window, maximum frame size, header table size, and whether server push or WebSocket-over-H2 is enabled. Incoming SETTINGS (with and without ACK) are handled by SettingsResponder.respond(), which validates every parameter against RFC 9113 §6.5.2 ranges — SETTINGS_INITIAL_WINDOW_SIZE must not exceed 2³¹−1, SETTINGS_MAX_FRAME_SIZE must be between 16384 and 16777215, and so on.

After SETTINGS, the connection optionally expands its inbound flow-control window beyond the RFC default of 65535 bytes, then enters _frame_loop.


2. Reading a frame — the 9-byte header (§4.1)

Every HTTP/2 frame starts with a 9-byte header:

+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
Enter fullscreen mode Exit fullscreen mode

HTTP2Actor.receive() reads exactly 9 bytes, extracts the frame length, then reads exactly that many more:

# HTTP2Actor.receive()
data = await self._reader.readexactly(9)
frame = self._parser.parse(data)
size = frame.length
if size > 0:
    payload = await self._reader.readexactly(size)
    frame = self._parser.parse_payload(frame, payload)
return frame
Enter fullscreen mode Exit fullscreen mode

Nine lines. The complexity lives in what happens next.


3. The frame loop guard tower — seven checks before dispatch (§4.2, §5.5, §6.3, §6.4, §6.10)

_frame_loop processes one frame at a time. Before dispatching to a specific handler, it runs a sequence of guards — checks that apply regardless of stream state. The ordering matters: each guard catches cases that would otherwise be misclassified by a later check.

Guard 1 — CONTINUATION expectation (§6.10)

If the previous HEADERS or PUSH_PROMISE had END_HEADERS=0, the only legal next frame is CONTINUATION. Any other type → connection PROTOCOL_ERROR. This must be checked first because a stray HEADERS or DATA arriving mid-header-block would otherwise be dispatched to the wrong handler:

# HTTP2Actor._frame_loop() — Guard 1
if waiting_continuation and frame_type != FrameTypes.CONTINUATION:
    await self._connection_error(ErrorCodes.PROTOCOL_ERROR, ...)
    return
Enter fullscreen mode Exit fullscreen mode

Guard 2 — unknown frame types (§5.5)

Frames with unrecognized type codes MUST be silently ignored for forward compatibility:

# HTTP2Actor._frame_loop() — Guard 2
if frame_type is None:
    continue
Enter fullscreen mode Exit fullscreen mode

Guard 3 — Rapid Reset rate limit (CVE-2023-44487)

The Rapid Reset attack sends HEADERS immediately followed by RST_STREAM in a tight loop — the server spawns a task for each stream, but by the time the handler starts the stream is already reset. max_concurrent_streams doesn't catch it because the stream lifecycle is too short.

BlackBull uses a rolling 1-second window. More than 20 RST_STREAMs per second → GOAWAY ENHANCE_YOUR_CALM. This check runs before stream-state validation so that RST_STREAM on idle/unknown streams also counts toward the budget.

Guard 4 — frame size check (§4.2)

A frame whose payload exceeds the receiver's advertised SETTINGS_MAX_FRAME_SIZE is a FRAME_SIZE_ERROR. The severity depends on the frame type: header-block and connection-state frames (HEADERS, CONTINUATION, PUSH_PROMISE, SETTINGS) are connection-fatal — they corrupt shared HPACK state. Everything else is stream-fatal. _FRAME_SIZE_CONNECTION_ERROR_TYPES is a class-level frozenset that encodes this distinction.

Guard 5 — stream_id==0 for stream-only frames (§6.1, §6.2, §6.4, §6.6, §6.10)

DATA, HEADERS, RST_STREAM, PUSH_PROMISE, PRIORITY, and CONTINUATION MUST NOT appear on stream 0 — that's reserved for connection-control frames (SETTINGS, PING, GOAWAY). _STREAM_ONLY_FRAME_TYPES is a class-level frozenset:

# HTTP2Actor._frame_loop() — Guard 5
if frame_type in _STREAM_ONLY_FRAME_TYPES and frame.stream_id == 0:
    await self._connection_error(ErrorCodes.PROTOCOL_ERROR, ...)
    return
Enter fullscreen mode Exit fullscreen mode

Guard 6 — CONTINUATION without preceding HEADERS (§6.10)

A CONTINUATION that arrives outside an open header block (i.e., waiting_continuation is not set) is a connection PROTOCOL_ERROR. This check must precede stream-state validation — otherwise a stray CONTINUATION on a half-closed or closed stream would be rejected with the wrong error type (STREAM_CLOSED instead of PROTOCOL_ERROR).

Guard 7 — PRIORITY frame length (§6.3)

A PRIORITY frame MUST be exactly 5 octets; anything else is a stream FRAME_SIZE_ERROR. This is enforced before stream-state lookup so a malformed PRIORITY on a not-yet-seen stream still gets the correct error.

After all seven guards, the remaining frames enter a match dispatch on frame type.


4. Stream birth — HEADERS, state machine, CONTINUATION (§5.1, §6.2, §6.10)

A stream is an independent, bidirectional flow of frames within an HTTP/2 connection, identified by a 31-bit integer. Multiple streams coexist on a single TCP connection — this is multiplexing, the defining feature of HTTP/2. Every stream follows a strict lifecycle through a set of states defined in RFC 9113 §5.1.

The RFC defines eight stream states; four matter for a server. Here is the complete lifecycle of a single request:

IDLE  →(HEADERS received)→  OPEN
OPEN  →(END_STREAM received on DATA)→  HALF_CLOSED_REMOTE
OPEN or HALF_CLOSED_REMOTE  →(response END_STREAM sent)→  CLOSED
any  →(RST_STREAM)→  CLOSED
Enter fullscreen mode Exit fullscreen mode

This section covers the IDLE→OPEN transition — everything that happens when a HEADERS frame first arrives.

Stream ID rules (§5.1.1)

Before accepting a HEADERS frame, HTTP2Actor._frame_loop() validates the stream identifier. Peer-initiated streams MUST use odd identifiers, strictly increasing — an even id or one ≤ _last_peer_stream_id is a connection PROTOCOL_ERROR. This monotonic-odd rule is what lets both ends allocate stream IDs without a round-trip; a violation means the peer's state has diverged from ours and the connection is no longer trustworthy.

The HEADERS admission gauntlet

HTTP2Actor._on_headers_frame() runs three checks before any application code sees the request:

  1. Concurrency check — if _active_stream_count >= max_concurrent_streams, respond RST_STREAM REFUSED_STREAM immediately. This is a stream-level error, not a connection error: the client did nothing wrong, it just bumped into a ceiling the server set for itself. The client keeps all its other in-flight streams.

  2. END_HEADERS check — if END_HEADERS=0, the header block continues across CONTINUATION frames. The method returns False and the caller sets waiting_continuation = True, stashing the frame for accumulation.

  3. Malformed check (§8.1.1) — after parse_payload() + parse_headers() decode the header block, the malformed flag is checked. Missing pseudo-headers, invalid field characters, connection-specific headers → RST_STREAM PROTOCOL_ERROR before the request ever reaches a handler.

HTTP2Actor._validate_stream_state(stream, frame_type) encodes the legal frame set for each state. A HEADERS frame arriving on a stream that is already OPEN, for instance, is rejected — the RFC forbids a second HEADERS on an active stream.

CONTINUATION — assembling split header blocks

When END_HEADERS=0, HTTP2Actor._on_continuation_frame() accumulates header_frame.raw_block across subsequent CONTINUATION frames. Only when END_HEADERS=1 does it call header_frame.parse_payload() + parse_headers() once on the complete block — the same concurrency check and malformed check then run on the assembled HEADERS.

CONTINUATION flood protection is covered in the security sidebar in part 2.


What's next — part 2 covers the rest of the lifecycle

Part 1 traced the connection from the 24-byte preface through the first request's arrival. Part 2 follows what happens after the stream is OPEN: flow control and DATA delivery, stream death and cleanup, connection shutdown via GOAWAY, and three security guards every from-scratch implementer needs. It also covers WebSocket over HTTP/2 (RFC 8441) — where the same WebSocketActor runs unmodified over HTTP/2 DATA frames.

Want the full RFC→code map? Every section of RFC 9113, every BlackBull method, with file references, lives at docs/about/rfc9113-implementation.md.

The source is blackbull/server/http2_actor.py — 1200+ lines, nearly every block annotated with its RFC cite.


BlackBull is a personal learning project — pure-Python HTTP/1.1, HTTP/2, and WebSocket, no C extensions in the protocol stack. Issues and PRs welcome.

Top comments (0)