HeaderKit¶
EDRI's HTTP header subsystem — a lossless, lazy, typed header accessor and response-header builder for ASGI scopes. It exists because Python's standard library and prevailing WSGI/ASGI tooling do not provide the combination of:
Lossless round-tripping (original byte pairs preserved)
Lazy semantic parsing (only parse a header when the application asks for it)
RFC-compliant MIME type, cache-control, and entity-tag grammars
Pluggable parsing and response-header construction
The headerkit lives at edri.api.headerkit — it is a standalone subsystem with no dependency on the rest of EDRI aside from its own error hierarchy.
Core concepts¶
Content-Type negotiation¶
The parsed Accept header is exposed as an Accept object whose best_match() method implements RFC 7231 content negotiation (quality, specificity, and order). The framework uses this internally during the content-negotiation step described in Content Negotiation.
Cache policy¶
Cache-related headers (Cache-Control, Expires, ETag, If-None-Match, etc.) are parsed into typed dataclasses. Cache-Control directives are exposed as a mapping; EntityTag and EntityTagSet support weak/strong comparison. See CacheControl.
MIME type grammar¶
The edri.api.headerkit.grammar module provides RFC 5234 / RFC 7230 low-level parsing primitives:
Function |
Purpose |
|---|---|
|
Validate RFC token characters |
|
Reject NUL, CR, LF in header values |
|
Split with quoted-string awareness |
|
RFC quoted-string round-trip |
|
Parameter dictionary extraction (e.g. |
|
|
Architecture¶
registry.py — Header field registry¶
Registry stores a FieldSpec per known header:
@dataclass(frozen=True, slots=True)
class FieldSpec:
name: bytes
combine: CombineMode
parser: Parser | None = None
allow_duplicate_identical: bool = False
request: bool = True
response: bool = True
description: str = ""
Each spec declares:
combine— how multi-line headers are combined (comma-list, cookie-style;, singleton, never-combine, or opaque).parser— optional callable that lazily converts the raw bytes into a typed object.request/response— which side of the HTTP exchange the header is valid on (used for documentation and future validation).
The DEFAULT_REGISTRY pre-registers ~40 common HTTP headers (Accept, Content-Type, Cache-Control, Cookie, Authorization, ETag, Range, CORS fields, etc.).
policy.py — Parsing policy¶
HeaderPolicy controls parsing strictness:
Field |
Default |
Meaning |
|---|---|---|
|
|
|
|
|
How to handle unregistered headers — |
|
64 KB |
Aggregate header size limit |
|
256 |
Maximum number of header field lines |
|
|
Permit RFC "obs-text" (bytes 0x80–0xFF) |
|
|
Strip optional whitespace from decoded values |
Pre-built constants: STRICT_POLICY and COMPAT_POLICY.
grammar.py — RFC-compliant MIME parsing¶
Grammar functions operate on raw byte or string values and raise ParseError on malformed input. They are the building blocks for all semantic parsers in edri.api.headerkit.parsers.
core.py — The Headers class¶
Headers is the main entry point. Constructed from an ASGI scope's "headers" list:
headers = Headers.from_scope(scope)
It provides:
first(name)/singleton(name)— get a single header valuecombined(name)— get the semantically combined value per the registry'sCombineModeparse(name)— lazily parse a header through its registered parser (result cached)Typed properties —
headers.accept,headers.cache_control,headers.content_type,headers.cookies, etc.
response.py — ResponseHeaders builder¶
ResponseHeaders constructs ASGI response header byte pairs:
resp = ResponseHeaders()
resp.set_content_type("text/html", charset="utf-8")
resp.set_cookie("session", "abc123", httponly=True, secure=True)
resp.set_date("expires")
resp.asgi() # → list[tuple[bytes, bytes]]
Set-Cookie is append-only (never coalesced). delete_cookie() sets an immediate expiry.
How to customise¶
Registering a custom header parser¶
Create a parser function and register it on a Registry:
from edri.api.headerkit import Registry, FieldSpec, CombineMode
def parse_my_header(headers, name: bytes) -> MyHeader:
raw = headers.singleton(name)
return MyHeader.parse(raw)
registry = Registry()
registry.register(FieldSpec(b"x-my-header", CombineMode.SINGLETON, parse_my_header))
# Pass to Headers
headers = Headers.from_scope(scope, registry=registry)
The default registry is a module-level singleton. To add headers globally, create a custom registry and pass it on construction — the singleton is never mutated after import.
Adding a response header transformer¶
Wrap or subclass ResponseHeaders:
class MyResponseHeaders(ResponseHeaders):
def append(self, name, value):
# Add a server-timing header to every response
super().append(b"server-timing", b"app;dur=42")
super().append(name, value)
Custom cache-header policy¶
from edri.api.headerkit import Headers, HeaderPolicy, ParseMode, UnknownHeaderMode
# Accept duplicate unknown headers by combining them with commas
policy = HeaderPolicy(
mode=ParseMode.COMPAT,
unknown=UnknownHeaderMode.COMBINE,
max_total_bytes=128 * 1024,
)
headers = Headers.from_scope(scope, policy=policy)
See also¶
Content Negotiation — how
Accept-based negotiation uses HeaderKitMiddleware — middleware can inspect parsed headers via
event._headers