Skip to content

Commit e0bd778

Browse files
ericproulxclaude
andcommitted
Answer 500 when an error response cannot be rendered
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. The unrecognised-error path (safe_default) gains rack.exception too; it had the same blind spot. Its deliberate silence is left alone, because there a rescue_from :internal_grape_exceptions handler can still own the response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 79c91a8 commit e0bd778

5 files changed

Lines changed: 167 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
* [#2838](https://github.com/ruby-grape/grape/pull/2838): Reject request params nested in more arrays than the block declares, instead of silently unwrapping them and passing validation, and report `type: Array[JSON]` errors against the element that failed - [@ericproulx](https://github.com/ericproulx).
6060
* [#2842](https://github.com/ruby-grape/grape/pull/2842): Warn at definition time when a `rescue_from` class is already covered by one registered earlier in the same scope, since the later handler never runs - [@ericproulx](https://github.com/ericproulx).
6161
* [#2853](https://github.com/ruby-grape/grape/pull/2853): Restore, behind a deprecation warning, the trailing positional options Hash of `requires`, `optional` and `use`, which #2618 turned into a parameter name - [@ericproulx](https://github.com/ericproulx).
62+
* [#2840](https://github.com/ruby-grape/grape/pull/2840): Answer 500 instead of letting an exception escape the middleware stack when an error response cannot be rendered (see UPGRADING) - [@ericproulx](https://github.com/ericproulx).
63+
* [#2840](https://github.com/ruby-grape/grape/pull/2840): Expose an exception Grape swallowed on `rack.exception` and write it to `rack.errors`, so error trackers keep reporting a failed error rendering and an unhandled exception raised inside a `rescue_from` block - [@ericproulx](https://github.com/ericproulx).
6264
* Your contribution here.
6365

6466
### 3.3.5 (2026-07-30)

UPGRADING.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,27 @@ Upgrading Grape
33

44
### Upgrading to >= 4.0.0
55

6+
#### A failed error rendering answers 500 instead of escaping the middleware stack
7+
8+
When Grape could not render an error response — an error formatter handed a payload it cannot serialize, most often — the exception escaped every middleware above Grape and reached the application server. Rendering runs inside `Grape::Middleware::Error#call!`'s own `rescue` clause, so that clause did not cover it.
9+
10+
Grape now answers `500` instead: first retrying the API's format with the framework's own `Internal Server Error` message, then falling back to a bare `text/plain` body if even that cannot be rendered.
11+
12+
This mirrors what `ActionDispatch::ShowExceptions#render_exception` does in Rails: try the application's own error rendering, and fall back to a bare `500 Internal Server Error` in `text/plain` when that rendering is itself broken.
13+
14+
**What can break.** Code that observed these exceptions by letting them propagate — a test asserting `expect { get '/' }.to raise_error`, most directly — no longer sees them raised. The exception is published on the rack env instead, under both Grape's own key and the conventional one that error trackers read:
15+
16+
```ruby
17+
env[Grape::Env::RACK_EXCEPTION] # 'rack.exception' — what trackers collect
18+
env[Grape::Env::GRAPE_EXCEPTION] # 'grape.exception' — same object, Grape's key
19+
```
20+
21+
An error tracker mounted as Rack middleware above Grape therefore keeps reporting these with no change on your side: sentry-ruby, for one, collects `env['rack.exception'] || env['sinatra.error']` for exactly this case — an exception that was handled rather than raised. The failure is also written to `rack.errors`, so it lands in the server log even with no tracker installed.
22+
23+
`rack.exception` is now set on the pre-existing unrecognised-error path too — an exception raised inside a `rescue_from` block that nothing else handles — which previously set only `grape.exception`.
24+
25+
Exceptions that no `rescue_from` matches still propagate exactly as before; only rendering failures changed.
26+
627
#### `Array`/`Set` of an unsupported type is rejected when the API is defined
728

829
Declaring a collection whose element type Grape cannot coerce — `type: Array[Foo]` or `type: Set[Foo]` where `Foo` is neither a primitive, a structure, nor a valid custom type — now raises as soon as the `params` block is evaluated, i.e. while the API class is being loaded:

lib/grape/env.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,11 @@ module Env
1414
GRAPE_ROUTING_ARGS = 'grape.routing_args'
1515
GRAPE_ALLOWED_METHODS = 'grape.allowed_methods'
1616
GRAPE_EXCEPTION = 'grape.exception'
17+
18+
# Not a Grape-owned key: the de-facto convention for an exception that was
19+
# handled rather than raised, which is how error trackers find one they
20+
# never saw propagate. sentry-ruby, for one, collects
21+
# +env['rack.exception'] || env['sinatra.error']+.
22+
RACK_EXCEPTION = 'rack.exception'
1723
end
1824
end

lib/grape/middleware/error.rb

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ def initialize(
4848
def_delegator :rescue_options, :backtrace, :include_backtrace
4949
def_delegator :rescue_options, :original_exception, :include_original_exception
5050

51+
# Emitted by {#failsafe_response} once even the framework's own message
52+
# could not be rendered. Deliberately built without a formatter, an i18n
53+
# lookup or anything else that could be the thing that is broken.
54+
FAILSAFE_STATUS = 500
55+
FAILSAFE_MESSAGE = '500 Internal Server Error'
56+
FAILSAFE_CONTENT_TYPE = 'text/plain'
57+
5158
def call!(env)
5259
@env = env
5360
error_response(catch(:error) { return @app.call(@env) })
@@ -104,7 +111,56 @@ def error_response(error = nil)
104111
backtrace: raw.backtrace || raw.original_exception&.backtrace || []
105112
)
106113
env[Grape::Env::API_ENDPOINT].status(payload.status) # error! may not have been called
107-
rack_response(payload.status, payload.headers, format_message(payload))
114+
begin
115+
rack_response(payload.status, payload.headers, format_message(payload))
116+
rescue StandardError => e
117+
failsafe_response(e)
118+
end
119+
end
120+
121+
# Last resort for an error response that could not be rendered — an error
122+
# formatter handed a payload it cannot serialize, typically. Rendering runs
123+
# inside #call!'s rescue clause, so it is not covered by that rescue and
124+
# anything raised here would escape the entire middleware stack. Grape has
125+
# committed to answering with an error by this point, so it answers with
126+
# one that does not depend on the payload rather than dropping the request.
127+
#
128+
# First retry the API's own format with the framework's InternalServerError,
129+
# whose message is a static string and so cannot be what defeated the first
130+
# attempt. Should even that fail — a wholesale broken formatter, rather than
131+
# one payload it choked on — drop the formatter entirely. Both attempts call
132+
# {#format_message} directly rather than re-entering {#error_response}, so
133+
# this path cannot recurse.
134+
#
135+
# The exception is exposed on the rack env, and written to +rack.errors+,
136+
# so upstream middleware (loggers, error trackers) can still observe what
137+
# actually went wrong — see {#expose_exception}.
138+
def failsafe_response(error)
139+
expose_exception(error)
140+
env[Rack::RACK_ERRORS]&.write("Grape could not render the error response: #{error.class}: #{error.message}\n")
141+
headers = { Rack::CONTENT_TYPE => content_type }
142+
rack_response(FAILSAFE_STATUS, headers, format_message(failsafe_payload(headers)))
143+
rescue StandardError
144+
rack_response(FAILSAFE_STATUS, { Rack::CONTENT_TYPE => FAILSAFE_CONTENT_TYPE }, FAILSAFE_MESSAGE)
145+
end
146+
147+
# Publish an exception Grape swallowed. +grape.exception+ is ours and has
148+
# always been set here; +rack.exception+ is what the ecosystem actually
149+
# reads to find an exception that never propagated, so a tracker mounted
150+
# above Grape keeps reporting these without any application change.
151+
def expose_exception(error)
152+
env[Grape::Env::GRAPE_EXCEPTION] = error
153+
env[Grape::Env::RACK_EXCEPTION] = error
154+
end
155+
156+
def failsafe_payload(headers)
157+
Grape::Exceptions::ErrorResponse.new(
158+
status: FAILSAFE_STATUS,
159+
message: Grape::Exceptions::InternalServerError.new.message,
160+
headers:,
161+
backtrace: [],
162+
original_exception: nil
163+
)
108164
end
109165

110166
def default_rescue_handler(exception)
@@ -191,7 +247,7 @@ def redispatch(error, endpoint, already_redispatched)
191247
# message. The framework deliberately does no logging of its own
192248
# here; that's the application's call.
193249
def safe_default(error, endpoint)
194-
env[Grape::Env::GRAPE_EXCEPTION] = error
250+
expose_exception(error)
195251
return run_rescue_handler(internal_grape_exceptions_rescue_handler, error, endpoint, redispatched: true) if internal_grape_exceptions_rescue_handler
196252

197253
framework_default(endpoint)

spec/grape/middleware/error_spec.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,86 @@ def self.call(_env)
9999
end
100100
end
101101

102+
# Rendering happens inside #call!'s rescue clause, so it is not covered by
103+
# that rescue: without a failsafe an error formatter that raises takes the
104+
# exception straight out through every middleware above.
105+
describe 'when the error response cannot be rendered' do
106+
subject(:response) do
107+
get '/'
108+
last_response
109+
end
110+
111+
context 'and the formatter chokes on the payload' do
112+
let(:app) do
113+
Class.new(Grape::API) do
114+
format :json
115+
116+
rescue_from(:all) { |e| error!({ detail: e.message }, 404) }
117+
118+
# A message the JSON formatter cannot serialize.
119+
get('/') { raise StandardError, +"bad \xC3 byte".b }
120+
end
121+
end
122+
123+
it 'answers with the framework message in the API format' do
124+
expect(response.status).to eq(500)
125+
expect(response.headers[Rack::CONTENT_TYPE]).to include('application/json')
126+
expect(JSON.parse(response.body)).to eq('error' => 'Internal Server Error')
127+
end
128+
129+
it 'exposes the rendering failure on the rack env' do
130+
get '/'
131+
expect(last_request.env[Grape::Env::GRAPE_EXCEPTION]).to be_a(StandardError)
132+
end
133+
134+
# The key error trackers read to find an exception that never propagated;
135+
# grape.exception alone leaves a swallowed failure invisible to them.
136+
it 'exposes the rendering failure under rack.exception' do
137+
get '/'
138+
expect(last_request.env[Grape::Env::RACK_EXCEPTION]).to be(last_request.env[Grape::Env::GRAPE_EXCEPTION])
139+
end
140+
141+
it 'writes the rendering failure to rack.errors' do
142+
errors = StringIO.new
143+
get '/', {}, Rack::RACK_ERRORS => errors
144+
expect(errors.string).to include('Grape could not render the error response: JSON::GeneratorError')
145+
end
146+
end
147+
148+
context 'and the formatter is broken outright' do
149+
let(:app) do
150+
Class.new(Grape::API) do
151+
format :json
152+
153+
error_formatter :json, ->(**) { raise 'formatter is broken' }
154+
rescue_from(:all) { error!({ detail: 'nope' }, 404) }
155+
156+
get('/') { raise StandardError, 'boom' }
157+
end
158+
end
159+
160+
it 'drops the formatter rather than recursing' do
161+
expect(response.status).to eq(500)
162+
expect(response.headers[Rack::CONTENT_TYPE]).to include('text/plain')
163+
expect(response.body).to eq('500 Internal Server Error')
164+
end
165+
end
166+
167+
context 'and nothing rescues the original exception' do
168+
let(:app) do
169+
Class.new(Grape::API) do
170+
format :json
171+
172+
get('/') { raise ArgumentError, 'kaboom' }
173+
end
174+
end
175+
176+
it 'keeps propagating it' do
177+
expect { get '/' }.to raise_error(ArgumentError, 'kaboom')
178+
end
179+
end
180+
end
181+
102182
describe 'when a rescue_from block raises' do
103183
subject(:response) do
104184
get '/'

0 commit comments

Comments
 (0)