You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -59,6 +59,8 @@
59
59
*[#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).
60
60
*[#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).
61
61
*[#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).
Copy file name to clipboardExpand all lines: UPGRADING.md
+21Lines changed: 21 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -3,6 +3,27 @@ Upgrading Grape
3
3
4
4
### Upgrading to >= 4.0.0
5
5
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
+
6
27
#### `Array`/`Set` of an unsupported type is rejected when the API is defined
7
28
8
29
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:
0 commit comments