Skip to content

Answer the header versioner's common Accept headers from a table built once - #2922

Closed
ericproulx wants to merge 1 commit into
masterfrom
perf/header-versioner-accept-table
Closed

Answer the header versioner's common Accept headers from a table built once#2922
ericproulx wants to merge 1 commit into
masterfrom
perf/header-versioner-accept-table

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

  • version ..., using: :header ran Rack::Utils.best_q_match (which splits both strings inside Rack::Mime.match? for every candidate) and a MediaType.parse on every request: about 3.6 µs of a 12.4 µs request. Its answer depends only on the Accept header and the middleware's available_media_types, which is fixed once the middleware is built.
  • Versioner::Header::MediaTypeForAcceptCache computes that answer once for every declared media type, for */* (what curl, Net::HTTP and most HTTP libraries send) and for no Accept header, and the versioner looks the header up. Any other header (q-value lists, another casing) misses and takes the unchanged full path. Only declared media types are keys, so client input cannot grow a table, and each entry is computed by the real function, so a hit answers exactly what the full path would.
  • Every endpoint builds its own versioner, so the table is shared per list through Grape::Util::Cache, as ContentTypes::MimeTypesCache already is.
  • Grape::Util::MediaType is now immutable, strings included.

Contract change

The strings the header versioner writes into the env (api.type, api.subtype, api.vendor, api.version, api.format) are now frozen, since requests sending the same Accept header share one parsed media type. They are frozen on the full path too, so behaviour does not depend on which Accept header came in. Code that altered one of them in place now raises FrozenError; UPGRADING has an entry. MediaType#initialize copies its arguments rather than freezing the caller's Strings.

Benchmarks

Median of 7 interleaved subprocess rounds against master, Ruby 4.0.6:

request no JIT YJIT
Accept: application/vnd.acme-v1+json +55.4% +79.6%
Accept: */* +43.1%
no Accept header +19.3%
path-versioned GET (control) −0.8% (noise)

Boot, 500 header-versioned endpoints: +16 KB retained, +1 ms compile. A table built per instance cost +3.5 MB and +120 ms, which is why it is shared.

Behaviour

Byte-identical to master over a 150-case matrix, apart from the frozen strings: 5 APIs (one version, two versions, strict, cascade: false, dotted vendor and version) × 30 Accept values, including q-value lists, wildcards, other casings, surrounding whitespace, parameters, invalid bytes and binary-encoded headers.

Test plan

  • Full RSpec suite passes locally.
  • RuboCop clean.
  • New spec, 'cannot be altered by one request for the next'. Mutation-checked: leaving the version or the subtype unfrozen fails it; making the table answer the default for every key fails 7 existing specs.
  • CI green.

🤖 Generated with Claude Code

…t once

The header versioner ran Rack's q-value match (`Rack::Utils.best_q_match`,
which splits both strings inside `Rack::Mime.match?` for every candidate)
and a media-type parse on every request: about 3.6 µs of a 12.4 µs
request. Its answer depends only on the Accept header and the middleware's
`available_media_types`, which is fixed once the middleware is built.

`MediaTypeForAcceptCache` works that answer out once for every declared
media type, for `*/*` (what curl, Net::HTTP and most HTTP libraries send)
and for no Accept header at all, and the versioner looks the header up
there. Any other header, such as a q-value list or another casing, misses
and takes the full path unchanged. Only declared media types are keys, so
client input cannot grow a table, and each entry is computed by the real
function, so a hit answers exactly what the full path would.

Every endpoint builds its own versioner, so the table is shared per list
through `Grape::Util::Cache`, as `ContentTypes::MimeTypesCache` already
is. Built per instance it cost 3.5 MB and 120 ms of compile time on a
500-endpoint API; shared, 16 KB and 1 ms.

Handing one parsed media type to many requests means the strings it gives
out must not be alterable in place, so `Grape::Util::MediaType` is now
immutable, strings included, on the full path too so both paths agree.
`#initialize` copies its arguments rather than freezing the caller's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the perf/header-versioner-accept-table branch from c3e7660 to a8b2942 Compare September 10, 2026 19:18
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx

Copy link
Copy Markdown
Contributor Author

Closing in favour of #2936, which combines #2922#2934 into one PR, re-benchmarked as a whole against master. The write-up here (behaviour matrix, mutation results) still describes this part of the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant