Skip to content

Catch exceptions raised inside rescue_from blocks (#2482)#2703

Merged
dblock merged 1 commit intomasterfrom
fix/rescue-from-internal-errors-2482
May 10, 2026
Merged

Catch exceptions raised inside rescue_from blocks (#2482)#2703
dblock merged 1 commit intomasterfrom
fix/rescue-from-internal-errors-2482

Conversation

@ericproulx
Copy link
Copy Markdown
Contributor

@ericproulx ericproulx commented May 8, 2026

Resolves #2482. Catches exceptions raised inside rescue_from blocks and routes them through one of three paths instead of letting them bubble up to Rack as a default 500 page.

Behavior

The re-raised exception is… What happens
A class with a registered rescue_from handler That handler runs (one redispatch only — a second raise from inside it stops the chain at the framework default, bounding loops)
A Grape::Exceptions::Base subclass with no registered handler Rendered via the default Grape error path using its own status and message
Anything else (vanilla StandardError, NoMethodError, etc.) Exposed on env['grape.exception']. If the user opted into rescue_from :internal_grape_exceptions, their handler runs and owns the response. Otherwise framework renders a generic Grape::Exceptions::InternalServerError (500). The original exception's message is never surfaced to the API consumer in this branch.

The third bucket is the safety property the issue thread was missing — neither the workaround in the issue body nor PR #2483 have it. A typo in a rescue_from block (e.g. rescue_from :all do; foo; end where foo is undefined) used to bubble up as a Rack 500 and now becomes a Grape 500 Internal Server Error with the actual NoMethodError observable in env['grape.exception'], but never rendered.

New rescue_from :internal_grape_exceptions opt-in

Mirrors the existing rescue_from :grape_exceptions keyword. When registered, the handler is invoked for any unrecognised internal error — typically what you want for forwarding to error trackers, custom logging, request-id correlation, etc.:

class TwitterAPI < Grape::API
  rescue_from :internal_grape_exceptions do |e|
    MyLogger.error("[#{env['x-request-id']}] #{e.class}: #{e.message}")
    Sentry.capture_exception(e)
    error!({ message: 'Something went wrong' }, 500)
  end
end

The framework hands you the original exception and steps out of the way. The framework does no logging of its own — formatting and destination are application concerns; :internal_grape_exceptions is where you opt in to whatever observability you want.

Why this resolves the issue

@ngernert's actual code in #2482 was:

rescue_from Grape::Exceptions::ValidationErrors do |e|
  begin
    raise Api::Exceptions::InvalidValueError, e.full_messages
  rescue Api::Exceptions::InvalidValueError => invalid_value_error
    run_rescue_handler(:error_response, invalid_value_error)
  end
end

After this PR, that simplifies to:

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

The first handler re-raises; the second handler is invoked through the redispatch.

How this differs from PR #2483

PR #2483 catches StandardError and forwards the caught exception to default_rescue_handler, which renders exception.message as the response body. That fixes the issue author's specific use case but:

  • Leaks internal detail for unrecognised exceptions. A typo (NoMethodError) ends up rendered as "undefined method 'foo' for nil:NilClass" to the API consumer.
  • Bypasses the user's rescue chain. A re-raised exception with a registered rescue_from handler is sent to the default handler, not the user's handler.
  • No application-controlled hook. The user can't intercept the unrecognised-error path to forward to Sentry, customize the response, etc.
  • Conflates two failure modes via the handler_exception || InvalidResponse fallback.

This PR addresses all of that: redispatch through the user's chain when applicable, render only Grape-aware exceptions with their own message, expose unrecognised exceptions in env, and let the application opt in to owning the unrecognised-error path entirely.

Files

File What
lib/grape/middleware/error.rb run_rescue_handler gains redispatched: kwarg; new private redispatch, safe_default, framework_default. New internal_grape_exceptions_rescue_handler option.
lib/grape/dsl/request_response.rb rescue_from :internal_grape_exceptions recognized as a keyword and stored in namespace_inheritable[:internal_grape_exceptions_rescue_handler].
lib/grape/endpoint.rb Pipes the new option through to the middleware. Extracted error_middleware_options helper since the kwarg list grew.
lib/grape/exceptions/internal_server_error.rb New Grape::Exceptions::InternalServerError < Grape::Exceptions::Base — status 500, i18n-backed generic message.
lib/grape/env.rb New Grape::Env::GRAPE_EXCEPTION = 'grape.exception' — exposed for upstream Rack middleware (also relevant to #2610).
lib/grape/locale/en.yml internal_server_error: 'Internal Server Error' i18n entry.
spec/grape/middleware/error_spec.rb 9 new examples covering all three buckets, the redispatch loop limit, the opt-in handler, the opt-in handler also raising, the InvalidResponse path preservation, and env['grape.exception'] exposure.
README.md New paragraph in the rescue_from section documenting re-raise semantics and the opt-in.
UPGRADING.md Note documenting the behaviour change and the opt-in.

Test plan

  • bundle exec rspec spec/grape/middleware/error_spec.rb — 16 examples, 0 failures (9 new + 7 existing)
  • bundle exec rspec — full suite green (2,263 examples, up from 2,254)
  • bundle exec rubocop lib/ spec/ — clean

Note on observability

Stashing the original exception on env['grape.exception'] overlaps with the request in #2610 (Rails-style action_dispatch.exception equivalent). It's a free side-effect of the safe-default path here. If maintainers want full #2610 coverage — exposing the original exception in env for every swallowed exception, not just re-raises from rescue_from blocks — that's a separate small change in error_response / call!.

🤖 Generated with Claude Code

@ericproulx ericproulx force-pushed the fix/rescue-from-internal-errors-2482 branch 6 times, most recently from 8fa211c to 95b26c7 Compare May 8, 2026 17:56
@ericproulx ericproulx force-pushed the fix/rescue-from-internal-errors-2482 branch from 95b26c7 to eebd072 Compare May 8, 2026 18:00
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

Danger Report

No issues found.

View run

@ericproulx ericproulx requested a review from dblock May 8, 2026 18:44
@ericproulx ericproulx force-pushed the fix/rescue-from-internal-errors-2482 branch from eebd072 to 8101553 Compare May 9, 2026 17:48
Resolves #2482. An exception raised inside a `rescue_from` block
used to bubble uncaught and produce the Rack default 500 page. The
middleware now catches it and routes it through one of three paths:

  1. The exception's class has a registered `rescue_from` handler →
     run that handler (with `redispatched: true` so a second raise
     stops the chain — bounded at one redispatch).
  2. The exception is a `Grape::Exceptions::Base` subclass → render
     it via the default Grape error path with its own status and
     message.
  3. Otherwise → fall through to safe_default. The original is
     exposed on `env['grape.exception']` for upstream Rack middleware
     to observe. If the user opted into
     `rescue_from :internal_grape_exceptions do |e| ... end`, that
     handler runs and owns the response. Otherwise the framework
     renders `Grape::Exceptions::InternalServerError` (status 500,
     generic message) — never the original exception's message.

The framework deliberately does no logging of its own for
unhandled internal exceptions. Formatting and destination are
application concerns; the `:internal_grape_exceptions` handler is
where the application opts in to logging, error-tracker forwarding,
or any other observability it cares about.

Adds:
  * `Grape::Exceptions::InternalServerError` — i18n-backed safe
    stand-in.
  * `Grape::Env::GRAPE_EXCEPTION` — env key carrying the original.
  * `rescue_from :internal_grape_exceptions` — DSL opt-in mirroring
    `:grape_exceptions`. Lets the application capture the exception
    and shape the response.

Existing behaviour preserved when the handler returns a non-Response
non-error value (still produces `InvalidResponse` via the default
handler).

UPGRADING note + README rescue_from section document the new
behaviour and the `:internal_grape_exceptions` opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ericproulx ericproulx force-pushed the fix/rescue-from-internal-errors-2482 branch from 8101553 to 006cc72 Compare May 9, 2026 18:56
@dblock dblock merged commit 55c7a51 into master May 10, 2026
79 checks passed
@dblock dblock deleted the fix/rescue-from-internal-errors-2482 branch May 10, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How to handle exception raised in rescue_from block?

2 participants