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: |
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
)
endabad865 to
395aa2c
Compare
|
Thanks! Took all three. The layering point. I don't think you're overthinking it — it's the right question — but rescue Exception => e # rubocop:disable Lint/RescueExceptionIt rescues The scope is narrower than "we rescue Opting out. Added Grape.configure do |config|
config.raise_rendering_errors = true
endDocumented in the README and UPGRADING, with a spec asserting the The refactor. Applied — it's better than what I had. Splitting render_response(payload) # → record_rendering_failure + render_failsafe_responseAlso rebased #2855 on top, so it's back to one call site plus a spec. |
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.
Grape.config.raise_rendering_errors opts back out: an application that would
rather have the exception propagate out of the middleware stack, as it did
before, can have that. Off by default.
Rendering is split into render_response / record_rendering_failure /
render_failsafe_response so each tier is named rather than nested in a begin
block inside error_response.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
395aa2c to
0c743e6
Compare
…2855) safe_default is the unrecognised-error path: an exception raised inside a rescue_from block that nothing else handles. Grape answers a generic 500 there rather than letting it propagate, and records the exception on env['grape.exception']. That key is Grape's own and no error tracker reads it. Because the exception never propagates, a tracker mounted above Grape has nothing to catch either, so a bug in a rescue_from block has always been able to turn into a silent 500 — the request is answered, the log says nothing, and the tracker never fires. 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']. Extracted as expose_exception, now shared with the rendering failsafe. The deliberate silence of this path is left alone: unlike the failsafe, a rescue_from :internal_grape_exceptions handler can still own the response here, so the logging stays the application's call. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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. On failure, first retry the API's own format with the framework's
InternalServerError— 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.Rendering is split so each tier is named rather than nested in a
beginblock insideerror_response: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.Opting out
Grape.config.raise_rendering_errorskeeps the old behaviour, alongside the existinglint/warn_on_helper_overridessettings:With it on the failsafe never runs: the exception is re-raised untouched, so neither env key is set and nothing is written to
rack.errors— whatever caught it before catches it again.Backward compatibility
UPGRADING entry added, including the opt-out. 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.README documents the behaviour under Exception Handling, with
raise_rendering_errorslisted in Configuration.Test plan
error_spec.rbcovering both fallback tiers,grape.exception,rack.exception,rack.errors, the opt-out, and the invariant that an unrescued exception still propagates; verified the behavioural ones fail without thelib/change.Follow-up: #2855 applies the same
rack.exceptionexposure tosafe_default, the pre-existing unrecognised-error path, which had the identical blind spot. Split out of this PR at review request; based on this branch since it needsGrape::Env::RACK_EXCEPTION.🤖 Generated with Claude Code