Skip to content

Speed up header versioning, content negotiation, params validation and error responses - #2936

Open
ericproulx wants to merge 1 commit into
masterfrom
perf/hot-path-throughput
Open

Speed up header versioning, content negotiation, params validation and error responses#2936
ericproulx wants to merge 1 commit into
masterfrom
perf/hot-path-throughput

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

This combines #2922#2934 into one PR: 13 request-path changes to Grape, found by benchmarking features that earlier throughput work never exercised (header versioning, content negotiation, nested and array params, typed params given bad input, rescue_from). Each PR has its full write-up (behaviour matrix, mutation results) and was green on CI (69/69). This PR is their union, squashed to one commit and re-benchmarked as a whole against master.

Against master on the same machine, without JIT (with YJIT in brackets):

  • Header versioning: +59% (+81%) for a vendor Accept, +48% (+64%) for */*.
  • Content-negotiated JSON: +37% (+52%).
  • Validation: a JSON body with a nested Hash +25% (+22%); arrays of Hashes +38% to +42% (+27% to +41%); a JSON body with typed params +17% (+10%).
  • Typed params given a bad value: types: [Integer, String] +136% (+218%); a 400 for a mistyped Integer +55% (+105%).
  • Error responses: a 500 from rescue_from :all +98% (+162%); a malformed JSON body +34% (+56%); the README's full_messages handler +28% to +35% (+28% to +40%).
  • Controls: 10 paths none of the changes target stay within −1.6% to +0.7% (YJIT −1.2% to +1.1%).
  • Averages: across the 37 targeted scenarios, +25.5% mean and +14.2% median (YJIT +33.9% / +12.6%).
  • A real app: in grape-on-rack, its header-versioned endpoint is +38.5% and a JSON-array GET +19.1%.

There is one contract change, listed under Contract change below, with an UPGRADING entry. There are no new dependencies, and no public method changes signature.

What changed, and why it was slow

Versioning and content negotiation

# Change Measured alone, against master
#2922 Header versioner answers common Accept headers from a table. version ..., using: :header ran Rack::Utils.best_q_match and MediaType.parse on every request. That took about 3.6 µs of a 12.4 µs request, because best_q_match splits both strings for every candidate. The answer depends only on the Accept header and the middleware's available_media_types, which are fixed at build. It is now computed once for every declared type, for */* and for no header; any other header takes the unchanged full path. Every endpoint builds its own versioner, so the table is shared per list through Grape::Util::Cache: a per-instance table cost +3.5 MB and +120 ms at 500 endpoints. Grape::Util::MediaType is now immutable. vendor Accept +55.4% (YJIT +79.6%); */* +43.1%; no Accept +19.3%
#2923 Versioners read their options off instance variables. cascade, parameter, strict and vendor each went through two Forwardable frames and two Data readers (~150 ns) for a value fixed at build. header +3.4%, param +2.8%, accept-version +1.7%
#2924 Formatter answers common Accept headers from a table. This is the same idea as #2922, for APIs that negotiate the format instead of pinning one. The negotiation cost ~1.7 µs per request for Accept: application/json. Accept: application/json +40.3% (YJIT +54.1%); */* +5.3%

Params validation

# Change Measured alone, against master
#2927 Required Hash scopes skip the attributes iterator. When a scope and all its ancestors are required and depend on nothing, should_validate? is always true. The iterator's per-attribute checks then reduce to "validate if required or present". This covers the root scope and required Hash scopes, which hold most validators of most endpoints. The plumbing took ~570 of a root validator's 840 ns. Per validator: root presence 658 → 311 ns, nested coerce 1.77 → 1.02 µs. JSON POST +20.5%; nested Hash POST +19.3%; 3-param GET +11.5%; 10-validator GET +11.1%; declared POST +6.5%
#2933 Array element scopes skip it too. requires/optional :items, type: Array do … end qualifies when it is the only element-iterating scope on such a chain. Optional scopes keep the empty-element skip and should_validate?'s all-blank check. Stacked on #2927. 1 / 10 / 50 elements +28.5% / +32.4% / +40.5%; optional array +21.2% (YJIT +26% to +39%), all against #2927
#2928 The iterator settles its per-scope state once per pass, instead of once per element per validator (fiber-storage read, index scope, array checks). 10-element array body +4.1%
#2925 qualifying_params returns early for scopes other than given. Only given ever stores any, yet every nested scope looked them up twice per validator. nested Hash POST +5.4%
#2926 declared skips the renamed-params lookup when nothing is renamed. It built a fresh path key per declared param for a table that is empty without as:. declared call −11% (4.81 → 4.29 µs); +1.4% end to end, within noise
#2930 Coercion uses dry-types' non-raising block form. A rejected value made dry-types' CoercionError.handle re-raise with the underlying error's backtrace, built as Strings: ~30 µs at request depth. A rejected Integer went from 31.2 to 4.4 µs. Every types: [Integer, String] param given a String pays this, as does every 400 for a mistyped value. types: [Integer, String] given abc +114.7% (YJIT +195.1%); 400 for ?id=abc +49.1% (YJIT +95.6%)

