Raise from error! instead of throwing :error - #3023
Merged
Merged
Conversation
ericproulx
force-pushed
the
fix/error-raises
branch
2 times, most recently
from
October 1, 2026 10:29
988103e to
700891c
Compare
Danger ReportNo issues found. |
ericproulx
marked this pull request as ready for review
October 1, 2026 10:37
ericproulx
force-pushed
the
fix/error-raises
branch
2 times, most recently
from
October 1, 2026 20:50
0ee4a3b to
7531e6b
Compare
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
force-pushed
the
fix/error-raises
branch
from
October 1, 2026 20:53
7531e6b to
f0243cb
Compare
dblock
approved these changes
Oct 3, 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.
error!threw:error, and a throw is not an exception:error!insidetransactionsaved 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: Usingreturn,breakorthrowto exit a transaction block #2197; on grape 4.0.1 and activerecord 8.1.4, the request answers409and leaves the row behind.Async { error!('nope', 422) }.waitbecame anUncaughtThrowErrorand answered 500.Change
error!raisesGrape::Exceptions::Halt, aStandardErrorcarrying theErrorResponsethe throw carried (#response, also#statusand#headers).Middleware::Errorrescues it inside itscatch(:error)blocks, in#respondand in#run_rescue_handler, so it is rendered where a thrown error is and never reaches arescue_fromhandler.Three constraints shaped it:
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.Grape::Exceptions::Base. Raised as one,error!reachesrescue_from :allorrescue_from StandardError, whose handler then answers instead of the statuserror!was given.Grape::Exceptions::Validationhas, soraiseskips capturing one.throw :errorfrom middleware andrescue_fromhandlers keeps working. Grape's own throw sites (formatter, versioners) are left for a follow-up.ErrorResponseas a plain objectRaising costs more than throwing, so this also makes building an error response cheaper.
ErrorResponseis now a plain frozen class instead of aData. Every error response builds at least two: the payload, then the copyMiddleware::Error#error_responsemakes with the defaults filled in, which went throughData#with.Data's constructor takes its keywords as a Hash and then validates it:DatanewData#withinitializeIt answers only what Grape reads off it: keyword construction with every field optional, and its readers.
error_responsebuilds its copy withnew, carryingoriginal_exceptionover 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_keysand positional construction.Haltspells 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:
rescue => eorrescue StandardErrorarounderror!now catches it.catch(:error)arounderror!gets the exception instead of the payload.endpoint_run.grape,endpoint_render.grapeandendpoint_run_filters.grapepayloads carry:exceptionand:exception_objectwhenerror!ends the request.Grape::Exceptions::ErrorResponseis no longer aData. 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:error!error!40 frames downerror!with a notifications subscriberrescue_from :allblock callingerror!rescue_from :alldefault handlerthrow)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%. TheErrorResponsechange saves about 1 µs on every error response. Soerror!comes out ahead, and responses that never raise keep the saving outright.With a notifications subscriber, ActiveSupport rescues and re-raises the
Haltaround 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.rescue_from :allorrescue_from StandardError.StandardError.ErrorResponseis frozen. The#with,#==and#hashspecs went with the methods.Each mutation below was caught by at least one spec:
error!back tothrowGrape::Exceptions::Basewith the middleware untouchedHaltrescue in#run_rescue_handlerHalt < ExceptionErrorResponseunfrozenerror_responsenot carryingoriginal_exceptionover (11 existing specs fail)Fixes #2197
🤖 Generated with Claude Code