Error handling in Rails, and the reporter you are not using
The error you can read in your log is not the error your error tracking service saw, and the gap
between the two is a design decision you probably made by accident. A rescue_from in
ApplicationController is a decision. An inline rescue in one action is a decision. So is doing
nothing, because doing nothing is what gets the error reported.
Rails 8.1 has three separate mechanisms here and they do not compose the way the names suggest.
rescue_from turns an exception into a response. Rails.error turns an exception into a report.
config.exceptions_app turns an exception into a page. Everything below was reproduced against
rails 8.1.3.1 on Ruby 4.0.5, in a throwaway application driven by rack-test, and the numbers are
copied out of that run.
What rescue_from is made of
ActiveSupport::Rescuable is 176 lines and holds one piece of state: a class_attribute named
rescue_handlers, which is an array of [class_name, handler] pairs. rescue_from appends to it.
The comment on the append says what matters:
# Put the new handler at the end because the list is read in reverse.
self.rescue_handlers += [[key, with]]
The reverse read is at rescuable.rb:129, inside find_rescue_handler:
_, handler = rescue_handlers.reverse_each.detect do |class_or_name, _|
if klass = constantize_rescue_handler_class(class_or_name)
klass === exception
end
end
Last declared wins. Not most specific wins, which is what the inheritance hierarchy of your
exception classes would suggest and what every reader assumes on first pass. With
CardDeclined < PaymentError, the order of two lines decides which handler runs:
declared specific last:
specific handler: CardDeclined
declared specific FIRST (subclass shadowed by the later, broader handler):
generic handler: CardDeclined
Same two handlers, same raised exception, opposite outcome. The rule to carry is that a broad
rescue_from declared below a narrow one disables the narrow one, and neither the class nor the
boot process says anything about it.
Two small guards are worth knowing because they fire at class definition time rather than at
request time. rescue_from PaymentError with no with: and no block raises
ArgumentError: Need a handler. Pass the with: keyword argument or provide a block., and
rescue_from :not_a_class, with: :x raises
ArgumentError: :not_a_class must be an Exception class or a String referencing an Exception class.
The String form exists for lazy constant lookup, and constantize_rescue_handler_class tries a
lexical const_get before safe_constantize, so rescue_from "Error" declared in a superclass
resolves to the subclass's own nested Error.
The handler that runs for an exception nobody raised
rescue_with_handler does not stop at the exception it is given. When no handler matches, it
recurses into exception.cause:
if handler = handler_for_rescue(exception, object: object)
handler.call exception
exception
elsif exception
if visited_exceptions.include?(exception.cause)
nil
else
rescue_with_handler(exception.cause, object: object, visited_exceptions: visited_exceptions)
end
end
So this, with only rescue_from PaymentError declared:
begin
raise CardDeclined, "declined by the issuer"
rescue
raise RuntimeError, "could not complete the order"
end
produces pay handler ran for CardDeclined: declined by the issuer. A RuntimeError was raised,
a PaymentError handler ran, and the argument the handler received is the CardDeclined buried two
frames down, not the RuntimeError at the top. Write a handler that renders exception.message and
your user reads the message from an exception your code never let out.
This is usually what you want. Any gem that wraps and re-raises, and ActiveRecord::StatementInvalid
is the one everybody meets, would otherwise slip past a handler written for the error underneath.
Knowing it happens is the difference between a useful rescue and a confusing one.
What happens when the handler itself blows up
rescue_from's documentation ends with a sentence that is easy to misread: "Exceptions raised inside
exception handlers are not propagated up." The accurate reading is narrow. The handler's exception is
not fed back into rescue_handlers, so a second handler cannot catch it and there is no recursion.
It propagates out of the controller exactly like an unhandled error.
Measured, with a handler whose body raises Boom while handling a NotAllowed:
GET /handler_raises -> raised into the caller: Boom: the handler itself blew up
reported=["Boom source=application.action_dispatch"]
The response is a 500 and the thing your service now has on file is Boom, with no trace of the
NotAllowed that started it. A handler that calls redirect_back_or_to or reads
exception.record.errors is one nil away from replacing a legible failure with an illegible one.
Keep handler bodies to a render or a redirect with no lookups in them.
Where the handler belongs
rescue_from earns its place when one exception class has one answer everywhere. Authorization is
the archetype: rescue_from Pundit::NotAuthorizedError, with: :deny in ApplicationController is
one line replacing a rescue in forty actions, and every one of those actions wants the same 403.
It is the wrong tool when the answer depends on the action. The LaunchKit boilerplate has zero
rescue_from declarations in app/, and rescues in the action body instead:
def stripe
Billing::Webhooks::Handler.new(verified_event).call
head :ok
rescue JSON::ParserError, Stripe::SignatureVerificationError
head :bad_request
end
Three lines of rescue sitting against the four lines they protect. A reader of
WebhooksController#stripe can see the whole contract without opening another file, which is the
argument for inline rescue and it is a strong one. The argument against is that the same three lines
appear in email_confirmations_controller.rb, passwords_controller.rb and
sessions/omniauth_controller.rb in slightly different shapes, and nothing keeps them consistent.
Pulling cross-cutting rescues into a concern is the middle position, and
Rails concerns is about when that helps and when it just moves the code.
The cost of the recommendation, stated plainly: every rescue_from you add is an exception your
error tracking stops seeing, for the reason in the next section, and you will not notice until the
day a bug hides behind one of them.
The rescued error nobody hears about
Rails reports unhandled errors from a middleware. ActionDispatch::Executor#call, at
executor.rb:36, wraps the whole downstream stack:
rescue Exception => error
request = ActionDispatch::Request.new env
backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
wrapper = ExceptionWrapper.new(backtrace_cleaner, error)
@executor.error_reporter.report(wrapper.unwrapped_exception, handled: false, source: "application.action_dispatch")
raise
Middleware sits above the controller. An exception a rescue_from handles never gets that high, so
it is never reported. Three routes through one application, with a subscriber counting calls:
GET /boom -> 500 reported=["Boom handled=false severity=error source=application.action_dispatch"]
GET /rescued -> 403 reported=[]
GET /nope -> 404 reported=[]
/rescued raises, renders a clean 403, and the rails error reporting above it never runs. That is correct
for an authorization denial and wrong for the rescue_from ActiveRecord::RecordInvalid somebody adds
six months later to stop a form crashing. The crash was the signal. The handler removed it.
The fix is one line in the handler body, and it costs nothing, for a reason worth its own section.
The report that only happens once
ErrorReporter#report begins and ends with the same trick:
def report(error, handled: true, severity: handled ? :warning : :error, context: {}, source: DEFAULT_SOURCE)
return if error.instance_variable_defined?(:@__rails_error_reported)
and, after the subscribers have run:
while error
unless error.frozen?
error.instance_variable_set(:@__rails_error_reported, true)
end
error = error.cause
end
An instance variable on the exception object, propagated down the entire cause chain. Three
report calls on one error produced one subscriber call. Reporting a wrapper and then separately
reporting its cause produced one, because the cause was stamped by the first call.
The practical consequence is the useful one. Report where you know the most. Inside the action you
have the order id, the plan, the third-party response code; twelve frames up in a middleware you have
an exception and a URL. Wrap the code that knows those things in
Rails.error.record(context: { order_id: order.id }), or call Rails.error.report from its rescue,
and the middleware's later report is discarded rather than duplicated. Verified end to end, with a
record block in the action and nothing else changed:
GET /recorded -> 500
reports: 1
Boom handled=false severity=error source=application
One report, and its source is "application" rather than "application.action_dispatch", meaning
the contextful one won and the generic one was dropped.
The deduplication is per exception object, not per message or per class, so two separate raises of the same class in one request are two reports. And an error that has been frozen skips the stamp entirely, which is the one case where you can get a duplicate.
handle, record, and the severity nobody sets on purpose
Rails.error.handle and Rails.error.record are seven lines each and differ in one keyword and one
default.
def handle(*error_classes, severity: :warning, context: {}, fallback: nil, source: DEFAULT_SOURCE)
error_classes = DEFAULT_RESCUE if error_classes.empty?
yield
rescue *error_classes => error
report(error, handled: true, severity: severity, context: context, source: source)
fallback.call if fallback
end
record is the same body with severity: :error, no fallback, handled: false, and raise
instead of the fallback call. So handle swallows and files a warning; record re-raises and files
an error. If you have been reaching for handle because the name sounds neutral, you have been
downgrading the severity of everything you report.
Measured behaviour, in order: handle { raise TypeError } returned nil;
handle(fallback: -> { :fallback_value }) { raise } returned :fallback_value;
handle { 42 } returned 42 with no report; record { raise ArgumentError, "kept" } reported and
then let ArgumentError: kept out.
DEFAULT_RESCUE = [StandardError].freeze is the list used when you name no classes, so
handle { raise Exception, "not a StandardError" } does not swallow. A SignalException or a
NoMemoryError walks straight through a bare handle block, which is the right call and is not what
"handle any unhandled error" sounds like.
Naming classes narrows it the way rescue does, and a mismatch is not an error, it is a pass-through:
handle(KeyError) { raise TypeError } let the TypeError out. Severity, by contrast, is validated,
and only at report time: report(..., severity: :critical) raises
ArgumentError: severity must be one of :error, :warning, :info, got: :critical. So does passing a
non-exception: Reported error must be an Exception, got: "just a string".
Swallowing on purpose
The place handle is exactly right is a degraded-mode read against something optional. A recommender,
a preview renderer, a third-party avatar fetch:
suggestions = Rails.error.handle(Recommender::Error, severity: :info,
context: { feature: "recommendations" },
fallback: -> { [] }) do
Recommender.for(current_user)
end
That shape, run in a controller action against a stub that raises, returned [], rendered a 200 and
filed one report at severity: :info whose context carried feature: "recommendations" next to the
controller Rails had already put there. The same block written as a bare
rescue Recommender::Error; [] renders the same 200 and files nothing, which is how a feature comes
to be silently broken for three weeks. That is the entire argument for the rails error reporter over
rescue plus a log line, and the same argument appears from the other side in
Transactions and rollback in Rails, where swallowing is the
thing that quietly commits half your work.
What a subscriber is handed
A subscriber is any object with a report method, checked at subscribe time and nowhere else:
unless subscriber.respond_to?(:report)
raise ArgumentError, "Error subscribers must respond to #report"
end
The signature is report(error, handled:, severity:, context:, source:). During a request, context
arrives already populated, because ActionController::Metal::Instrumentation#process_action sets
ActiveSupport::ExecutionContext[:controller] = self at instrumentation.rb:60, and report merges
ExecutionContext.to_h under whatever you passed. Printed from inside a subscriber during a real
request:
subscriber saw Boom handled=false severity=error source=application.action_dispatch
context keys: [:controller]
controller: ThingsController
and for a handle call with its own context:
context keys: [:controller, :feature]
controller: ThingsController
feature: "recommendations"
Active Job sets ExecutionContext[:job] the same way, in execution.rb:66. Beyond those two, the
context is yours: Rails.error.set_context(section: "checkout") anywhere in the request, or
Rails.error.add_middleware(->(error, context:, **) { context.merge(release: ENV["GIT_SHA"]) }) once
at boot, which is how you get a release tag onto every report without touching a call site.
The subscriber that takes your application down with it
A subscriber that raises is the failure mode this API is most exposed to, because a reporting service
is a network call and network calls fail. The documentation says "The report method should never
raise an error", and the enforcement is conditional:
rescue => subscriber_error
if logger
logger.fatal(
"Error subscriber raised an error: #{subscriber_error.message} (#{subscriber_error.class})\n" +
subscriber_error.backtrace.join("\n")
)
else
raise
end
With no logger, a subscriber raising IOError inside a handle block that was swallowing a
TypeError gave this:
escaped the reporter: IOError: the reporting service is down
A block whose entire purpose was to swallow raised something worse than what it swallowed. With a
logger set, the same code returned nil and wrote one FATAL line. Rails sets
Rails.error.logger = Rails.logger during boot, in bootstrap.rb:70, so a real application is on
the safe branch. A subscriber registered in a test harness, a rails runner script or a Rake task
that built its own ErrorReporter is not, and neither is anything that assigned
Rails.error.logger = nil.
The 400s your service sees in development only
The Rails guide is unambiguous: "For HTTP requests, errors present in
ActionDispatch::ExceptionWrapper.rescue_responses are not reported as they do not result in server
errors (500)." The mechanism is one line in show_exceptions.rb:38:
request.set_header "action_dispatch.report_exception", !wrapper.rescue_response?
which ActionDispatch::Executor reads at executor.rb:22 before deciding to report. Same
application, same controller, one config difference:
### production shape (no reloader)
GET /missing -> 400 reported=[]
### development shape (reloader present)
GET /missing -> 400 reported=["ActionController::ParameterMissing handled=false severity=error source=application.action_dispatch"]
The cause is a one-line class in reloader.rb:14:
class Reloader < Executor
end
ActionDispatch::Reloader is inserted only when reloading is enabled, and it sits at position 10 in
the stack, below ShowExceptions at 8. The flag check at executor.rb:22 belongs to the outer
Executor at position 2, which never sees the exception because ShowExceptions already turned it
into a response. The inner Reloader does see it, and takes the rescue Exception branch at
executor.rb:36, which reports unconditionally.
ActionController::RoutingError escapes this because it is raised above the Reloader, at
debug_exceptions.rb:35, when the router hands back X-Cascade: pass. So a 404 stays quiet in both
environments and a 400 does not.
The practical damage is small and the lesson is not. If you are calibrating alert thresholds against what your development machine reports, you are calibrating against a different middleware stack than the one in production.
What the 500 page can and cannot say
ActionDispatch::PublicExceptions is the default exceptions_app and it is 64 lines. For an HTML
request it reads a file off disk:
def render_html(status)
path = "#{public_path}/#{status}.#{I18n.locale}.html"
path = "#{public_path}/#{status}.html" unless (found = File.exist?(path))
if found || File.exist?(path)
render_format(status, "text/html", File.read(path))
else
[404, { Constants::X_CASCADE => "pass" }, []]
end
end
File.read, nothing else. No ERB, no layout, no helpers, no database. That constraint is the whole
design: the page renders after the application that would have rendered a nicer one has already
failed, so it cannot depend on the application working. Delete the file and the failure is silent:
### without public/500.html
HTML -> 500 ct=text/html; charset=UTF-8
body: ""
Status 500, zero bytes, a blank white page in the browser. Non-HTML requests never touch the file at
all and get a generated body: {"status":500,"error":"Internal Server Error"}.
What the page should not say is the exception. The default page says "We're sorry, but something went wrong. If you're the application owner check the logs for more information", and the omission is deliberate.
This site keeps that copy and argues with the template about something else: a meta tag. The
generated public/500.html carries <meta name="robots" content="noindex, nofollow"> on line 11.
The one here carries no robots tag at all, and a comment in its place records why:
<!-- No robots meta tag here on purpose. The HTTP status code is the signal, and it is the
accurate one: a 404 de-indexes on its own, a 5xx means "come back later". A noindex tag on
an error page overrides that with "never index this URL", which turns any transient
failure into a de-indexing instruction ... -->
That happened. /yield and /quiz were both reported as "Excluded by noindex tag" in Search Console
while serving perfectly indexable pages, because a crawler caught a transient 5xx and read the
noindex on the error page as the answer for the URL. The LaunchKit boilerplate still ships the
generated tag, which is right for an application behind a login that nobody is indexing. On anything
with public pages in it, the status code is already the honest signal and the tag overrides it.
Giving the error page a request id
Pointing config.exceptions_app at a controller buys back everything PublicExceptions gives up.
The env carries what you need:
class ErrorsController < ActionController::Base
def show
status = request.path_info[1..].to_i
render plain: "status=#{status} request_id=#{request.request_id} original=#{request.env["action_dispatch.original_path"]}",
status: status
end
end
Output on a failed request, with the response header alongside it:
status=500 request_id=f1484b36-634c-49cb-add1-28c8a1db015c original=/boom
header X-Request-Id: f1484b36-634c-49cb-add1-28c8a1db015c
The id in the body is the same id ActionDispatch::RequestId put in the header and the same one
Rails.logger tagged the request with, which turns "something went wrong" into "something went wrong,
quote f1484b36 at support". That is the one piece of information a 500 page should add.
The env also carries action_dispatch.exception, and rendering exception.message from it printed
internal: password rotation failed straight into the response body. Nothing stops you. The whole
reason PublicExceptions can only read a flat file is that a flat file cannot make that mistake, and
a controller can make it in one line.
The trade is real: an exceptions app is your application, so it can fail, and then
ShowExceptions#render_exception catches it and emits a plain-text failsafe body that begins
"500 Internal Server Error. If you are the administrator of this website...". Keep the errors
controller free of database calls, current_user, and the layout, or accept that your nice page has a
worse page behind it.
Reporting to a service without naming it in your code
The subscriber interface is the decoupling, and it is a real one. Rails.error.report in your code,
Rails.error.subscribe in one initializer, and the service's name appears in exactly one file:
# config/initializers/error_subscriber.rb
class LogSubscriber
def report(error, handled:, severity:, context:, source:)
Rails.logger.error({ error: error.class.name, message: error.message,
handled:, severity:, source:,
controller: context[:controller]&.class&.name }.to_json)
end
end
Rails.error.subscribe(LogSubscriber.new)
Swapping vendors means editing that file. Multiple subscribers are supported, so routing errors to a
log aggregator and an error tracker at once is two subscribe calls, and
Rails.error.disable(Subscriber) { ... } mutes one for a block.
Now the part that matters before you assume this is already wired up for you. Rails error tracking in production means one of three gems for most people, and the three give three different answers about the reporter, checked here in their source rather than in their marketing:
sentry-rails 7.0.0, released 2026-09-01, MIT, 120.9 million downloads. It ships
Sentry::Rails::ErrorSubscriber and does not register it. In configuration.rb,
@register_error_subscriber = false, with the comment: "Rails 7.0 introduced a new error reporter
feature, which the SDK once opted-in by default. But after receiving multiple issue reports, the
integration seemed to cause serious troubles to some users. So the integration is now controlled by
this configuration, which is disabled (false) by default." Sentry still captures your unhandled 500s,
through its own Sentry::Rails::CaptureExceptions middleware inserted after ShowExceptions. What it
does not capture, unless you set that flag, is every Rails.error.handle and Rails.error.record
call you wrote.
honeybadger 6.9.2, released 2026-09-15, MIT, 40.4 million downloads. Calls
::Rails.error.subscribe(ErrorSubscriber) in its Rails plugin, gated on exceptions.enabled, whose
default is true. Opposite answer, same week.
bugsnag 6.30.0, released 2026-04-13, MIT, 67.4 million downloads. No ErrorSubscriber anywhere in
lib/, on the released gem here or on master. It hooks ActiveSupport::Notifications and rescues in
its own middleware, so a Rails.error.report call never reaches it.
So "libraries like Sentry and Honeybadger automatically register subscribers", which is what the Rails
guide says, is true for one of the two. Check yours by calling Rails.error.report(StandardError.new("subscriber smoke test"))
from a console attached to staging and looking at the dashboard. If nothing arrives, the wiring is
not there.
The call, and what would change it
Use rescue_from only for exception classes whose answer is identical in every action, and add
Rails.error.report(exception) to the handler body unless you can say out loud why the failure is
uninteresting. Rescue inline for everything else, because the rescue belongs next to the call that can
fail. Reach for Rails.error.handle when a feature is genuinely optional and you want a fallback plus
a warning rather than silence, and for record when you want the report attached to the context and
the exception to keep going.
Keep public/500.html as your 500 page until you have an actual use for the request id. An
exceptions_app is more moving parts in the one code path that runs when the application is already
broken, and the failsafe behind it is a plain-text paragraph. When a support inbox starts filling with
"it just said something went wrong", the id is worth the risk, and not before.
What would change the recommendation: if sentry-rails flipped register_error_subscriber to true,
the reporter would become the one place to report from regardless of vendor, and the case for calling
it everywhere would stop needing this much explanation. If Rails reported errors handled by
rescue_from (an env flag the handler could clear, rather than silence by default) the main cost of
the first paragraph above would disappear.
The position has a cost and it is discipline. "Report from the handler" is a convention, not a
mechanism, and nothing in the framework fails when somebody forgets. A rescue_from added in a hurry
to stop a page 500ing is indistinguishable, at review time, from one that was thought about, and the
difference only shows up as an alert that never fires.
What this post does not cover
The LaunchKit boilerplate has no error tracking gem in its Gemfile, no rescue_from in app/, and
no call to Rails.error anywhere. Neither does this sales site. Every measurement above comes from
the framework source and from throwaway applications built for this post, which is the honest
provenance for a page about a feature neither codebase uses yet.
Also absent: Active Job's error path, where retry_on and discard_on both take report: and both
default it to false (active_job/exceptions.rb:64 and :109), so a job that retries five times and
gives up reports nothing unless you ask; Rails.error.unexpected, which raises in development by
wrapping your error in ActiveSupport::ErrorReporter::UnexpectedError, a direct subclass of
Exception that a bare rescue will not catch, and reports quietly in production; rescue_from in
Action Cable and Action Mailbox, which include the same Rescuable module (connection/base.rb:62,
channel/base.rb:115, action_mailbox/base.rb:67) against very different lifecycles;
and error grouping, fingerprinting and rate limiting, which are properties of the service you send to
rather than of Rails.
Comments
No comments yet. Be the first.