Code Quality

Error Handling Best Practices: Designing Failure Paths That Actually Work

Every catch block in this codebase looks reasonable in its own diff. That's exactly the problem. This guide is about why exception handling fails as a routing problem, not a resilience one, and what has to change about how the routing gets enforced, not just written.

8 min read · 8 Principles ·
Blue geometric star sculpture with multiple pointed facets on a matching blue background.

TL;DR

  • An empty or debug-logged catch block doesn't prevent a failure - it hides one, specifically from the tools built to detect it.
  • Catching a base exception type or leaving failure modes undifferentiated means the code that's supposed to distinguish retry from give-up can't.
  • Wrapping an exception without its cause keeps your message and throws away the evidence of where the failure actually started.
  • An outcome that happens in normal operation is a return value, not an exception - and a returned error nobody checks is the same bug, backwards.
  • Exactly one frame should decide a failure ends there and log it once; every layer logging, or every layer deciding, produces duplicate incidents or none.
  • A cleanup block or retry loop without a guaranteed release or a maximum attempt count turns a local failure into a leak or an outage.
  • The detail that makes an exception debuggable is the same detail that must never reach a response body outside the team - precision for one audience is exposure for another.

What Exception Handling Discipline Actually Protects

Ask five engineers why a stack trace ended up in a response body and four will describe a debugging session that ran long under deadline pressure. That's usually true, and it's still the wrong frame. The engineer who left the detail in wasn't cutting a corner — they were doing the fastest, most defensible thing available to someone who could only see their own catch block, not the response it would eventually sit inside.

None of the seven principles below are really about carelessness. They're about the fact that the right call from inside a single catch block and the right call for every audience that failure information will reach are different decisions, and the gap between them is invisible from any seat with visibility into only one block.

1. Every Swallowed Exception Fabricates Success

A payment webhook handler wraps the gateway call in a try/except, logs at debug level, and returns 200 regardless. Weeks later, finance flags a reconciliation gap with no error anywhere in the metrics - the operation has been reporting success the entire time it was failing. Never write a catch block that's empty, logs only at debug, or returns a default without recording the failure. Every catch handles the condition, translates it into a domain error, or rethrows it.

ANTI-PATTERN — a catch block that fabricates success
try:
    charge_result = payment_gateway.charge(order.id, amount)
except Exception:
    pass  # don't block checkout
return {"status": "ok"}
CORRECTED — the failure is translated, not hidden
try:
    charge_result = payment_gateway.charge(order.id, amount)
except GatewayTimeoutError as exc:
    raise PaymentProcessingError(order.id) from exc

Raising here means checkout now has to handle a failure path it didn't visibly have before. That complexity was always there - this just stops hiding it.

2. Catch-All Handlers Erase Failure Identity

A service catches the base Exception type around an entire request handler to be safe. That one block now intercepts a null pointer bug, a cancelled request, and a genuine downstream timeout identically - same log line, same fallback, same alert. On-call can't tell a defect from an outage from the alert alone. Catch the specific exception types the operation can produce, and declare a distinct type per failure mode so callers don't have to string-match a message to tell them apart.

ANTI-PATTERN — a base-type catch erases which failure occurred
try {
    inventory.reserve(sku, qty);
} catch (Exception e) {
    log.warn("reserve failed", e);
    return Result.fail("try again");
}
CORRECTED — specific types, one per failure mode
try {
    inventory.reserve(sku, qty);
} catch (InsufficientStockException e) {
    return Result.outOfStock(sku);
} catch (WarehouseTimeoutException e) {
    throw new ReservationUnavailableException(sku, e);
}

This is the same instinct behind ordering catches from most-derived to least-derived: a base-type catch isn't a shortcut, it's a decision to stop distinguishing failure modes the moment they'd actually matter.

3. Wrapping Without a Cause Erases Evidence

A repository layer catches a SqlException and re-throws a new exception built from a message string - no inner exception attached. In the incident, the stack trace ends at the repository. Nobody can see the constraint violation, the timeout, or which of three queries inside that method actually failed. Always pass the caught exception as the cause when throwing a new one; never construct the replacement from a message string alone.

