Answer 500 when an error response cannot be rendered - #2840
Conversation
4e908ce to
9d0faa6
Compare
Danger ReportNo issues found. |
9d0faa6 to
4b7a3b3
Compare
dblock
left a comment
There was a problem hiding this comment.
If Grape is mounted inside another stack like Rails or Sinatra, which it usually is, this causes a different 500 than before. This makes me a little uneasy. Is there a strong argument to change the escape behavior?
4b7a3b3 to
36b084b
Compare
|
You were right to push on this, and the concrete cost is worse than "a different 500": mounted in Rails, the exception stopped being reported. I had it on
So a rendering failure Sentry used to report became an unremarkable 500. That's a real regression and it's fixed now: the exception is also published on On whether there's a strong argument for changing the escape behavior — two: The trigger is client-controlled. The repro is an invalid UTF-8 byte in the path, echoed through Rails already does exactly this. Worth noting the swallow policy isn't new either: One thing to flag, since it's scope I added after your review: |
36b084b to
e0bd778
Compare
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. This is the
shape ActionDispatch::ShowExceptions#render_exception already has in Rails,
down to the text/plain last resort.
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.
Swallowing an exception must not make it invisible. Grape put the exception on
env['grape.exception'], but that is a Grape-private key no tracker reads, so a
rendering failure that Sentry used to report as a raised exception would have
become an unremarkable 500. Publish it on env['rack.exception'] as well — the
convention for an exception that was handled rather than raised, which
sentry-ruby collects as `env['rack.exception'] || env['sinatra.error']` — and
write the failure to rack.errors so it reaches the server log even with no
tracker installed. Rails likewise writes to $stderr from its failsafe branch:
deferring the logging to the application is not an option here, since the
application's own error rendering is precisely what broke.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e0bd778 to
abad865
Compare
dblock
left a comment
There was a problem hiding this comment.
This still makes me uneasy. Grape is just a rack middleware, by attempting to rescue StandardError we abort the rack middleware stack as it's intended. But maybe I'm overthinking it?
Is there an easy way to allow for the old behavior?
Btw, copilot suggests unwrapping this a little like so:
def render_response(payload)
rack_response(payload.status, payload.headers, format_message(payload))
rescue StandardError => error
record_rendering_failure(error)
render_failsafe_response
end
def render_failsafe_response
headers = { Rack::CONTENT_TYPE => content_type }
payload = failsafe_payload(headers)
rack_response(FAILSAFE_STATUS, headers, format_message(payload))
rescue StandardError
rack_response(
FAILSAFE_STATUS,
{ Rack::CONTENT_TYPE => FAILSAFE_CONTENT_TYPE },
FAILSAFE_MESSAGE
)
end
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.The trigger is client-controlled, which is what makes this more than a cosmetic issue. A
rescue_fromhandler echoing request-derived bytes is enough:GET /%C3%28— an invalid UTF-8 byte in the path — makes the JSON formatter raiseJSON::GeneratorError, and the request dies instead of being answered. Any API whose handler interpolates request-derived text can be made to defeat its own error handling on demand.JSON::GeneratorErrorescapes the stack500{"error":"Internal Server Error"}in the API formatRuntimeErrorescapes the stack500text/plain500 Internal Server Errorrescue_frommatches the exceptionApproach
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.This is the shape Rails already has.
ActionDispatch::ShowExceptions#render_exceptiontries the application's own error rendering and falls back to a hardcoded[500, text/plain, "500 Internal Server Error\n..."]when that rendering is itself broken — same two tiers, same status, same content type, and it calls the second tier "failsafe" too.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.Swallowing an exception must not make it invisible
Answering 500 means an exception that used to propagate no longer does, and Grape is usually mounted inside a host stack that was reporting it. Setting
env['grape.exception']is not enough on its own: that key is Grape-private and nothing in the ecosystem reads it, so a rendering failure Sentry used to report as a raised exception would have quietly become an unremarkable 500.So the exception is also published on
env['rack.exception']— the convention for an exception that was handled rather than raised, which sentry-ruby collects asenv['rack.exception'] || env['sinatra.error']— and the failure is written torack.errors, so it reaches the server log even with no tracker installed. Measured with a tracker middleware mounted above Grape, using the repro above:raised: JSON::GeneratorError500 text/html, the host's error pagerack.exception: JSON::GeneratorError500 application/jsonRails writes to
$stderrfrom its failsafe branch for the same reason: deferring the logging to the application is not an option there, because the application's own error rendering is precisely what broke.This also changes one pre-existing path.
safe_default— an exception raised inside arescue_fromblock that nothing else handles — had the identical blind spot, setting onlygrape.exception. It now setsrack.exceptionas well. Fixing one of two identical paths seemed worse than fixing both, but it does mean this PR touches shipped behaviour beyond the rendering failure. Its deliberate silence is left alone, since there arescue_from :internal_grape_exceptionshandler can still own the response.Backward compatibility
UPGRADING entry added. Tests asserting
expect { get '/' }.to raise_erroron a rendering failure no longer see it raised; error trackers above Grape keep reporting it viarack.exceptionwith no application change. Exceptions that norescue_frommatches still propagate exactly as before.Test plan
error_spec.rbcovering both fallback tiers,grape.exception,rack.exception,rack.errors, and the invariant that an unrescued exception still propagates; verified the behavioural ones fail without thelib/change.🤖 Generated with Claude Code