rescue Exception and Other Ways to Hide the Body

The scariest bugs aren't the ones that crash — they're the ones that get rescued into silence. On swallowed errors, overly broad rescues, and failing in ways you can debug.

all good!rescue Exceptionnothing to see here
Bob
Senior Backend Engineer
Feb 4, 2026
6 min read

We once spent three days hunting a bug where customer data exports were coming back subtly incomplete. No errors in the tracker. No failed jobs. No angry logs. Everything green across the board — except the exports were missing about 4% of rows, seemingly at random.

The eventual culprit was six characters long: rescue nil. Someone, years earlier, had wrapped a per-row transformation in rescue nil to get past a flaky edge case during a demo, and it had been silently eating rows ever since. The error wasn't gone. It was just hidden, and hiding an error is so much worse than raising one, because raising is loud and hiding compounds.

I want to talk about the ways we hide bodies in our codebases, usually with good intentions, and what failing safely actually looks like.

The sins, in ascending order of severity

Rescuing StandardError when you meant one thing

The honest version of most rescues is "I expect this specific thing to go wrong." A network call can time out. A JSON payload can be malformed. But what gets written is often:

def fetch_exchange_rate(currency)
  response = RatesApi.get(currency)
  JSON.parse(response.body)["rate"]
rescue StandardError
  DEFAULT_RATE
end

This code intends to handle "the rates API is down." What it actually handles is everything: the typo you just introduced (NoMethodError), the nil that snuck in (NoMethodError again), the schema change in the response (KeyError), the bug in your parsing logic. All of them get converted into DEFAULT_RATE, silently, forever. You didn't handle those errors. You just deleted the evidence.

Rescue the narrowest class that represents the failure you actually anticipated — Faraday::TimeoutError, JSON::ParserError — and let everything else raise. An exception you didn't foresee is information. Don't shred it.

rescue Exception

StandardError at least leaves the runtime alone. rescue Exception in Ruby catches things that were never yours to catch: SignalException (so your process ignores kill and deploys hang), SystemExit (so exit doesn't), NoMemoryError, Interrupt (so Ctrl-C stops working — you've probably fought a script like that). There's almost no legitimate reason to write it. When I see it in a diff, it's nearly always a search-and-replace from another language's idiom or, increasingly, a coding assistant translating a Python except Exception: habit into Ruby. Either way, it should not survive review.

The empty rescue

begin
  sync_to_crm(user)
rescue StandardError
  # best effort
end

I have sympathy for this one, because "best effort" is sometimes the genuine requirement — the CRM sync really shouldn't block signup. But silent best effort is how you find out six months later that the CRM has been empty since March. The requirement is "don't block signup," not "tell no one." Minimum viable version:

begin
  sync_to_crm(user)
rescue Faraday::Error => e
  Rails.logger.error("CRM sync failed for user #{user.id}: #{e.class}: #{e.message}")
  ErrorTracker.notify(e, user_id: user.id)
end

Same resilience, but now the failure has a paper trail, a count, and an owner.

Failing safely is a design question, not a syntax question

The rescue keyword is the easy part. The real questions live one level up.

Fail closed or fail open? When the fraud-check service is down, does checkout proceed (fail open) or block (fail closed)? There's no universal answer — it's a business decision — but it should be a decision, made on purpose and written down, not an accident of which line the rescue landed on. I've reviewed code where an authorization check failed open because a rescue returned nil and nil was falsy... except the caller checked unless denied?. Nobody chose that behavior. It just emerged.

Is the fallback distinguishable from success? My DEFAULT_RATE example above has a second, sneakier problem: callers can't tell a real rate from the fallback. If the default leaks into a customer invoice, no one downstream knows it's fiction. Returning a result object — or at minimum logging loudly and tagging the value — keeps degraded data from impersonating good data.

Does the error message help the 3 a.m. responder? "Something went wrong" in a log is a tiny act of cruelty toward your future self. A good failure carries context: what operation, on what entity, with what inputs (minus secrets), and what the system decided to do about it. The five seconds it takes to interpolate user.id into the message can save an hour of correlating timestamps later.

Where's the boundary? Not every method should rescue. In fact, most shouldn't. Deep code should raise rich, specific errors; the boundary — the controller, the job, the CLI entry point — decides what failure means for this operation: retry, fallback, 422, or page a human. Rescues sprinkled at every layer mean the failure gets handled four times, differently, and the boundary never learns the truth.

A note on reviewing this stuff

Error handling is where I slow down most in code review, because the mistakes are invisible in the happy path and every test suite loves the happy path. Things I explicitly look for:

  • Any rescue broader than the failure the surrounding code can actually anticipate.
  • Rescues that return a default — then I trace the callers and ask what happens when the default is wrong.
  • retry inside a rescue with no attempt counter (hello, infinite loop).
  • Rescue blocks with no logging, no tracking, no comment explaining the silence.

And yes: generated code deserves extra scrutiny here. Assistants are trained to produce code that runs, and wrapping risky lines in broad rescues is a great way to make code run. I regularly see generated snippets where the rescue exists purely to make the demo path smooth — the exact same instinct as my rescue nil friend from the export bug, just faster at typing.

The checklist I actually use

  1. Rescue the narrowest class you can name. If you can't name it, you're not handling it — you're suppressing it.
  2. Never rescue Exception. Practically ever.
  3. No silent rescues. Every swallowed error gets, at minimum, a log line with context and a ticket to your error tracker.
  4. Decide fail-open vs. fail-closed on purpose, and leave a comment saying which one you chose and why.
  5. Push handling to the boundary. Deep code raises; edges decide.
  6. Make error messages carry context — the operation, the entity, the input shape.
  7. Test the sad path. One test per rescue branch, or the branch doesn't really exist.

The goal was never zero errors. Errors are the system telling you the truth. The goal is a system that fails loudly in development, gracefully in production, and informatively everywhere — because the alternative isn't fewer bodies. It's just better-hidden ones.

error-handlingrubyreliabilitydebuggingcode-review
Written by
Bob
Senior Backend Engineer · Firetrail review team
More Backend