The header model

This page explains why http-fields is shaped the way it is. For task-focused instructions, see the how-to guides; for the full API, the reference.

Headers are immutable value objects

Every header is a frozen dataclass whose fields are the structured components of the header — Age.seconds, ContentType.media_type, SetCookie.max_age, and so on. Consequences:

  • Immutable. You never mutate a header in place; you build a new one. This makes headers safe to share and cache.

  • Comparable and hashable. Two headers are equal when their fields are equal, so headers work in sets and as dict keys.

  • The value is derived. .value (and str(), bytes()) are computed from the fields, so the serialized form always reflects the current structured state — they can’t drift apart.

abnf drives parsing and validation

The library is built on abnf grammars for the relevant RFCs. Those grammars are used in both directions:

  • ParsingHeader.parse(value) runs the grammar over the raw value and a visitor turns the parse tree into structured fields.

  • Validation — the field types themselves (Token, NonNegativeInt, EntityTag, …) parse and validate on construction. So validity isn’t a separate step bolted on; it’s a property of the types the fields are made of.

A constructed header is therefore always well-formed: invalid input raises ValueError (or TypeError for wrong argument types), whether it came from parse() or direct construction.

Because each field type is self-validating, a header is parsed at most once. parse() runs the grammar and the visitor wraps the already-validated node text as leaf types with parse=False (a trusted construction that skips re-parsing); direct construction validates only the parts you actually pass. There is no second grammar pass to re-check a value the visitor just built. Structured-Fields headers take this a step further: their serializer is the validator — a value that would inject control characters or exceed a numeric bound simply fails to serialize — so a successful serialization is grammar-valid by construction, with no re-parse at all.

One construction contract

There is a single, consistent way to make headers:

  • Header.parse(value) is the only string entry point.

  • Direct construction takes the structured fields as their parsed types. Scalars coerce plain values (Age(60)); list-style headers take leaf types as varargs (Connection(Token("keep-alive"), Token("close"))); headers with richer values take value objects (Upgrade(Protocol("HTTP", "2"))). A raw string where a typed field is expected is a TypeError — reach for parse() instead.

  • Builders (ContentType.of(...), SetCookie.build(...), ETag.from_tag(...)) exist only where piece-wise construction is richer than the stored fields.

  • Header.create(name, value) dispatches by field name to the right subclass, or a CustomHeader.

This is a deliberate break from the older overloaded “string or pieces” constructors: one object, one obvious way to build it, and precise types throughout.

Shared bases where shapes repeat

Families that share an identical shape are factored onto a common base — dates (DateHeader), entity-tag lists (EntityTagListHeader), product lists (UserAgent/Server), the auth pairs (WWWAuthenticate/ProxyAuthenticate, …), and Structured-Fields lists/dictionaries. Where a family only rhymes (different element types or separators), the headers stay individual — a shared base isn’t forced.

Scope

Coverage spans the core HTTP RFCs (9110, 9111, 6265, 6266), widely-deployed extensions (Forwarded, CORS, Link, HSTS, Alt-Svc, Prefer), and the modern Structured-Fields headers (RFC 9651 and the specs built on it). Headers defined outside a clean RFC ABNF grammar are out of scope, because the whole library is grammar-driven.


Implementation notes, design decisions, and the migration history live in the project’s internal design document (.claude/design/header-model.md).