Skip to content

Answer the formatter's common Accept headers from a table built once - #2924

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

ericproulx wants to merge 1 commit into
masterfrom
perf/formatter-accept-table

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

  • An API that pins no format (content negotiation, e.g. default_format :json) negotiated one from the Accept header on every request that sent one, through Rack::Utils.best_q_match: about 1.7 µs for Accept: application/json. The answer depends only on the header and the middleware's mime_types.
  • Formatter::FormatForAcceptCache computes it once for every registered media type, */* and no Accept header, and format_from_header looks the header up. Any other header misses and is negotiated as before. Only registered media types are keys, so client input cannot grow a table.
  • Shared per mime_types, which Grape::ContentTypes already shares per content-type registry, since every endpoint builds its own formatter. An API that pins a format never negotiates from the header and builds no table.
  • The negotiation moved into the class method Formatter.format_for_accept (@api private) so the cache and the miss path run the same function. The keys are valid strings that scrubbing leaves alone, so only a miss scrubs the header.

Benchmarks

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

request, API with no pinned format no JIT YJIT
Accept: application/json +40.3% +54.1%
Accept: */* +5.3%
pinned-format GET (control) +0.2% (noise)

Boot, 500 negotiating endpoints: retained memory and compile time unchanged.

Behaviour

Byte-identical to master over a 525-case matrix: 7 APIs (defaults, default_format, a custom text/csv type, a mixed-case registered type, a registered application/*, a parameterized application/json; charset=utf-8, a pinned format) × 25 Accept values (a browser Accept, axios's default, q-value lists, other casings, invalid bytes, binary-encoded headers) × 3 paths (plain, .json extension, ?format=xml).

Test plan

  • Full RSpec suite passes locally.
  • RuboCop clean.
  • Mutation-checked through existing specs: a table answering :txt for every key fails 7; skipping the scrub on a miss fails 1.
  • CI green.

🤖 Generated with Claude Code

An API that pins no `format` negotiates one from the Accept header on
every request that sends one, through `Rack::Utils.best_q_match`: about
1.7 µs for `Accept: application/json`. The answer depends only on the
header and `mime_types`, which is fixed once the middleware is built.

`FormatForAcceptCache` works it out once for every registered media type,
`*/*` and no Accept header, and `format_from_header` looks the header up
there. Any other header misses and is negotiated as before, and only
registered media types are keys, so client input cannot grow a table.
Each entry is computed by the same function the full path runs, now a
class method so the cache can call it, so a hit answers exactly what the
full path would. The keys are valid strings that scrubbing leaves alone,
so only a miss scrubs the header.

Every endpoint builds its own formatter, so the table is shared per
`mime_types`, which `Grape::ContentTypes` already shares per content-type
registry. An API that pins a format never negotiates from the header and
builds no table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the perf/formatter-accept-table branch from 7f1b86f to 921e21f Compare September 10, 2026 19:18
@github-actions

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@github-actions

Copy link
Copy Markdown

Danger Report

Errors

  • One of the lines below found in CHANGELOG.md doesn't match the expected format. Please make it look like the other lines, pay attention to version numbers, periods, spaces and date formats.

Markdowns

* [#XXXX](https://github.com/ruby-grape/grape/pull/XXXX): Answer the formatter's common Accept headers from a table built once instead of negotiating them per request - [@ericproulx](https://github.com/ericproulx).
does not include a pull request link

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.

@ericproulx ericproulx closed this Sep 11, 2026
@ericproulx
ericproulx deleted the perf/formatter-accept-table branch September 15, 2026 21:49
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