Speed up header versioning, content negotiation, params validation and error responses - #2936
Open
ericproulx wants to merge 1 commit into
Open
Speed up header versioning, content negotiation, params validation and error responses#2936ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
…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
force-pushed
the
perf/hot-path-throughput
branch
from
September 11, 2026 15:04
d226b82 to
d6b0de7
Compare
Danger ReportNo issues found. |
This was referenced Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
*/*.types: [Integer, String]+136% (+218%); a 400 for a mistyped Integer +55% (+105%).rescue_from :all+98% (+162%); a malformed JSON body +34% (+56%); the README'sfull_messageshandler +28% to +35% (+28% to +40%).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
version ..., using: :headerranRack::Utils.best_q_matchandMediaType.parseon every request. That took about 3.6 µs of a 12.4 µs request, becausebest_q_matchsplits both strings for every candidate. The answer depends only on the Accept header and the middleware'savailable_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 throughGrape::Util::Cache: a per-instance table cost +3.5 MB and +120 ms at 500 endpoints.Grape::Util::MediaTypeis now immutable.*/*+43.1%; no Accept +19.3%cascade,parameter,strictandvendoreach went through two Forwardable frames and two Data readers (~150 ns) for a value fixed at build.Accept: application/json.Accept: application/json+40.3% (YJIT +54.1%);*/*+5.3%Params validation
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.declaredPOST +6.5%requires/optional :items, type: Array do … endqualifies when it is the only element-iterating scope on such a chain. Optional scopes keep the empty-element skip andshould_validate?'s all-blank check. Stacked on #2927.qualifying_paramsreturns early for scopes other thangiven. Onlygivenever stores any, yet every nested scope looked them up twice per validator.declaredskips the renamed-params lookup when nothing is renamed. It built a fresh path key per declared param for a table that is empty withoutas:.declaredcall −11% (4.81 → 4.29 µs); +1.4% end to end, within noiseCoercionError.handlere-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. Everytypes: [Integer, String]param given a String pays this, as does every 400 for a mistyped value.types: [Integer, String]givenabc+114.7% (YJIT +195.1%); 400 for?id=abc+49.1% (YJIT +95.6%)Error responses
exception.backtrace.rescue_from :all(no block) built the whole backtrace and discarded it unlessbacktrace: true.#resolved_backtracealready reads it lazily fromoriginal_exceptionwhen asked.rescue_from :all+92.8%rescue Grape::Exceptions::Base => e; raise emade Ruby read the backtrace (setup_exception→rb_get_backtrace) on every malformed body. A matcher module in therescueclause now keeps Grape errors out of it.rescue_from :allValidationErrors#full_messagesis translated once. Each call re-ran an I18n lookup per error and per attribute, a few µs each. The README'serror!({ messages: e.full_messages }, 400)paid for all of them twice.Rack::Responsemakes again anyway,instance_execon handlers that are already Methods, and re-encoding a message that is already valid UTF-8.Two things sit behind most of these:
exception.backtraceor re-raising costs ~25 µs. That's why Coerce through dry-types' non-raising call #2930–Let a parser's Grape errors through the formatter without re-raising #2932 gain so much.best_q_matchand the attributes iterator's plumbing cost more than the work they guard, for inputs that are fixed when the API is built.Benchmarks, this branch against master
Each row compares
lib/from master (a0462ac) withlib/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
using: :header,Accept: application/vnd.acme-v1+jsonusing: :header,Accept: */*using: :header, no Accept headerusing: :accept_version_headerusing: :paramdefault_format :json,Accept: application/jsondefault_format :json,Accept: */*Params and validation
regexp,values)declared(params, include_missing: false)values/defaults +mutually_exclusiveroute_parambeforefilter reading a header, typed route paramgivenblockDate/Time/DateTimeparamstypes: [Integer, String]given a String +coerce_withrequires :items, type: Array do, 1 elementoptional :items, type: Array do, 10 elementsError responses
?id=abcfor an Integerrescue_from ValidationErrors+e.full_messages, 1 errorrescue_from :allerror!rescue_fromblock callingerror!rescue_from :all(default handler)rescue_from :allblock callingerror!rescue_from ..., backtrace: trueControls (paths none of the changes target)
status+ custom headerbody falseredirectpresentof 5 objectsA 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:header_version(Accept: application/vnd.acme-v1+json)get_json(a JSON-array query param)header_key(route param)ring_put(PUT with a typed param)spline(POST with a typed param)raise, …)Boot and memory
Both Accept tables are built once per list of media types and shared through
Grape::Util::Cache, the wayContentTypes::MimeTypesCachealready is, so their cost doesn't grow with the number of endpoints. Compiling an API, 3 runs each: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:
strict,cascade: false, dotted vendor/version) × 30 Accept values (q-lists, wildcards, casings, whitespace, parameters, invalid bytes, binary)api.*strings being frozen, the contract change below.json,?format=)Types.build_coercerwith, wrong shapes, under the Hash and HWIA buildersArray[JSON],fail_fast,givenin an element, a Hash given an Array, malformed elements, both builderserror!shapes,rescue_fromhandler kinds, 405/404/OPTIONS × 3 Accept headers (status, headers, body)rescue_from :allwith and withoutbacktrace:/original_exception:, malformed bodiesformatter.rbmovedrescue_from ValidationErrorshandlers readingfull_messages,message,as_json,to_json,errors, re-raising, in two localesfull_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 parsedGrape::Util::MediaType. Code that altered one of them in place raisesFrozenError. UPGRADING has an entry with the one-line fix (env['api.version'] = "#{env['api.version']}-beta"instead of<<).MediaType#initializecopies its arguments, so it never freezes a caller's Strings.Behaviour notes
ValidationErrors#full_messagesnow returns the list as it was translated when the error was raised, which is also when#messageis 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.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: theapi.*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:withgroup inside an Array records each element's index against the nearest Array ancestor.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 aStandardErrorkeeps propagating instead of becoming a 400.exceptions/validation_errors_spec.rb: changing the array#full_messagesreturned doesn't change the next answer.Not in this PR
declaredon non-Hash Array elements) is a bug fix, not a performance change.versions of one path, told apart by cascading) is still 7.6× slower for a non-first version. It builds, raises and renders anInvalidVersionHeaderper request before cascading. Every shortcut would be observable, so that needs a design decision first.Test plan
Supersedes #2922, #2923, #2924, #2925, #2926, #2927, #2928, #2929, #2930, #2931, #2932, #2933, #2934.
🤖 Generated with Claude Code