Skip to content
Merged
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 @@ -30,6 +30,7 @@
* [#2682](https://github.com/ruby-grape/grape/pull/2682): Fix `Style/OptionalBooleanParameter` offenses - [@ericproulx](https://github.com/ericproulx).
* [#2699](https://github.com/ruby-grape/grape/pull/2699): Fix `Grape::Validations::Types::CustomTypeCoercer` dropping symbolized hash keys for `Array`/`Set` types; refactor the class for readability - [@ericproulx](https://github.com/ericproulx).
* [#2700](https://github.com/ruby-grape/grape/pull/2700): Fix README typos, remove obsolete Ruby 2.4 / Fixnum section, and replace incorrect `requires + values + allow_blank` note with a correct one covering `optional + values` semantics (closes #2631) - [@ericproulx](https://github.com/ericproulx).
* [#2703](https://github.com/ruby-grape/grape/pull/2703): Catch exceptions raised inside `rescue_from` blocks; new `rescue_from :internal_grape_exceptions` opt-in for unrecognised internal errors (resolves [#2482](https://github.com/ruby-grape/grape/issues/2482)) - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

### 3.2.1 (2026-04-16)
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2734,6 +2734,41 @@ class Twitter::API < Grape::API
end
```

#### Re-raising from inside a `rescue_from` block

A `rescue_from` block can re-raise an exception to invoke a different handler. This is useful for translating one exception class into another:

```ruby
class Twitter::API < Grape::API
rescue_from Grape::Exceptions::ValidationErrors do |e|
raise Api::Exceptions::InvalidValueError, e.full_messages
end

rescue_from Api::Exceptions::InvalidValueError do |e|
error!({ errors: e.message }, 422)
end
end
```

The first handler re-raises; the second handler runs against the new exception.

If the re-raised exception has no registered `rescue_from` and is a `Grape::Exceptions::Base` subclass, it is rendered through the default Grape error path (using its own `status` and `message`). Anything else — typos, `NoMethodError`, an unrelated `StandardError` — is treated as an internal error: it is exposed on `env['grape.exception']` for upstream Rack middleware to observe, and rendered to the API consumer as a generic `500 Internal Server Error`. This avoids leaking internal detail in the response body.

You can take control of the internal-error path by opting in with `rescue_from :internal_grape_exceptions`:

```ruby
class Twitter::API < Grape::API
rescue_from :internal_grape_exceptions do |e|
Sentry.capture_exception(e)
error!({ message: 'Something went wrong' }, 500)
end
end
```

When this handler is registered the framework hands the original exception to you, and you own the response shape and any logging. The framework deliberately does not log internal errors itself — it has no way to know your preferred format or destination.

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.

You can also rescue all exceptions with a code block and handle the Rack response at the lowest level.

```ruby
Expand Down
30 changes: 30 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,36 @@ end

**Behaviour change for code that didn't define a helper.** If your code references `logger` inside an endpoint context *without* a corresponding `helpers` definition, that call previously raised `NoMethodError` and now returns the API's configured logger. This is almost always the intended behaviour, but if you were relying on the `NoMethodError` (for instance to short-circuit logging in test environments via `rescue NoMethodError`), update your code to check `respond_to?(:logger)` or to gate logging on a feature flag.

#### Exceptions raised inside `rescue_from` blocks are now caught

Previously, an exception raised inside a `rescue_from` block was uncaught and bubbled up to Rack, producing the Rack default 500 page. The framework now catches and routes it:

1. If the re-raised exception's class has a registered `rescue_from` handler, that handler runs (one redispatch only — a second raise stops the chain).
2. If the re-raised exception is a `Grape::Exceptions::Base` subclass, it is rendered via the default Grape error path with its own `status` and `message`.
3. Otherwise, the original exception is exposed on `env['grape.exception']` for upstream Rack middleware to observe, and the response is a generic `Grape::Exceptions::InternalServerError` (`500 Internal Server Error`) — the original exception's message is **not** rendered to the API consumer.

This means deliberate re-raises in a `rescue_from` block (e.g. translating one exception class into another) now compose with the rest of your `rescue_from` configuration, and accidental crashes (typos, `NoMethodError`, …) no longer leak internal detail to API consumers.

The framework deliberately does **not** log unhandled internal exceptions itself — formatting and destination are application concerns. To log, forward to an error tracker, or customize the response shape for these errors, register a `rescue_from :internal_grape_exceptions` handler:

```ruby
rescue_from :internal_grape_exceptions do |e|
Sentry.capture_exception(e)
error!({ message: 'Something went wrong' }, 500)
end
```

When this handler is registered, the framework hands the original exception to you and you own the response shape entirely.

If you relied on the old behaviour and want raw exception messages exposed in development, register a catch-all handler:

```ruby
rescue_from StandardError do |e|
error!({ message: e.message, class: e.class.name }, 500)
end
```


### Upgrading to >= 3.2

#### Rack parameter parsing errors now raise `Grape::Exceptions::RequestError`
Expand Down
2 changes: 2 additions & 0 deletions lib/grape/dsl/request_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ def rescue_from(*args, with: nil, **options, &block)
inheritable_setting.namespace_inheritable[:rescue_all] = true
inheritable_setting.namespace_inheritable[:rescue_grape_exceptions] = true
inheritable_setting.namespace_inheritable[:grape_exceptions_rescue_handler] = handler
elsif args.include?(:internal_grape_exceptions)
inheritable_setting.namespace_inheritable[:internal_grape_exceptions_rescue_handler] = handler
else
handler_type =
case options[:rescue_subclasses]
Expand Down
34 changes: 21 additions & 13 deletions lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -305,19 +305,7 @@ def build_stack

stack.use Rack::Head
stack.use Rack::Lint if lint?
stack.use Grape::Middleware::Error,
format:,
content_types:,
default_status: inheritable_setting.namespace_inheritable[:default_error_status],
rescue_all: inheritable_setting.namespace_inheritable[:rescue_all],
rescue_grape_exceptions: inheritable_setting.namespace_inheritable[:rescue_grape_exceptions],
default_error_formatter: inheritable_setting.namespace_inheritable[:default_error_formatter],
error_formatters: inheritable_setting.namespace_stackable_with_hash(:error_formatters),
rescue_options: inheritable_setting.namespace_stackable_with_hash(:rescue_options),
rescue_handlers:,
base_only_rescue_handlers: inheritable_setting.namespace_stackable_with_hash(:base_only_rescue_handlers),
all_rescue_handler: inheritable_setting.namespace_inheritable[:all_rescue_handler],
grape_exceptions_rescue_handler: inheritable_setting.namespace_inheritable[:grape_exceptions_rescue_handler]
stack.use Grape::Middleware::Error, **error_middleware_options(format, content_types)

stack.concat inheritable_setting.namespace_stackable[:middleware]

Expand All @@ -341,6 +329,26 @@ def build_stack
builder.to_app
end

def error_middleware_options(format, content_types)
ns_inh = inheritable_setting.namespace_inheritable
ns_stack = inheritable_setting
{
format:,
content_types:,
default_status: ns_inh[:default_error_status],
rescue_all: ns_inh[:rescue_all],
rescue_grape_exceptions: ns_inh[:rescue_grape_exceptions],
default_error_formatter: ns_inh[:default_error_formatter],
error_formatters: ns_stack.namespace_stackable_with_hash(:error_formatters),
rescue_options: ns_stack.namespace_stackable_with_hash(:rescue_options),
rescue_handlers:,
base_only_rescue_handlers: ns_stack.namespace_stackable_with_hash(:base_only_rescue_handlers),
all_rescue_handler: ns_inh[:all_rescue_handler],
grape_exceptions_rescue_handler: ns_inh[:grape_exceptions_rescue_handler],
internal_grape_exceptions_rescue_handler: ns_inh[:internal_grape_exceptions_rescue_handler]
}
end

def build_helpers
helpers = inheritable_setting.namespace_stackable[:helpers]
return if helpers.empty?
Expand Down
1 change: 1 addition & 0 deletions lib/grape/env.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ module Env
GRAPE_REQUEST_PARAMS = 'grape.request.params'
GRAPE_ROUTING_ARGS = 'grape.routing_args'
GRAPE_ALLOWED_METHODS = 'grape.allowed_methods'
GRAPE_EXCEPTION = 'grape.exception'
end
end
16 changes: 16 additions & 0 deletions lib/grape/exceptions/internal_server_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module Grape
module Exceptions
# Raised internally when a +rescue_from+ handler itself raises an
# unrecognised exception. The framework substitutes the original
# exception with this safe stand-in for rendering, while preserving
# the original on +env[Grape::Env::GRAPE_EXCEPTION]+ for upstream
# observability (loggers, error trackers, etc.).
class InternalServerError < Base
def initialize
super(status: 500, message: compose_message(:internal_server_error))
end
end
end
end
1 change: 1 addition & 0 deletions lib/grape/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ en:
exactly_one: 'are missing, exactly one parameter must be provided'
except_values: 'has a value not allowed'
incompatible_option_values: '%{option1}: %{value1} is incompatible with %{option2}: %{value2}'
internal_server_error: 'Internal Server Error'
invalid_accept_header:
problem: 'invalid accept header'
resolution: '%{message}'
Expand Down
53 changes: 50 additions & 3 deletions lib/grape/middleware/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Error < Base
error_formatters: nil,
format: :txt,
grape_exceptions_rescue_handler: nil,
internal_grape_exceptions_rescue_handler: nil,
rescue_all: false,
rescue_grape_exceptions: false,
rescue_handlers: nil,
Expand All @@ -25,8 +26,8 @@ class Error < Base

attr_reader :all_rescue_handler, :base_only_rescue_handlers, :default_error_formatter,
:default_message, :default_status, :error_formatters, :format,
:grape_exceptions_rescue_handler, :rescue_all, :rescue_grape_exceptions,
:rescue_handlers
:grape_exceptions_rescue_handler, :internal_grape_exceptions_rescue_handler,
:rescue_all, :rescue_grape_exceptions, :rescue_handlers

def initialize(app, **options)
super
Expand All @@ -38,6 +39,7 @@ def initialize(app, **options)
@error_formatters = @options[:error_formatters]
@format = @options[:format]
@grape_exceptions_rescue_handler = @options[:grape_exceptions_rescue_handler]
@internal_grape_exceptions_rescue_handler = @options[:internal_grape_exceptions_rescue_handler]
@rescue_all = @options[:rescue_all]
@rescue_grape_exceptions = @options[:rescue_grape_exceptions]
@rescue_handlers = @options[:rescue_handlers]
Expand Down Expand Up @@ -127,10 +129,12 @@ def rescue_handler_for_any_class(klass)
all_rescue_handler || method(:default_rescue_handler)
end

def run_rescue_handler(handler, error, endpoint)
def run_rescue_handler(handler, error, endpoint, redispatched: false)
handler = endpoint.public_method(handler) if handler.is_a?(Symbol)
response = catch(:error) do
handler.arity.zero? ? endpoint.instance_exec(&handler) : endpoint.instance_exec(error, &handler)
rescue StandardError => e
return redispatch(e, endpoint, redispatched)
end

if error?(response)
Expand All @@ -142,6 +146,49 @@ def run_rescue_handler(handler, error, endpoint)
end
end

# Route an exception raised inside a +rescue_from+ block.
#
# * If we have already redispatched once (the redispatched handler
# itself raised), go straight to {#framework_default} — bounds the
# chain at one redispatch.
# * Else if the exception has a registered +rescue_from+ handler,
# run it.
# * Else if it's a +Grape::Exceptions::Base+ subclass, render it
# through +error_response+ with its own +status+ and +message+.
# * Else fall through to {#safe_default}, which lets the user opt
# in via +rescue_from :internal_grape_exceptions+ or, failing
# that, applies the framework default.
def redispatch(error, endpoint, already_redispatched)
return framework_default(endpoint) if already_redispatched

if (registered = registered_rescue_handler(error.class))
run_rescue_handler(registered, error, endpoint, redispatched: true)
elsif error.is_a?(Grape::Exceptions::Base)
run_rescue_handler(method(:error_response), error, endpoint, redispatched: true)
else
safe_default(error, endpoint)
end
end

# The unrecognised-error path. Exposes the original exception on
# the rack env so upstream Rack middleware (loggers, error
# trackers) can observe it. If the user registered a
# +rescue_from :internal_grape_exceptions+ handler, that handler
# runs and owns the response. Otherwise the framework renders the
# generic +InternalServerError+ — never the original exception's
# message. The framework deliberately does no logging of its own
# here; that's the application's call.
def safe_default(error, endpoint)
env[Grape::Env::GRAPE_EXCEPTION] = error
return run_rescue_handler(internal_grape_exceptions_rescue_handler, error, endpoint, redispatched: true) if internal_grape_exceptions_rescue_handler

framework_default(endpoint)
end

def framework_default(endpoint)
run_rescue_handler(method(:default_rescue_handler), Grape::Exceptions::InternalServerError.new, endpoint)
end

def error!(message, status = default_status, headers = {}, backtrace = [], original_exception = nil)
env[Grape::Env::API_ENDPOINT].status(status) # not error! inside route
rack_response(
Expand Down
Loading
Loading