From 7ce8f360b7fcd9570216a4406c93eed086922b04 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Wed, 21 Oct 2026 16:17:01 -0700 Subject: [PATCH] Add a setting to let an exception raised in a rescue_from block propagate Since #2703, Grape redispatches an exception raised inside a rescue_from block, so it no longer reaches Rack middleware mounted above Grape. That fixed #2482, and it also stopped a block from handing an exception to such middleware by re-raising it. Active Record's DatabaseSelector with a custom resolver that retries a request on the primary after an ActiveRecord::ReadOnlyError is one: an API that re-raised the error from rescue_from :all for it now answers 500 where the request used to recover. Grape.config.raise_rescue_handler_errors brings back the earlier behaviour, as raise_rendering_errors (#2840) does for rendering failures. With it on, whatever a rescue_from block raises is re-raised untouched. A block that calls error! still renders, because Halt is rescued ahead of it. Off by default. Fixes #3003. --- CHANGELOG.md | 1 + README.md | 11 +++++ lib/grape.rb | 3 ++ lib/grape/middleware/error.rb | 2 + spec/grape/middleware/error_spec.rb | 69 +++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 034d240cd..c8283e3ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * [#3008](https://github.com/ruby-grape/grape/pull/3008): Add the grape-on-rack app as a live-server memory profiling harness - [@ericproulx](https://github.com/ericproulx). * [#3025](https://github.com/ruby-grape/grape/pull/3025): Add `:md` and `:markdown` formatters that render an object's `to_markdown`, for APIs that declare a `text/markdown` content type - [@ericproulx](https://github.com/ericproulx). * [#3029](https://github.com/ruby-grape/grape/pull/3029): Add a built-in `:jsonl` (JSON Lines) format that streams an Enumerator body - [@ericproulx](https://github.com/ericproulx). +* [#3040](https://github.com/ruby-grape/grape/pull/3040): Add the `raise_rescue_handler_errors` setting to let an exception raised inside a `rescue_from` block propagate - [@dduugg](https://github.com/dduugg). * Your contribution here. #### Fixes diff --git a/README.md b/README.md index 8723ca5e4..438fec32c 100644 --- a/README.md +++ b/README.md @@ -583,6 +583,7 @@ Currently the configurable settings are: * `lint`: Checks every response against Rack's specification, defaults to `false`. See [Linting](#linting). * `warn_on_helper_overrides`: Warns when a helper method masks a `Grape::Endpoint` instance method, defaults to `false`. * `raise_rendering_errors`: Lets an error response that cannot be rendered propagate out of the middleware stack instead of being answered with a failsafe `500`, defaults to `false`. See [When the error response itself cannot be rendered](#when-the-error-response-itself-cannot-be-rendered). +* `raise_rescue_handler_errors`: Lets an exception raised inside a `rescue_from` block propagate out of the middleware stack instead of being redispatched, defaults to `false`. See [Re-raising from inside a `rescue_from` block](#re-raising-from-inside-a-rescue_from-block). To change a setting value make sure that at some point during load time the following code runs @@ -2971,6 +2972,16 @@ When this handler is registered the framework hands the original exception to yo A second raise inside the redispatched handler is not redispatched again — it goes straight to the framework's generic 500. This bounds the chain at one redispatch and prevents loops. +To have an exception raised inside a `rescue_from` block propagate out of the middleware stack instead, so that Rack middleware above Grape can act on it: + +```ruby +Grape.configure do |config| + config.raise_rescue_handler_errors = true +end +``` + +With this on the exception is re-raised untouched: it is not redispatched, so `rescue_from :internal_grape_exceptions` never sees it and neither `rack.exception` nor `grape.exception` is set. A block that calls `error!` still renders as usual. + You can also rescue all exceptions with a code block and handle the Rack response at the lowest level. ```ruby diff --git a/lib/grape.rb b/lib/grape.rb index 200614f66..2b6b3d9de 100644 --- a/lib/grape.rb +++ b/lib/grape.rb @@ -61,6 +61,9 @@ module Grape # Let an error response that cannot be rendered propagate out of the # middleware stack instead of being answered with a failsafe 500. setting :raise_rendering_errors, default: false + # Let an exception raised inside a `rescue_from` block propagate out of the + # middleware stack instead of being redispatched. + setting :raise_rescue_handler_errors, default: false # The HTTP QUERY method (RFC 10008): a safe, idempotent request whose content # carries the query. Rack has no constant for it yet, hence the literal. diff --git a/lib/grape/middleware/error.rb b/lib/grape/middleware/error.rb index 98b3a2636..de2a1db1b 100644 --- a/lib/grape/middleware/error.rb +++ b/lib/grape/middleware/error.rb @@ -352,6 +352,8 @@ def run_rescue_handler(handler, error, endpoint, redispatched: false) rescue Grape::Exceptions::Halt => e e.response rescue StandardError => e + raise if Grape.config.raise_rescue_handler_errors + return redispatch(e, endpoint, redispatched) end diff --git a/spec/grape/middleware/error_spec.rb b/spec/grape/middleware/error_spec.rb index 24d4451e8..6797e764d 100644 --- a/spec/grape/middleware/error_spec.rb +++ b/spec/grape/middleware/error_spec.rb @@ -523,6 +523,75 @@ def initialize expect(response.body).to eq('Invalid response') end end + + context 'and Grape.config.raise_rescue_handler_errors is set' do + around do |example| + Grape.config.raise_rescue_handler_errors = true + example.run + ensure + Grape.config.raise_rescue_handler_errors = false + end + + context 'and the block re-raises the exception it was handed' do + let(:app) do + Class.new(Grape::API) do + format :txt + + rescue_from(:internal_grape_exceptions) { error!('internal', 503) } + rescue_from(:all) { |e| raise e } + + get('/') { raise ArgumentError, 'oops' } + end + end + + it 'lets it propagate past rescue_from :internal_grape_exceptions' do + expect { response }.to raise_error(ArgumentError, 'oops') + expect(last_request.env).not_to include(Grape::Env::GRAPE_EXCEPTION, Grape::Env::RACK_EXCEPTION) + end + end + + context 'and the re-raised exception has a registered rescue_from' do + let(:app) do + Class.new(Grape::API) do + format :txt + + custom_error_class = Class.new(StandardError) + const_set(:CustomError, custom_error_class) + + rescue_from custom_error_class do |e| + error!("custom-handled: #{e.message}", 422) + end + + rescue_from :all do |e| + raise custom_error_class, "wrapped(`#{e.message}`)" + end + + get('/') { raise ArgumentError, 'oops' } + end + end + + it 'lets it propagate instead of redispatching it' do + expect { response }.to raise_error(app::CustomError, 'wrapped(`oops`)') + end + end + + context 'and the block calls error!' do + let(:app) do + Class.new(Grape::API) do + format :txt + + rescue_from(:all) { |e| error!("handled: #{e.message}", 422) } + + get('/') { raise ArgumentError, 'oops' } + end + end + + it 'still renders the error' do + expect(response.status).to eq(422) + expect(response.body).to eq('handled: oops') + end + end + end end describe '#error!' do