Error responses

# Change Measured alone, against master
#2931 The default rescue handler stops reading exception.backtrace. rescue_from :all (no block) built the whole backtrace and discarded it unless backtrace: true. #resolved_backtrace already reads it lazily from original_exception when asked. 500 from rescue_from :all +92.8%
#2932 Parser errors are not re-raised. rescue Grape::Exceptions::Base => e; raise e made Ruby read the backtrace (setup_exceptionrb_get_backtrace) on every malformed body. A matcher module in the rescue clause now keeps Grape errors out of it. malformed JSON body +28.7%; +29.8% on top of #2931 with rescue_from :all
#2934 ValidationErrors#full_messages is translated once. Each call re-ran an I18n lookup per error and per attribute, a few µs each. The README's error!({ messages: e.full_messages }, 400) paid for all of them twice. README handler, 1 / 2 errors +21.9% / +27.3% (YJIT +24.9% / +34.4%)
#2929 Three redundant steps on every error response: a header copy that Rack::Response makes again anyway, instance_exec on handlers that are already Methods, and re-encoding a message that is already valid UTF-8. 405 +4.9%, 401 +3.3%, 400/404/500 +2.3% to +2.6%

Two things sit behind most of these:

Benchmarks, this branch against master

Each row compares lib/ from master (a0462ac) with lib/ from this branch. Each round runs both in separate processes, one after the other, and alternates which goes first; the table shows the median of 7 rounds without JIT and 5 with YJIT. Every run warms up for 5,000 requests and then measures for 1.5 s. The µs/request columns are without JIT. Setup: Ruby 4.0.6 (arm64), Rack 3.2.7, Apple M2 Pro, macOS 26.6.

The noise floor is about ±1.6%: master against itself read −0.8% and −1.2% on two scenarios in the same harness, and the control group below spans −1.6% to +1.1%.

Versioning and content negotiation

request master µs/req branch µs/req no JIT YJIT
using: :header, Accept: application/vnd.acme-v1+json 12.5 7.9 +58.9% +80.6%
using: :header, Accept: */* 11.5 7.8 +48.0% +63.7%
using: :header, no Accept header 9.6 7.9 +22.1% +28.8%
two header versions of one path, first-mounted version 12.5 7.9 +59.7% +81.0%
two header versions of one path, second version (cascades) 65.0 58.8 +10.6% +13.9%
using: :accept_version_header 7.9 7.8 +1.2% +0.8%
using: :param 11.0 10.7 +3.0% +0.7%
default_format :json, Accept: application/json 9.3 6.8 +36.7% +51.6%
default_format :json, Accept: */* 7.2 6.9 +4.5% +5.4%

Params and validation

request master µs/req branch µs/req no JIT YJIT
GET, 3 typed query params 29.0 26.6 +9.1% +4.3%
form POST, 2 typed params 25.5 22.3 +14.2% +5.5%
JSON POST, 5 typed root params 27.7 23.7 +17.0% +9.9%
JSON POST, required nested Hash (regexp, values) 41.8 33.4 +25.0% +22.2%
JSON POST returning declared(params, include_missing: false) 37.5 34.5 +8.8% +12.6%
GET, pagination helper with values/defaults + mutually_exclusive 29.8 27.0 +10.6% +10.1%
GET, typed route_param 16.7 15.1 +10.3% +9.7%
GET, before/after/validation/finally filters on 3 namespace levels 16.7 15.7 +6.2% +8.0%
GET, before filter reading a header, typed route param 20.9 20.0 +4.2% +5.1%
JSON POST with a given block 22.3 21.1 +5.7% +10.4%
GET, Date/Time/DateTime params 49.2 46.3 +6.1% +5.7%
GET, types: [Integer, String] given a String + coerce_with 53.4 22.6 +135.8% +218.4%
JSON POST, requires :items, type: Array do, 1 element 33.0 24.0 +37.8% +26.9%
… 10 elements 82.3 59.8 +37.6% +37.6%
… 50 elements 287.1 202.6 +41.7% +41.4%
JSON POST, optional :items, type: Array do, 10 elements 82.5 65.5 +25.9% +29.1%

