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
h2library β a sans-I/O
state machine where you callreceive_data(bytes)and react to a list of
events (RequestReceived,DataReceived,WindowUpdatedβ¦).h2is 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
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}')
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,
))
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) |
+=+=============================================================+
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
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
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
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
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
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:
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.END_HEADERS check β if
END_HEADERS=0, the header block continues across CONTINUATION frames. The method returnsFalseand the caller setswaiting_continuation = True, stashing the frame for accumulation.Malformed check (Β§8.1.1) β after
parse_payload()+parse_headers()decode the header block, themalformedflag 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.












