From f67e86c7b8f8ad8a332b0dfb6f3f40b40b8f1604 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 1 Aug 2026 12:04:51 +0200 Subject: [PATCH] Let rescue_from :grape_exceptions outrank 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, which lives in all_rescue_handler and is consulted last. Written 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 still came back as 500s: rescue_from StandardError { error!('server error', 500) } rescue_from :grape_exceptions # inert Let the opt-in win over a handler that only matched through a non-Grape ancestor. A handler registered for a Grape exception class is more precise than the opt-in and still wins, so an explicit rescue_from Grape::Exceptions::ValidationErrors keeps its handler. registered_rescue_handler gains an _entry variant returning the matched class alongside its handler, since deciding this needs to know *which* class matched, not just that one did. find_handler resolves the entry once and passes it down. Application errors still reach the catch-all, rescue_from :all is unchanged, and InvalidVersionHeader is left alone so it keeps reaching Rack and version cascading still works. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + README.md | 13 +++++++++ UPGRADING.md | 32 +++++++++++++++++++++ lib/grape/middleware/error.rb | 48 +++++++++++++++++++++++++++---- spec/grape/api_spec.rb | 53 +++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0049465b9..3d63f1783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index efae8b900..3aaafa0cf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/UPGRADING.md b/UPGRADING.md index ab4838229..d4011fc81 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -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: diff --git a/lib/grape/middleware/error.rb b/lib/grape/middleware/error.rb index f12fa398e..cee7aef75 100644 --- a/lib/grape/middleware/error.rb +++ b/lib/grape/middleware/error.rb @@ -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 @@ -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) diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index ff3454f15..453d03932 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -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