CORRECTED — the cause travels with the new exception
catch (SqlException ex) {
    throw new OrderPersistenceException("failed to save order", ex);
}

PEP 3134's exception chaining, and the equivalent cause-attribute conventions in Java and .NET, exist for the same reason: the new exception is allowed to add meaning, but it isn't allowed to replace it.

There's no real cost to this one. The only reason it gets skipped is that the wrapping line is usually written fast, and the second argument is easy to forget.

4. Expected Outcomes Are Values, Not Exceptions

An inventory check throws an exception for a condition that happens on roughly 8% of checkouts - routine, not exceptional. To keep the happy path readable, checkout wraps the whole operation in one broad try/catch, which now also silently absorbs a database error thrown three calls deeper. Represent outcomes that occur in normal operation as return values or result types, never exceptions, and never discard a returned error without inspecting it.

CORRECTED — the expected outcome is a typed value
function findUser(id: string): Result<User, NotFoundError> {
  const user = db.get(id);
  return user ? Ok(user) : Err(new NotFoundError(id));
}

Go's error-as-value convention and Rust's Result type both encode this at the language level: a caller has to look at the value before it can pretend nothing went wrong.

Result types push error handling into the type system, which means more boilerplate at every call site. The payoff is that a missing check is a compiler error instead of a production incident.

5. One Handler Logs Each Failure Once

Three layers of a request each catch, log, and rethrow the same exception just in case. One real failure produces three near-identical alerts at three stack depths, and on-call opens three tickets before realizing they're one incident. Register exactly one top-level handler per entry point that logs the exception once with a correlation ID and returns a generic response.

ANTI-PATTERN — logged here, and again by the framework default
def handle_order(req):
    try:
        process(req)
    except Exception as e:
        logger.error("order failed", exc_info=e)
        raise
CORRECTED — one handler, one log line, one response
@app.errorhandler(Exception)
def handle_uncaught(e):
    cid = current_request.correlation_id
    logger.error("unhandled", exc_info=e, extra={"cid": cid})
    return {"error": "internal_error", "correlation_id": cid}, 500

Centralizing the log line this way means individual handlers lose the ability to add throwaway context at the point of failure. The fix is structured context attached to the exception as it propagates, not a fourth log line at every hop.

6. Cleanup and Retries Need Explicit Limits

A batch job closes a temp file inside a finally block that swallows the close error, since we're already failing anyway. In a different service, a client retries a downstream call in a loop with no maximum count — during the next partial outage, that loop is the load that finishes the dependency off. Acquire resources with a construct that guarantees release on every path, and bound every retry with a maximum attempt count and backoff.

A retry loop with no maximum attempt count isn't resilience - it's a denial-of-service attack you're running against your own dependency.

The try-with-resources guarantee in Java, and the equivalent using/defer/context-manager constructs in C#, Go, and Python, exist because a close call in a finally block is exactly the kind of thing that gets wrapped in its own forgotten try/except.

7. Exception Detail Is a Data Leak

A staging config with verbose error pages gets promoted to production during a rushed deploy. For six hours, every 500 on one endpoint returns the full stack trace and the value of the field that failed validation — including, in a handful of requests, another customer's data echoed into the message. Never place exception detail in a response body, and never interpolate a field value into an exception message; identify the failure by field name and record ID only.

No one decided to leak that. Every individual catch block along the way looked reasonable in its own diff - which is exactly why this rule is the hardest one to catch in review.

The discipline that makes an exception useful for debugging is exactly the discipline that makes it dangerous to expose. Across a codebase with thousands of catch blocks written by people who each made a locally reasonable call, that's not a training gap. It's a coverage gap - which is exactly what the next section is for.

How Pandorian Enforces This Guide

Individually, each rule above is a judgment call a competent engineer makes correctly most of the time. The problem is that "most of the time," multiplied across every service and every engineer who's joined since this was written down, becomes a codebase where the exception-handling shape is different in every file. Pandorian enforces the shape across three surfaces:

Caught at PR level

Every pull request is checked before merge. A catch block that swallows an exception, a message that interpolates a field value, a retry loop with no bound - each surfaces with the line, the rule, and a suggested fix, before it ships.

