Skip to content

Let a parser's Grape errors through the formatter without re-raising - #2932

Closed
ericproulx wants to merge 1 commit into
masterfrom
perf/parser-errors-without-reraise
Closed

Let a parser's Grape errors through the formatter without re-raising#2932
ericproulx wants to merge 1 commit into
masterfrom
perf/parser-errors-without-reraise

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

Formatter#read_rack_input answers a parser's StandardErrors with a 400, and let Grape's own errors go on to the error middleware by rescuing them first: rescue Grape::Exceptions::Base => e; raise e. Re-raising an exception makes Ruby read its backtrace (setup_exception calls rb_get_backtrace, which builds the lazily captured backtrace as Strings), and at request depth that is the dearest part of the error: about 25 µs. It was paid by every malformed body, since the built-in parsers report one as InvalidMessageBody.

A matcher module in the rescue clause now selects the errors to answer here, StandardErrors that are not Grape errors, so a Grape error is never rescued at this frame and travels on as it is. Unwinding past a frame that does not rescue it costs nothing extra.

Benchmarks

Median of 7 interleaved subprocess rounds, Ruby 4.0.6, no JIT, POST with a malformed JSON body:

API compared against delta
no rescue_from master +28.7%
rescue_from :all master + #2931 +29.8%
rescue_from :all master −0.6%

The last row is not a contradiction. A backtrace is built once and then cached, so the cost is paid by whichever step reads it first. On an API with rescue_from :all, master's default handler also reads the backtrace eagerly (fixed by #2931), so either change alone just moves the cost to the other step, and the gain lands once both are in. Without rescue_from, nothing else reads it and this change alone gets it all.

Missing spec, added

A parser raising something that is not a StandardError, such as an Interrupt or a SystemExit, keeps propagating rather than being answered as a 400. Nothing pinned that: dropping the StandardError bound from the matcher passed the whole suite. The new spec raises a NotImplementedError from a custom parser; it passes before and after this change.

Behaviour

Byte-identical to master over a 30-case matrix of rescue_from :all APIs, with and without backtrace: true and original_exception: true, in JSON, txt and XML, including malformed bodies. The rendered backtraces of InvalidMessageBody are identical once paths are normalized, so re-raising never altered them.

Test plan

  • Full RSpec suite passes locally.
  • RuboCop clean.
  • Mutation-checked: the matcher also taking Grape errors fails 4 specs; the matcher dropping its StandardError bound fails 1 (the new spec).
  • CI green.

🤖 Generated with Claude Code

`Formatter#read_rack_input` answers a parser's StandardErrors with a 400,
and let Grape's own errors go on to the error middleware by rescuing them
first, `rescue Grape::Exceptions::Base => e; raise e`. Re-raising an
exception makes Ruby read its backtrace, which builds it as Strings, and
at request depth that is the dearest part of the error: about 25 µs. It
was paid by every malformed body, since the built-in parsers report one
as `InvalidMessageBody`.

A matcher module in the `rescue` clause now selects the errors to answer
here, StandardErrors that are not Grape errors, so a Grape error is
never rescued at this frame and travels on as it is.

A parser raising something that is not a StandardError, such as an
Interrupt or a SystemExit, keeps propagating rather than being answered
as a 400. Nothing pinned that; it now has a spec, which passes before and
after this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the perf/parser-errors-without-reraise branch from 11287b5 to 4a5844b Compare September 11, 2026 08:45
@github-actions

github-actions Bot commented Sep 11, 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
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