Error responses

request master µs/req branch µs/req no JIT YJIT
400, required param missing 52.1 49.5 +5.1% +4.6%
400, ?id=abc for an Integer 85.9 55.5 +54.7% +105.1%
400 via the README's rescue_from ValidationErrors + e.full_messages, 1 error 67.8 53.1 +27.8% +28.0%
… 2 errors 90.8 67.4 +34.7% +40.3%
400, malformed JSON body 51.0 38.1 +33.7% +55.8%
400, malformed JSON body, rescue_from :all 51.7 38.7 +33.6% +55.9%
401 via error! 14.2 13.5 +5.1% +5.5%
404 via a rescue_from block calling error! 16.3 16.1 +1.2% +1.8%
405, wrong method 18.7 17.8 +4.9% +7.4%
500 via rescue_from :all (default handler) 30.2 15.3 +97.7% +162.0%
500 via a rescue_from :all block calling error! 17.2 16.8 +2.0% +2.2%
422 via rescue_from ..., backtrace: true 17.7 17.4 +1.4% +2.3%

Controls (paths none of the changes target)

request master µs/req branch µs/req no JIT YJIT
GET, no params, path-versioned 7.9 8.0 -1.1% -1.0%
GET, no version 6.6 6.8 -1.6% +0.6%
GET, 5 namespaces deep 8.5 8.5 +0.4% -0.4%
POST, status + custom header 8.2 8.2 +0.0% +1.1%
DELETE, body false 7.0 7.1 -1.3% -1.1%
GET, redirect 8.1 8.1 -0.0% -0.2%
GET, cookies read and written 16.7 16.6 +0.6% -0.3%
GET, grape-entity present of 5 objects 44.3 44.1 +0.5% -1.2%
multipart file upload 184.3 183.8 +0.3% -0.7%
unrouted path 1.3 1.3 -0.3% +1.0%

A real application

grape-on-rack, booted in-process through its own bundle with each lib/ loaded ahead of the gem. 5 interleaved rounds of 2 s per endpoint, no JIT:

endpoint master i/s branch i/s delta
header_version (Accept: application/vnd.acme-v1+json) 57,372 79,481 +38.5%
get_json (a JSON-array query param) 19,065 22,714 +19.1%
header_key (route param) 51,149 53,893 +5.4%
ring_put (PUT with a typed param) 48,971 51,561 +5.3%
spline (POST with a typed param) 52,131 54,801 +5.1%
the other 11 endpoints (ping, entities in JSON and XML, path versioning, plain text, raise, …) −0.8% to +0.1%

Boot and memory

Both Accept tables are built once per list of media types and shared through Grape::Util::Cache, the way ContentTypes::MimeTypesCache already is, so their cost doesn't grow with the number of endpoints. Compiling an API, 3 runs each:

API master branch
500 header-versioned endpoints 6,166 KB retained, 149–153 ms 6,175 KB retained, 149–150 ms
500 content-negotiated endpoints 4,412 KB retained, 136–138 ms 4,413 KB retained, 137 ms

A table built per middleware instance instead cost +3.5 MB and +120 ms at 500 endpoints, which is why the tables are shared.

Behaviour

Besides the full suite, each change was checked against master with a response matrix. The combined branch was then re-run through them:

matrix responses against master
header versioning: 5 APIs (one version, two, strict, cascade: false, dotted vendor/version) × 30 Accept values (q-lists, wildcards, casings, whitespace, parameters, invalid bytes, binary) 150 identical apart from the api.* strings being frozen, the contract change below
content negotiation: 7 APIs × 25 Accept values × 3 paths (plain, .json, ?format=) 525 identical
coercion: 27 types, each coerced and strict, × 41 inputs, through Types.build_coercer 2,214 identical (1,640 are rejections, the changed path)
validation: every built-in validator, a custom one, nested Hashes 3 deep, with, wrong shapes, under the Hash and HWIA builders 104 identical
Array element scopes: required/optional, flat/nested, Array[JSON], fail_fast, given in an element, a Hash given an Array, malformed elements, both builders 164 identical
error responses: JSON/txt/XML/negotiated × error! shapes, rescue_from handler kinds, 405/404/OPTIONS × 3 Accept headers (status, headers, body) 228 identical
rescue_from :all with and without backtrace:/original_exception:, malformed bodies 30 identical; rendered backtraces have the same frames, and only line numbers in formatter.rb moved
rescue_from ValidationErrors handlers reading full_messages, message, as_json, to_json, errors, re-raising, in two locales 120 identical except the 5 responses of a handler that switches locale before reading full_messages (see below)

