Skip to content

Answer 500 when an error response cannot be rendered - #2840

Open
ericproulx wants to merge 1 commit into
masterfrom
fix/error-rendering-failsafe
Open

Answer 500 when an error response cannot be rendered#2840
ericproulx wants to merge 1 commit into
masterfrom
fix/error-rendering-failsafe

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

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 — the failure happens after the handler has already returned its payload.

A rescue_from handler echoing request-derived bytes was enough to hit it:

class API < Grape::API
  format :json
  rescue_from(Missing) { |e| error!({ error: 'not_found', detail: e.message }, 404) }
  route_param(:id) { get { raise Missing, "no such thing: #{params[:id]}" } }
end

GET /%C3%28 — an invalid UTF-8 byte in the path — made the JSON formatter raise JSON::GeneratorError, and the request died instead of being answered.

scenario before after
formatter chokes on the payload JSON::GeneratorError escapes the stack 500 {"error":"Internal Server Error"} in the API format
formatter broken outright RuntimeError escapes the stack 500 text/plain 500 Internal Server Error
no rescue_from matches the exception propagates propagates (unchanged)
format has no error formatter 404 via the existing throw :error unchanged

Approach

Guard the rendering in error_response. 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 call format_message directly rather than re-entering error_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 through framework_default was also wrong — that goes back through run_rescue_handler, whose failure path redispatches into framework_default again, turning a clean RuntimeError into SystemStackError.

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

  • Four new examples in error_spec.rb covering both fallback tiers, the env exposure, and the invariant that an unrescued exception still propagates; verified the three behavioural ones fail without the lib/ change.
  • Full RSpec suite passes locally (2547 examples, 0 failures).
  • RuboCop clean.
  • CI green.

🤖 Generated with Claude Code

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
ericproulx force-pushed the fix/error-rendering-failsafe branch from 4e908ce to 9d0faa6 Compare July 29, 2026 21:06
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx requested a review from dblock July 29, 2026 21:11
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.

1 participant