Skip to content

Skip three redundant steps on every error response - #2929

Closed
ericproulx wants to merge 1 commit into
masterfrom
perf/error-response-copies
Closed

ericproulx wants to merge 1 commit into
masterfrom
perf/error-response-copies

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

Three steps every error response took for nothing:

  • Middleware::Error#rack_response copied the headers into a Grape::Util::Header only to hand them to Rack::Response.new, which copies its argument into a Headers of its own on every supported Rack: Rack 2.2's HeaderHash[] adopts only a Hash that already is a HeaderHash, and 3.x always builds a fresh Rack::Headers. Copy response headers with merge! instead of merge #2911 kept this copy on the grounds that callers write into what they are given, but here the writer is Rack::Response, which writes into its own copy, and all three callers build the Hash fresh for this response. The copy Copy response headers with merge! instead of merge #2911 kept in API::Instance#call, which can be handed a mounted app's frozen Hash, stays.
  • run_rescue_handler ran every handler through instance_exec, turning a Method into a Proc first. The middleware's own handlers (method(:error_response), method(:default_rescue_handler)) and the endpoint's for a with: Symbol are Methods, already bound to a receiver that instance_exec cannot change, so they are now called as they are. Blocks still run as the endpoint.
  • ErrorFormatter::Json#ensure_utf8 re-encoded every String message. For one that already is valid UTF-8, encode only returns a copy, for about 260 ns. Such a message is now returned as it is; anything else is converted as before.

Benchmarks

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

response delta
405 (raised MethodNotAllowed) +4.9%
401 via error! +3.3%
400 validation error +2.6%
404 via a rescue_from block calling error! +2.6%
500 via rescue_from :all +2.3%
successful GET (control) +0.5% (noise)

Behaviour

Byte-identical to master (status, headers and body) over a 228-case matrix: JSON, txt, XML and content-negotiated APIs × error! with a String, a Hash, custom headers, an HTML content type, and frozen, binary, malformed and US-ASCII messages; rescue_from with a block, a with: Symbol, an arity-0 block, a handler that returns junk, one that raises again and one that returns a Rack::Response; unhandled exceptions, validation errors, 405, OPTIONS and 404; each with three Accept headers.

Missing spec, added

What ensure_utf8 does with a message that is not valid UTF-8 had no spec: dropping the encoding half of the new check passed the whole suite while a binary message went out as raw bytes (json 3 warns this "will raise an encoding error"). spec/grape/error_formatter/json_spec.rb pins the three cases: UTF-8 as it is, binary converted with the unmappable bytes replaced, and malformed UTF-8 with the invalid bytes replaced. It passes before and after this change.

Test plan

  • Full RSpec suite passes locally.
  • RuboCop clean.
  • Mutation-checked: calling every Method handler with the error fails 2 specs (arity-0 with: handlers); skipping the validity half of the UTF-8 check fails 2; skipping the encoding half fails 1 (the new spec).
  • CI green.

🤖 Generated with Claude Code

- `Middleware::Error#rack_response` built a `Grape::Util::Header` from
  the headers only to hand it to `Rack::Response.new`, which copies its
  argument into a Headers of its own on every supported Rack. Rack 2.2's
  `HeaderHash[]` adopts only a Hash that already is one, and 3.x always
  builds a fresh `Rack::Headers`. #2911 kept this copy because callers
  write into what they are given, but here the writer is Rack::Response,
  which writes into its own copy, and all three callers build the Hash
  fresh for this response. The copy #2911 kept in `API::Instance#call`,
  which can be handed a mounted app's frozen Hash, stays.
- `run_rescue_handler` ran every handler through `instance_exec`, turning
  a Method into a Proc first. The middleware's own handlers and the
  endpoint's for a `with:` Symbol are Methods, already bound to a
  receiver that `instance_exec` cannot change, so they are now called as
  they are. Blocks still run as the endpoint.
- `ErrorFormatter::Json#ensure_utf8` re-encoded every String message,
  which for one that already is valid UTF-8 only copies it, for about
  260 ns. Such a message is now returned as it is; anything else is
  converted as before.

What `ensure_utf8` does with a message that is not valid UTF-8 had no
spec; dropping the encoding half of the new check passed the suite. The
new spec pins all three cases: UTF-8, binary and malformed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the perf/error-response-copies branch from 37f57c8 to d747e80 Compare September 10, 2026 20:11
@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.

@ericproulx ericproulx closed this Sep 11, 2026
@ericproulx
ericproulx deleted the perf/error-response-copies 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