Answer 500 when an error response cannot be rendered - #2840
Open
ericproulx wants to merge 1 commit into
Open
Conversation
Grape::Middleware::Error#call! renders the error response from inside its own
rescue clause, so that clause never covered the rendering. An error formatter
that raised on the payload it was handed took the exception straight out
through every middleware above Grape and into the application server —
`rescue_from :all` did not help, because the failure happened after the
handler had already returned.
A rescue_from handler echoing request-derived bytes was enough to hit it:
rescue_from(Missing) { |e| error!({ detail: e.message }, 404) }
with an invalid UTF-8 byte in the path, the JSON formatter raised
JSON::GeneratorError and the request died rather than being answered.
Guard the rendering in error_response. On failure, first retry the API's own
format with the framework's InternalServerError, whose message is a static
string and so cannot be what defeated the first attempt; if that fails too — a
formatter broken outright rather than one payload it choked on — answer
without a formatter at all. Both attempts call format_message directly instead
of re-entering error_response, so the fallback cannot recurse.
The guard sits on the rendering rather than around run_rescue_handler on
purpose. Wrapping the handler call too would have swallowed things that must
keep propagating, the deprecation raised when a handler returns a Hash among
them.
Exceptions that no rescue_from matches still propagate unchanged; only
rendering failures are caught. The exception that defeated rendering is put on
env['grape.exception'], the key the existing unrecognised-error path already
uses, so upstream loggers can still observe it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
fix/error-rendering-failsafe
branch
from
July 29, 2026 21:06
4e908ce to
9d0faa6
Compare
Danger ReportNo issues found. |
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.
Summary
Grape::Middleware::Error#call!renders the error response from inside its ownrescueclause, so that clause never covered the rendering. An error formatter that raised on the payload it was handed took the exception straight out through every middleware above Grape and into the application server.rescue_from :alldid not help — the failure happens after the handler has already returned its payload.A
rescue_fromhandler echoing request-derived bytes was enough to hit it:GET /%C3%28— an invalid UTF-8 byte in the path — made the JSON formatter raiseJSON::GeneratorError, and the request died instead of being answered.JSON::GeneratorErrorescapes the stack500{"error":"Internal Server Error"}in the API formatRuntimeErrorescapes the stack500text/plain500 Internal Server Errorrescue_frommatches the exception404via the existingthrow :errorApproach
Guard the rendering in
error_response. On failure, first retry the API's own format with the framework'sInternalServerError— its message is a static string, so it cannot be what defeated the first attempt — and if that fails too, answer without a formatter at all. Both attempts callformat_messagedirectly rather than re-enteringerror_response, so the fallback cannot recurse.The guard sits on the rendering rather than around
run_rescue_handler. I tried the wider placement first; it swallowed things that must keep propagating, including the deprecation raised when a handler returns a Hash (error_spec.rb:95). Routing the retry throughframework_defaultwas also wrong — that goes back throughrun_rescue_handler, whose failure path redispatches intoframework_defaultagain, turning a cleanRuntimeErrorintoSystemStackError.Backward compatibility
UPGRADING entry added. Code that observed these exceptions by letting them propagate — an error tracker mounted as Rack middleware above Grape — no longer sees them raised. The exception is exposed on
env['grape.exception']instead, the same key the existing unrecognised-error path already uses.Test plan
error_spec.rbcovering both fallback tiers, the env exposure, and the invariant that an unrescued exception still propagates; verified the three behavioural ones fail without thelib/change.🤖 Generated with Claude Code