Skip to content

Commit 1d94591

Browse files
ericproulxclaudedblock
authored
Fix base-only rescue handlers never firing (#2794)
* Fix base-only rescue handlers never firing `rescue_from Klass, rescue_subclasses: false` registered its handler in `namespace_reverse_stackable[:base_only_rescue_handlers]` (dsl/request_response.rb), but the error middleware options read `:base_only_rescue_handlers` from `namespace_stackable` via `namespace_stackable_with_hash`. Those are two different stores, so the read was always empty and base-only handlers never ran — the exception propagated uncaught. Read base-only handlers from the same reverse-stackable store the write uses, sharing the child-precedence merge with `rescue_handlers` via `merged_reverse_stackable`. This also gives base-only handlers the same nested-scope precedence that subclass handlers already had. The gap slipped through because the only behavioral test asserted a *child* error is not rescued (which passes even when base-only is fully broken); nothing asserted the exact class is rescued. Add that missing test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Encapsulate namespace_reverse_stackable behind rescue-handler methods namespace_reverse_stackable existed only to store rescue-handler maps, yet it was a public attr_reader that callers reached into with raw keys from two different files (the rescue_from write and the endpoint read). That's how the base-only handlers write/read drifted onto mismatched stores. Make the store internal (protected, still used by inherit_from/copy_state_from/ to_hash) and expose intent-revealing methods on InheritableSetting instead: rescue_handlers / base_only_rescue_handlers (merged, child-scope-first) and add_rescue_handlers(mapping, subclasses:). The DSL write and endpoint reads go through those, so there's one place that knows the key, store and merge. The reverse-ordering behaviour stays covered by ReverseStackableValues' own spec; the InheritableSetting specs that poked the raw store are replaced with tests for the new methods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Daniel (dB.) Doubrovkine <dblock@dblock.org>
1 parent db3e3e9 commit 1d94591

7 files changed

Lines changed: 71 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* [#2782](https://github.com/ruby-grape/grape/pull/2782): Remove the dead `format` keyword from `Grape::Router::Pattern` - [@ericproulx](https://github.com/ericproulx).
1313
* [#2783](https://github.com/ruby-grape/grape/pull/2783): Read a route's `version`, `anchor` and `requirements` from its pattern instead of storing them again on the route - [@ericproulx](https://github.com/ericproulx).
1414
* [#2784](https://github.com/ruby-grape/grape/pull/2784): Move HEAD route creation into `Grape::Router::Route#to_head` - [@ericproulx](https://github.com/ericproulx).
15+
* [#2795](https://github.com/ruby-grape/grape/pull/2795): Make `Grape::Util::InheritableSetting#namespace_reverse_stackable` internal, exposing `rescue_handlers` / `base_only_rescue_handlers` / `add_rescue_handlers` instead - [@ericproulx](https://github.com/ericproulx).
1516
* [#2793](https://github.com/ruby-grape/grape/pull/2793): Add a `Grape::Mountable` marker to identify a Grape app instead of duck-typing on `respond_to?(:inheritable_setting)` - [@ericproulx](https://github.com/ericproulx).
1617
* [#2792](https://github.com/ruby-grape/grape/pull/2792): Move `Grape::Path` to `Grape::Router::Pattern::Path` and add a `Grape::Router::Pattern.build` factory (`Grape::Path` kept as a deprecated constant) - [@ericproulx](https://github.com/ericproulx).
1718
* [#2785](https://github.com/ruby-grape/grape/pull/2785): Make route `params` a first-class endpoint input and split `Grape::Router::Route#params` into `#params` (declared definitions) and `#params_for(input)` (extracted values) - [@ericproulx](https://github.com/ericproulx).
@@ -21,6 +22,7 @@
2122
#### Fixes
2223

2324
* [#2767](https://github.com/ruby-grape/grape/pull/2767): Update rubocop to 1.88.0 and rubocop-rspec to 3.10.2 - [@ericproulx](https://github.com/ericproulx).
25+
* [#2794](https://github.com/ruby-grape/grape/pull/2794): Fix `rescue_from Klass, rescue_subclasses: false` never rescuing — base-only handlers were written to and read from different settings stores - [@ericproulx](https://github.com/ericproulx).
2426
* [#2789](https://github.com/ruby-grape/grape/pull/2789): Escape error messages served with a parameterized `text/html` content-type (e.g. `text/html; charset=utf-8`) and warn when a bare Rack app is mounted under authentication - [@ericproulx](https://github.com/ericproulx).
2527
* [#2790](https://github.com/ruby-grape/grape/pull/2790): Stop mutating `Grape::Router`'s internal maps at request time for HTTP methods with no routes (data race on JRuby/TruffleRuby and unbounded growth) - [@ericproulx](https://github.com/ericproulx).
2628
* [#2791](https://github.com/ruby-grape/grape/pull/2791): Build HEAD/OPTIONS/405 helper routes from a copy of the inheritable settings instead of temporarily mutating the shared class-level settings during compilation - [@ericproulx](https://github.com/ericproulx).

lib/grape/dsl/request_response.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,7 @@ def rescue_from(*args, with: nil, rescue_subclasses: true, backtrace: false, ori
120120
when :internal_grape_exceptions
121121
namespace_inheritable[:internal_grape_exceptions_rescue_handler] = handler
122122
else
123-
handler_type = rescue_subclasses ? :rescue_handlers : :base_only_rescue_handlers
124-
inheritable_setting.namespace_reverse_stackable[handler_type] = args.to_h { |klass| [klass, handler] }
123+
inheritable_setting.add_rescue_handlers(args.to_h { |klass| [klass, handler] }, subclasses: rescue_subclasses)
125124
end
126125

127126
inheritable_setting.namespace_stackable[:rescue_options] = RescueOptions.new(backtrace:, original_exception:)

lib/grape/endpoint.rb

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,8 @@ def error_middleware_options(format, content_types)
384384
default_error_formatter: ns_inh[:default_error_formatter],
385385
error_formatters: ns_stack.namespace_stackable_with_hash(:error_formatters),
386386
rescue_options: ns_stack.namespace_stackable[:rescue_options]&.last,
387-
rescue_handlers:,
388-
base_only_rescue_handlers: ns_stack.namespace_stackable_with_hash(:base_only_rescue_handlers),
387+
rescue_handlers: ns_stack.rescue_handlers,
388+
base_only_rescue_handlers: ns_stack.base_only_rescue_handlers,
389389
all_rescue_handler: ns_inh[:all_rescue_handler],
390390
grape_exceptions_rescue_handler: ns_inh[:grape_exceptions_rescue_handler],
391391
internal_grape_exceptions_rescue_handler: ns_inh[:internal_grape_exceptions_rescue_handler]
@@ -424,14 +424,5 @@ def build_response_cookies
424424
def lint?
425425
inheritable_setting.namespace_inheritable[:lint] || Grape.config.lint
426426
end
427-
428-
def rescue_handlers
429-
rescue_handlers = inheritable_setting.namespace_reverse_stackable[:rescue_handlers]
430-
return if rescue_handlers.blank?
431-
432-
rescue_handlers.each_with_object({}) do |rescue_handler, result|
433-
result.merge!(rescue_handler) { |_k, s1, _s2| s1 }
434-
end
435-
end
436427
end
437428
end

lib/grape/util/inheritable_setting.rb

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module Util
55
# A branchable, inheritable settings object which can store both stackable
66
# and inheritable values (see InheritableValues and StackableValues).
77
class InheritableSetting
8-
attr_reader :route, :namespace, :namespace_inheritable, :namespace_stackable, :namespace_reverse_stackable, :parent
8+
attr_reader :route, :namespace, :namespace_inheritable, :namespace_stackable, :parent
99

1010
# Lazy-allocated; +api_class+ and +point_in_time_copies+ are rarely
1111
# written on most settings layers, so don't pay for a Hash/Array each.
@@ -107,8 +107,28 @@ def namespace_stackable_with_hash(key)
107107
data.each_with_object({}) { |value, result| result.deep_merge!(value) }
108108
end
109109

110+
# Rescue-handler maps registered by +rescue_from+, keyed by exception
111+
# class and merged so a nested scope's handler wins. Record them with
112+
# #add_rescue_handlers; the backing store is an internal detail.
113+
def rescue_handlers
114+
merged_rescue_handlers(:rescue_handlers)
115+
end
116+
117+
def base_only_rescue_handlers
118+
merged_rescue_handlers(:base_only_rescue_handlers)
119+
end
120+
121+
def add_rescue_handlers(mapping, subclasses:)
122+
namespace_reverse_stackable[subclasses ? :rescue_handlers : :base_only_rescue_handlers] = mapping
123+
end
124+
110125
protected
111126

127+
# Reverse-stackable so a child scope's rescue handlers precede inherited
128+
# ones. Reached only through #rescue_handlers / #base_only_rescue_handlers
129+
# / #add_rescue_handlers, and #inherit_from / #copy_state_from.
130+
attr_reader :namespace_reverse_stackable
131+
112132
# Used by +point_in_time_copy+ to populate a freshly-built instance
113133
# with cloned state from another instance of the same class.
114134
def copy_state_from(source)
@@ -119,6 +139,15 @@ def copy_state_from(source)
119139
@route = source.route.clone
120140
@api_class = source.api_class
121141
end
142+
143+
private
144+
145+
def merged_rescue_handlers(key)
146+
handlers = namespace_reverse_stackable[key]
147+
return if handlers.blank?
148+
149+
handlers.each_with_object({}) { |handler, result| result.merge!(handler) { |_k, s1, _s2| s1 } }
150+
end
122151
end
123152
end
124153
end

spec/grape/api_spec.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2438,6 +2438,19 @@ def rescue_all_errors
24382438
expect(last_response).to be_server_error
24392439
end
24402440

2441+
it 'rescues the exact error class if rescue_subclasses is false' do
2442+
subject.rescue_from ApiSpec::APIErrors::ParentError, rescue_subclasses: false do |e|
2443+
error!("rescued from #{e.class.name}", 500)
2444+
end
2445+
subject.get '/caught_parent' do
2446+
raise ApiSpec::APIErrors::ParentError
2447+
end
2448+
2449+
get '/caught_parent'
2450+
expect(last_response).to be_server_error
2451+
expect(last_response.body).to eq('rescued from ApiSpec::APIErrors::ParentError')
2452+
end
2453+
24412454
it 'does not rescue child errors if rescue_subclasses is false' do
24422455
subject.rescue_from ApiSpec::APIErrors::ParentError, rescue_subclasses: false do |e|
24432456
error!("rescued from #{e.class.name}", 500)

spec/grape/dsl/request_response_spec.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,34 +220,34 @@
220220

221221
it 'sets hash of exceptions as rescue handlers' do
222222
subject.rescue_from StandardError
223-
expect(subject.inheritable_setting.namespace_reverse_stackable[:rescue_handlers]).to eq([{ StandardError => nil }])
223+
expect(subject.inheritable_setting.rescue_handlers).to eq(StandardError => nil)
224224
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
225225
end
226226

227227
it 'rescues only base handlers if rescue_subclasses: false option is passed' do
228228
subject.rescue_from StandardError, rescue_subclasses: false
229-
expect(subject.inheritable_setting.namespace_reverse_stackable[:base_only_rescue_handlers]).to eq([{ StandardError => nil }])
229+
expect(subject.inheritable_setting.base_only_rescue_handlers).to eq(StandardError => nil)
230230
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
231231
end
232232

233233
it 'sets given proc as rescue handler for each key in hash' do
234234
rescue_handler_proc = proc {}
235235
subject.rescue_from StandardError, rescue_handler_proc
236-
expect(subject.inheritable_setting.namespace_reverse_stackable[:rescue_handlers]).to eq([{ StandardError => rescue_handler_proc }])
236+
expect(subject.inheritable_setting.rescue_handlers).to eq(StandardError => rescue_handler_proc)
237237
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
238238
end
239239

240240
it 'sets given block as rescue handler for each key in hash' do
241241
rescue_handler_proc = proc {}
242242
subject.rescue_from StandardError, &rescue_handler_proc
243-
expect(subject.inheritable_setting.namespace_reverse_stackable[:rescue_handlers]).to eq([{ StandardError => rescue_handler_proc }])
243+
expect(subject.inheritable_setting.rescue_handlers).to eq(StandardError => rescue_handler_proc)
244244
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
245245
end
246246

247247
it 'sets a rescue handler declared through :with option for each key in hash' do
248248
with_block = -> { 'hello' }
249249
subject.rescue_from StandardError, with: with_block
250-
expect(subject.inheritable_setting.namespace_reverse_stackable[:rescue_handlers]).to eq([{ StandardError => with_block }])
250+
expect(subject.inheritable_setting.rescue_handlers).to eq(StandardError => with_block)
251251
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
252252
end
253253
end

spec/grape/util/inheritable_setting_spec.rb

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
settings.namespace[:namespace_thing] = :namespace_foo_bar
1313
settings.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar
1414
settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar
15-
settings.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = :namespace_reverse_stackable_foo_bar
1615
settings.route[:route_thing] = :route_foo_bar
1716
end
1817
end
@@ -22,7 +21,6 @@
2221
settings.namespace[:namespace_thing] = :namespace_foo_bar_other
2322
settings.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar_other
2423
settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar_other
25-
settings.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = :namespace_reverse_stackable_foo_bar_other
2624
settings.route[:route_thing] = :route_foo_bar_other
2725
end
2826
end
@@ -116,13 +114,24 @@
116114
end
117115
end
118116

119-
describe '#namespace_reverse_stackable' do
120-
it 'works with reverse stackable values' do
121-
expect(subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar]
117+
describe '#rescue_handlers / #add_rescue_handlers' do
118+
it 'records subclass-matching handlers under rescue_handlers' do
119+
subject.add_rescue_handlers({ StandardError => :handler }, subclasses: true)
120+
expect(subject.rescue_handlers).to eq(StandardError => :handler)
121+
expect(subject.base_only_rescue_handlers).to be_nil
122+
end
122123

123-
subject.inherit_from other_parent
124+
it 'records exact-match handlers under base_only_rescue_handlers' do
125+
subject.add_rescue_handlers({ StandardError => :handler }, subclasses: false)
126+
expect(subject.base_only_rescue_handlers).to eq(StandardError => :handler)
127+
expect(subject.rescue_handlers).to be_nil
128+
end
124129

125-
expect(subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar_other]
130+
it 'lets a nested scope override an inherited handler for the same class' do
131+
parent = described_class.new.tap { |s| s.add_rescue_handlers({ StandardError => :parent }, subclasses: true) }
132+
subject.inherit_from parent
133+
subject.add_rescue_handlers({ StandardError => :child }, subclasses: true)
134+
expect(subject.rescue_handlers).to eq(StandardError => :child)
126135
end
127136
end
128137

@@ -192,14 +201,6 @@
192201
expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar]
193202
end
194203

195-
it 'decouples namespace reverse stackable values' do
196-
expect(cloned_obj.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar]
197-
198-
subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = :other_thing
199-
expect(subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq %i[other_thing namespace_reverse_stackable_foo_bar]
200-
expect(cloned_obj.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar]
201-
end
202-
203204
it 'decouples route values' do
204205
expect(cloned_obj.route[:route_thing]).to eq :route_foo_bar
205206

@@ -218,7 +219,7 @@
218219
subject.namespace[:namespace_thing] = :namespace_foo_bar
219220
subject.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar
220221
subject.namespace_stackable[:namespace_stackable_thing] = [:namespace_stackable_foo_bar]
221-
subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = [:namespace_reverse_stackable_foo_bar]
222+
subject.add_rescue_handlers({ StandardError => :handler }, subclasses: true)
222223
subject.route[:route_thing] = :route_foo_bar
223224
expect(subject.to_hash).to match(
224225
global: { global_thing: :global_foo_bar },
@@ -227,8 +228,7 @@
227228
namespace_inheritable_thing: :namespace_inheritable_foo_bar
228229
},
229230
namespace_stackable: { namespace_stackable_thing: [:namespace_stackable_foo_bar, [:namespace_stackable_foo_bar]] },
230-
namespace_reverse_stackable:
231-
{ namespace_reverse_stackable_thing: [[:namespace_reverse_stackable_foo_bar], :namespace_reverse_stackable_foo_bar] },
231+
namespace_reverse_stackable: { rescue_handlers: [{ StandardError => :handler }] },
232232
route: { route_thing: :route_foo_bar }
233233
)
234234
end

0 commit comments

Comments
 (0)