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

is_token / require_token

Validate RFC token characters

validate_name_bytes / validate_value_bytes

Reject NUL, CR, LF in header values

split_commas / split_semicolons

Split with quoted-string awareness

unquote / quote

RFC quoted-string round-trip

parse_key_value / parse_params

Parameter dictionary extraction (e.g. charset=utf-8)

parse_quality

q-value parsing with range validation


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

mode

STRICT

STRICT or COMPAT (affects obs-text allowance)

unknown

RAW

How to handle unregistered headers — RAW, COMBINE, or REJECT_DUPES

max_total_bytes

64 KB

Aggregate header size limit

max_field_lines

256

Maximum number of header field lines

allow_obs_text

True

Permit RFC "obs-text" (bytes 0x80–0xFF)

strip_ows

True

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 value

  • combined(name) — get the semantically combined value per the registry's CombineMode

  • parse(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