Skip to content

Raise from error! instead of throwing :error - #3023

Merged
ericproulx merged 1 commit into
masterfrom
fix/error-raises
Oct 3, 2026
Merged

ericproulx merged 1 commit into
masterfrom
fix/error-raises

Conversation

@ericproulx

@ericproulx ericproulx commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

error! threw :error, and a throw is not an exception:

  • Active Record rolls a transaction back only when an exception leaves its block, and commits it on any other exit. So error! inside transaction saved what the block had written while the client was told the request failed. Since Rails 7.2 this happens without a warning. Reproduction in DEPRECATION WARNING: Using return, break or throw to exit a transaction block #2197; on grape 4.0.1 and activerecord 8.1.4, the request answers 409 and leaves the row behind.
  • A throw cannot leave the fiber it was thrown in. Under Falcon, Async { error!('nope', 422) }.wait became an UncaughtThrowError and answered 500.

Change

error! raises Grape::Exceptions::Halt, a StandardError carrying the ErrorResponse the throw carried (#response, also #status and #headers). Middleware::Error rescues it inside its catch(:error) blocks, in #respond and in #run_rescue_handler, so it is rendered where a thrown error is and never reaches a rescue_from handler.

Three constraints shaped it:

  • A StandardError. async treats any other exception leaving a task as fatal and stops its reactor, which takes every in-flight request on the worker with it.
  • Not a Grape::Exceptions::Base. Raised as one, error! reaches rescue_from :all or rescue_from StandardError, whose handler then answers instead of the status error! was given.
  • A preset empty backtrace, as Grape::Exceptions::Validation has, so raise skips capturing one.

throw :error from middleware and rescue_from handlers keeps working. Grape's own throw sites (formatter, versioners) are left for a follow-up.

ErrorResponse as a plain object

Raising costs more than throwing, so this also makes building an error response cheaper.

ErrorResponse is now a plain frozen class instead of a Data. Every error response builds at least two: the payload, then the copy Middleware::Error#error_response makes with the defaults filled in, which went through Data#with. Data's constructor takes its keywords as a Hash and then validates it:

interpreter YJIT
Data new ~460 ns ~415 ns
Data#with ~710 ns ~675 ns
plain class with a keyword initialize ~120 ns ~53 ns

It answers only what Grape reads off it: keyword construction with every field optional, and its readers. error_response builds its copy with new, carrying original_exception over explicitly. It stays frozen, as the 4.0 UPGRADING entry promised.

Nothing in Grape compared, hashed, converted or pattern-matched one, so the rest of Data's interface is dropped, with an UPGRADING entry: #with, ==/eql?/hash, #to_h, members, deconstruct/deconstruct_keys and positional construction.

Halt spells its keywords out instead of forwarding ** for the same reason: 404 → 274 ns in the interpreter, 288 → 124 ns with YJIT.

Breaking

Covered in UPGRADING:

  • A rescue => e or rescue StandardError around error! now catches it.
  • catch(:error) around error! gets the exception instead of the payload.
  • endpoint_run.grape, endpoint_render.grape and endpoint_run_filters.grape payloads carry :exception and :exception_object when error! ends the request.
  • Grape::Exceptions::ErrorResponse is no longer a Data. It has no #with, value equality, #to_h, members, positional construction or pattern matching.

Performance

benchmark-ips on API.call(env), Ruby 4.0.7, master and this branch run alternately. Each figure is the mean of two rounds, which agree:

request interpreter YJIT
200 210k → 215k (noise) 446k → 438k (noise)
error! 114k → 123k (+7%) 205k → 251k (+23%)
error! 40 frames down 93.6k → 102.6k (+10%) 190k → 226k (+19%)
error! with a notifications subscriber 55.4k → 55.4k 105k → 106k
rescue_from :all block calling error! 87.6k → 94.0k (+7%) 162k → 190k (+17%)
rescue_from :all default handler 99.1k → 106.9k (+8%) 175k → 210k (+20%)
formatter 415 (throw) 118k → 138k (+16%) 204k → 259k (+27%)
validation 400 25.1k → 26.4k (+5%) 53.8k → 56.8k (+6%)

On its own, raising instead of throwing costs about 0.8 µs per error! in the interpreter and 0.35 µs with YJIT, about 8%. The ErrorResponse change saves about 1 µs on every error response. So error! comes out ahead, and responses that never raise keep the saving outright.

With a notifications subscriber, ActiveSupport rescues and re-raises the Halt around each instrumented block, which uses up the gain: parity. The preset empty backtrace keeps that re-raise cheap; without it, the deep case took a further 12–13% longer.

Tests

New specs:

  • error! leaves a transaction-shaped block as an exception, so the block rolls back.
  • It answers from a fiber the route resumes.
  • It is not handed to rescue_from :all or rescue_from StandardError.
  • It raises a StandardError.
  • ErrorResponse is frozen. The #with, #== and #hash specs went with the methods.

Each mutation below was caught by at least one spec:

  • error! back to throw
  • raising Grape::Exceptions::Base with the middleware untouched
  • no Halt rescue in #run_rescue_handler
  • Halt < Exception
  • ErrorResponse unfrozen
  • error_response not carrying original_exception over (11 existing specs fail)

Fixes #2197

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the fix/error-raises branch 2 times, most recently from 988103e to 700891c Compare October 1, 2026 10:29
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx marked this pull request as ready for review October 1, 2026 10:37
@ericproulx
ericproulx force-pushed the fix/error-raises branch 2 times, most recently from 0ee4a3b to 7531e6b Compare October 1, 2026 20:50
error! threw :error, and a throw is not an exception. Active Record
commits a transaction that a throw leaves, so error! inside a
transaction saved what the block had written while the client was told
the request failed. A throw cannot leave the fiber it was thrown in
either, so error! inside an Async task answered 500.

error! now raises Grape::Exceptions::Halt, a StandardError carrying the
same ErrorResponse. The error middleware takes it inside its catch, so
it is rendered where a thrown error is and never reaches rescue_from.
Its backtrace is preset empty, as Validation's is, so raise skips
capturing one.

Raising costs more than throwing, so ErrorResponse is now a plain frozen
class rather than a Data. Every error response builds at least two of
them, and Data's constructor takes its keywords as a Hash it then
validates: about 450 ns each and 700 ns for the copy #with made, against
about 120 ns. It answers only what Grape reads off it, its readers, and
stays frozen; error_response builds its copy with new. Nothing compared,
hashed, converted or pattern-matched one, so the rest of Data's
interface is dropped, as UPGRADING says. Halt spells its keywords out
for the same reason. With both, an error! response is at least as fast
as before, and other error responses are faster.

throw :error from middleware and rescue_from handlers keeps working.

Fixes #2197

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ericproulx
ericproulx merged commit 35916a8 into master Oct 3, 2026
70 checks passed
@ericproulx
ericproulx deleted the fix/error-raises branch October 3, 2026 23:04
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.

DEPRECATION WARNING: Using return, break or throw to exit a transaction block

2 participants