Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
* [#2827](https://github.com/ruby-grape/grape/pull/2827): Make the `cascade` DSL getter return the configured value (`cascade false` read back as `true`) - [@ericproulx](https://github.com/ericproulx).
* [#2829](https://github.com/ruby-grape/grape/pull/2829): Fix a cascading route handing over only to the last route registered for the path, making a middle version (3+ mounted versions with a catch-all) answer 406 - [@ericproulx](https://github.com/ericproulx).
* [#2826](https://github.com/ruby-grape/grape/pull/2826): Fix `api.version` not being set for the root route of a path-versioned API (`GET /v1`) - [@ericproulx](https://github.com/ericproulx).
* [#2843](https://github.com/ruby-grape/grape/pull/2843): Let `rescue_from :grape_exceptions` take precedence over a catch-all registered as a class, so Grape errors keep their own status (see UPGRADING) - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

### 3.3.5 (2026-07-30)
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2737,6 +2737,19 @@ rescue_from :grape_exceptions do |e|
end
```

The opt-in takes precedence over a catch-all handler, whether that catch-all is written as `rescue_from :all` or as a class such as `rescue_from StandardError`. So a validation failure stays a `400` rather than becoming whatever the catch-all returns:

```ruby
class Twitter::API < Grape::API
rescue_from StandardError do
error!('server error', 500)
end
rescue_from :grape_exceptions # validation errors still answer 400
end
```

A handler registered for a specific Grape exception class is more precise than the opt-in and still wins, so `rescue_from Grape::Exceptions::ValidationErrors` keeps its own handler. The opt-in only outranks handlers that matched through a non-Grape ancestor.

You can also rescue specific exceptions.

```ruby
Expand Down
32 changes: 32 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,38 @@ Upgrading Grape

### Upgrading to >= 4.0.0

#### `rescue_from :grape_exceptions` now outranks a catch-all class handler

`rescue_from :grape_exceptions` is an opt-in to keep Grape's own errors rendering with their own status — a validation failure answers `400` rather than whatever the application's catch-all returns.

It only ever worked against `rescue_from :all`. Written as a class instead, a catch-all is a *registered* handler, which `Grape::Middleware::Error` consults first, and Grape's exceptions are `StandardError`s — so the opt-in was silently inert:

```ruby
rescue_from StandardError do
error!('server error', 500)
end
rescue_from :grape_exceptions
```

| request | before | 4.0 |
| --- | --- | --- |
| fails parameter validation | **500** `server error` | `400` |
| raises an application error | `500` `server error` | unchanged |

**What can break.** An API that registers a catch-all as a class *and* opts into `:grape_exceptions` will now answer Grape's own status for Grape's own errors, where it previously answered the catch-all's. That is what the opt-in asks for, so the change makes the two spellings agree — but a client or test asserting the catch-all's status for a validation failure will see the new one.

Precedence is unchanged in every other case. A handler registered for a specific Grape exception class is more precise than the opt-in and still wins:

```ruby
rescue_from Grape::Exceptions::ValidationErrors do
error!('unprocessable', 422) # still runs
end
rescue_from StandardError { ... }
rescue_from :grape_exceptions
```

Application errors still reach the catch-all, `rescue_from :all` behaves as before, and `Grape::Exceptions::InvalidVersionHeader` is still never rescued, so version cascading keeps working. An API that does not use `rescue_from :grape_exceptions` is unaffected.

#### `Array`/`Set` of an unsupported type is rejected when the API is defined

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:
Expand Down
48 changes: 43 additions & 5 deletions lib/grape/middleware/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ def format_message(error)
end

def find_handler(klass)
registered_rescue_handler(klass) ||
registered_entry = registered_rescue_handler_entry(klass)

grape_exceptions_precedence_handler(klass, registered_entry) ||
registered_entry&.last ||
rescue_handler_for_grape_exception(klass) ||
rescue_handler_for_any_class(klass) ||
raise
Expand Down Expand Up @@ -118,16 +121,51 @@ def default_rescue_handler(exception)
end

def registered_rescue_handler(klass)
rescue_handler_from(base_only_rescue_handlers) { |err| klass == err } ||
rescue_handler_from(rescue_handlers) { |err| klass <= err }
registered_rescue_handler_entry(klass)&.last
end

# The matched entry rather than just its handler, so callers can tell
# *which* class matched — see {#grape_exceptions_precedence_handler}.
# @return [Array(Class, #call), nil]
def registered_rescue_handler_entry(klass)
rescue_handler_entry_from(base_only_rescue_handlers) { |err| klass == err } ||
rescue_handler_entry_from(rescue_handlers) { |err| klass <= err }
end

def rescue_handler_from(handlers)
def rescue_handler_entry_from(handlers)
error, handler = handlers&.find { |err, _handler| yield(err) }

return unless error

handler || method(:default_rescue_handler)
[error, handler || method(:default_rescue_handler)]
end

# +rescue_from :grape_exceptions+ is an opt-in to keep Grape's own errors
# rendering with their own status — a validation failure stays a 400
# instead of becoming whatever the app's catch-all returns.
#
# It only ever worked against +rescue_from :all+, which lives in
# +all_rescue_handler+ and is consulted last. Spelled as a class instead,
# +rescue_from StandardError+ is a *registered* handler, matched first,
# and Grape's exceptions are StandardErrors — so the opt-in silently did
# nothing and validation errors came back as 500s either way.
#
# Let it win over a handler that only matched through a non-Grape
# ancestor. One registered for a Grape exception class is more specific
# than the opt-in and still wins, so an explicit
# +rescue_from Grape::Exceptions::ValidationErrors+ keeps its handler.
#
# InvalidVersionHeader is left alone: it must keep reaching Rack so the
# next versioned route is tried.
def grape_exceptions_precedence_handler(klass, registered_entry)
return unless rescue_grape_exceptions
return unless klass <= Grape::Exceptions::Base
return if klass == Grape::Exceptions::InvalidVersionHeader

matched, = registered_entry
return if matched.nil? || matched <= Grape::Exceptions::Base

grape_exceptions_rescue_handler || method(:error_response)
end

def rescue_handler_for_grape_exception(klass)
Expand Down
53 changes: 53 additions & 0 deletions spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2529,6 +2529,59 @@ def rescue_all_errors
expect(last_response).to be_forbidden
expect(last_response.body).to eq('Redefined Error')
end

# The opt-in exists to keep Grape's own errors rendering with their own
# status. A catch-all registered as a class is matched before it, and
# Grape's exceptions are StandardErrors, so it used to be silently inert
# unless the catch-all happened to be spelled `rescue_from :all`.
context 'with a catch-all class handler' do
subject(:app) do
Class.new(Grape::API) do
format :json
rescue_from(StandardError) { error!({ h: 'catch-all' }, 500) }
rescue_from(:grape_exceptions) { |e| error!({ h: 'grape' }, e.status) }
params { requires :n, type: Integer }
get('/validated') { 'ok' }
get('/boom') { raise ArgumentError, 'app error' }
end
end

it 'keeps a validation failure a 400' do
get '/validated'

expect(last_response.status).to eq(400)
expect(last_response.body).to eq({ h: 'grape' }.to_json)
end

it 'still sends the application’s own errors to the catch-all' do
get '/boom'

expect(last_response.status).to eq(500)
expect(last_response.body).to eq({ h: 'catch-all' }.to_json)
end
end

it 'lets a handler registered for a grape exception class win over the opt-in' do
subject.rescue_from(Grape::Exceptions::ValidationErrors) { error!('specific', 422) }
subject.rescue_from(StandardError) { error!('catch-all', 500) }
subject.params { requires :n, type: Integer }
subject.get('/validated') { 'ok' }

get '/validated'

expect(last_response.status).to eq(422)
expect(last_response.body).to eq('specific')
end

it 'does not intercept an exception that is not a grape exception' do
subject.rescue_from(StandardError) { error!('catch-all', 500) }
subject.get('/boom') { raise ArgumentError }

get '/boom'

expect(last_response.status).to eq(500)
expect(last_response.body).to eq('catch-all')
end
end

describe '.error_format' do
Expand Down
Loading