Full scans

An on-demand scan across the whole codebase establishes the day-one baseline: every existing violation, including the ones written before this guideline existed and the ones in services no one's touched in two years.

Scheduled scans

The same full scan, run on a cadence, catches drift introduced outside any PR - a config flag flipped in a runbook, a dependency upgrade that changes default error verbosity, anything the PR check doesn't cover.

A Taste of the Guideline Package

Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.

RULE WHAT IT FLAGS
REL_38874
Catch blocks that are empty, log only at debug level, or return a default without recording the failure.
SEC_45431
Exception messages or custom exception fields that interpolate a request payload, field value, or entity.
REL_52097
Retry loops or recursive calls inside a catch block with no maximum attempt count or backoff policy.
OBS_51986
An exception logged at more than one frame of the call stack for a single failure.

FAQ

What is exception handling discipline?

+

Exception handling discipline is the practice of deciding, deliberately and consistently, which failures a system recovers from, which it propagates, and which it refuses to continue past. It covers what gets caught, what gets logged, what gets wrapped, and what reaches the caller. The goal is that every failure is either handled by code that can actually do something about it, or surfaced loudly.

The failure mode it prevents is the quiet one: a system that keeps returning 200 while doing nothing, because an exception was caught somewhere by code that had no idea what to do with it.

What is exception swallowing?

+

Exception swallowing is catching an exception and then continuing as if nothing happened, usually via an empty catch block or a catch that only logs at debug level. The code compiles, tests pass, and the failure becomes invisible. It is the single most common way a production bug survives for months before anyone notices the data was wrong all along.

The variant that hides best is catch-and-log-and-return-null, because it produces a log line nobody reads and a null that fails somewhere else entirely.

What is the difference between a recoverable and an unrecoverable error?

+

A recoverable error is a failure the caller has a meaningful alternative response to: a network timeout it can retry, a validation failure it can report to a user, a missing optional resource it can default. An unrecoverable error is one where no correct continuation exists, such as a corrupt configuration or a violated invariant, and the right behavior is to fail fast rather than proceed on bad state.

The distinction matters because it decides where the catch belongs. Recoverable errors are caught by the layer that owns the alternative. Unrecoverable ones should reach the process boundary untouched.

Is it ever acceptable to catch a generic Exception?

+

Yes, at exactly two kinds of boundary: the top-level handler that converts any unexpected failure into a clean response and an alerted error record, and the edge of a long-running worker or consumer loop that must not die because one message was malformed. Both are deliberate, both re-raise or record, and neither swallows.

Everywhere else, a broad catch is a way of saying you did not know what could fail. It catches the timeout you anticipated and also the type error you introduced last week, and it treats them identically. Catch the specific exception types the operation can actually produce, and let the rest travel.

How do I tell if our error handling is actually hiding failures?

+

Three greps and one question. Search for empty catch blocks and catches whose body is a single log call. Search for broad catches (except Exception, catch (Throwable), catch (err)) outside your top-level handlers. Then check whether your error rate metric and your alerting are derived from the same place your code writes caught exceptions, or only from uncaught ones.

The question: in your last incident, how long between the first failed operation and the first alert? A long gap almost always means something caught the error and kept going.

Can't static analysis catch bad error handling?

+

tatic analysis catches a narrow slice: an empty catch, an unused exception variable, an unreachable branch. It cannot judge whether the catch is at the right layer, whether the error type is specific enough for the operation, whether the log carries enough context to act on, or whether swallowing was in fact the correct call in this one place. Those need the rule your organization wrote, not a generic default.

That is the gap automated governance fills: your own error handling standard checked against every pull request, with full scans establishing where the existing swallowed exceptions already live.

Get the Guideline Package

You'll get this as a guideline MD file - hand it straight to your own coding agent or CI setup. Or skip the DIY step: give these same guidelines real enforcement superpowers, with PR checks and scans live on your repos, through Pandorian.

From Principles to Enforcement

Every rule in this guide is easy to agree with in a design review and easy to violate six sprints later, one locally-correct decision at a time. Pandorian turns boundary discipline like this into CI guardrails - scoped to the repos, teams or services you choose.