correctness

Parsing Accept & quality values

Ranking types by q-value, breaking ties by specificity, and respecting q=0. The parsing rules every content-negotiating server needs to get right.

The Accept header is a structured list of preferences, not a string. Implementing negotiation with includes() or startsWith() gets wrong answers on real browsers. Here’s what a correct parser does.

The input

Accept: text/markdown, text/html;q=0.8, */*;q=0.1

Split into entries on ,. Each entry has:

  • A type (possibly with a wildcard, like text/* or */*)
  • Zero or more parameters, separated by ;
  • A quality factor q (default 1 if absent), ranging 0 to 1

The ranking rules

  1. Sort by q descending. Higher-q types are preferred.
  2. Break ties by specificity. Within the same q, a fully specified type (text/markdown) beats a subtype wildcard (text/*), which beats the catch-all */*.
  3. Respect q=0. It explicitly means don’t send me this. Never choose a type the client marked q=0, even if it’s your only supported representation — return 406 Not Acceptable instead.

The algorithm

Given:

  • A list of types the client accepts (from the header)
  • A list of types your server can produce

Return the type you should serve, or “none” (→ 406).

for each type you can produce:
  find the best-matching accept entry
  (exact type match > text/* match > */* match)
  the score is that entry's q-value
  (0 if no match, or if matched entry had q=0)

pick the type with the highest score
if max score is 0, return 406

What libraries do for you

Every mature stack has an Accept parser:

  • Node: the accepts package (what Express uses), or negotiator
  • Flask / Werkzeug: request.accept_mimetypes.best_match([...])
  • Django: request.accepted_types (4.2+), or request.get_preferred_type([...]) (5.2+)
  • Ruby: Rails’ respond_to handles it; raw access via Rack::Utils.q_values
  • PHP: Symfony’s HttpFoundation Request::getAcceptableContentTypes
  • Go: github.com/golang/gddo/httputil has NegotiateContentType

Use these. Writing your own parser is how you end up with a bug where Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 (a real Chrome header) accidentally matches your “Markdown” branch.

Gotchas

  • Missing Accept header: means no constraint. Serve your default (usually HTML). It is not the same as an empty Accept.
  • Accept: */*: means anything is fine. Also serve your default.
  • Accept: text/markdown;q=0: means anything but Markdown. Your server should serve HTML (or whatever else), not 406.
  • Whitespace and case: Accept values are case-insensitive for type names and parameter names. Parameter values are not, in general, but charset=utf-8 is fine either case.

Test vectors

Quick sanity checks your parser should pass:

AcceptServer producesShould serve
text/markdownmd, htmlmarkdown
text/markdown, text/html;q=0.8md, htmlmarkdown
text/htmlmd, htmlhtml
text/markdown;q=0, text/htmlmd, htmlhtml
text/markdown;q=0md only406
(no Accept)md, htmlhtml (default)
*/*md, htmlhtml (default)