Contract change

The header versioner's api.* env strings (api.type, api.subtype, api.vendor, api.version, api.format) are frozen now, because requests sending the same Accept header share one parsed Grape::Util::MediaType. Code that altered one of them in place raises FrozenError. UPGRADING has an entry with the one-line fix (env['api.version'] = "#{env['api.version']}-beta" instead of <<). MediaType#initialize copies its arguments, so it never freezes a caller's Strings.

Behaviour notes

  • ValidationErrors#full_messages now returns the list as it was translated when the error was raised, which is also when #message is built. A handler that switched locale and then asked for the list used to get a half-translated mix: the new locale's format and attribute names around messages still in the old one. The list is handed out as a copy, so a caller changing it can't affect the next caller.
  • Apart from this and the frozen strings, the matrices above show no difference in status, headers or bodies. Rendered backtraces keep the same frames; only line numbers in the changed files move.

Specs added

Every spec below pins behaviour a mutation showed was unpinned: the mutation passed the whole suite. Apart from the frozen-strings one, each passes on master and on this branch.

  • versioner/header_spec.rb: the api.* env strings can't be altered by one request for the next. This one pins the contract change, so it fails on master.
  • params_scope_spec.rb:
    • An Array group handed a Hash reports only its own type error, and its members are not validated against the Hash.
    • A with group inside an Array records each element's index against the nearest Array ancestor.
    • A Hash group handed an Array reports only itself, not the optional Array group inside it.
  • validations_spec.rb: an optional Array scope skips its empty elements, and is not validated at all when every element is blank ([false, ' ']).
  • error_formatter/json_spec.rb (new): a UTF-8 message goes out as is; binary and malformed messages go out with the bad bytes replaced.
  • middleware/formatter_spec.rb: a parser raising something that is not a StandardError keeps propagating instead of becoming a 400.
  • exceptions/validation_errors_spec.rb: changing the array #full_messages returned doesn't change the next answer.

Not in this PR

Test plan

Supersedes #2922, #2923, #2924, #2925, #2926, #2927, #2928, #2929, #2930, #2931, #2932, #2933, #2934.

🤖 Generated with Claude Code

…d error responses

Thirteen request-path changes, each measured on its own and together:

Versioning and content negotiation
- Header versioner: answer the Accept headers most requests send (every
  declared media type, */*, none) from a table built once per list of
  media types and shared through Grape::Util::Cache, instead of running
  Rack::Utils.best_q_match and MediaType.parse per request. MediaType is
  now immutable, so the api.* env strings it writes are frozen
  (UPGRADING).
- Versioners read cascade/parameter/strict/vendor off instance variables
  instead of two Forwardable hops and two Data readers.
- Formatter: the same kind of shared table for Accept-negotiated formats
  when no format is pinned.

Params validation
- A required, dependency-free Hash scope (the root scope included) is
  validated without the attributes iterator, and skips should_validate?,
  which always answers true for it.
- So are the elements of an Array scope that is the only one iterating
  elements on such a chain, required or optional.
- The attributes iterator settles its per-scope state once per pass.
- qualifying_params returns early for scopes other than `given`.
- declared skips the renamed-params lookup when nothing is renamed.
- DryTypeCoercer uses dry-types' non-raising block form, so a rejected
  value no longer builds a re-raised backtrace.

Error responses
- The default rescue handler no longer reads the exception's backtrace
  unless one is asked for.
- Formatter lets a parser's Grape errors through without re-raising them.
- rack_response hands its fresh headers to Rack::Response as they are,
  Method rescue handlers are called directly, and ensure_utf8 returns a
  valid UTF-8 message as it is.
- ValidationErrors#full_messages is translated once instead of on every
  call.

Adds specs for behaviour the changes brought to light and nothing
pinned, each found by a mutation that passed the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the perf/hot-path-throughput branch from d226b82 to d6b0de7 Compare September 11, 2026 15:04
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

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