Skip to content

Error Handling

Failing at the boundary, catching only what you can act on, and leaving the system in a valid state when something breaks midway.

Intermediate19 minUpdated 27 September 2026

The happy path is the part of a function you can hold in your head. The network succeeds, the file exists, the user types a number. It is also the part that takes the least time to write.

Everything else — the timeout, the malformed row, the half-finished transaction — is where the design work actually lives. Error handling is not defensive clutter bolted onto working code. It is the second interface your function offers, and callers depend on it exactly as much as they depend on the return value.

Fail fast, at the boundary

The cheapest error to handle is the one you reject before doing any work.

def place_order(price, quantity, tax_rate):
    subtotal = price * quantity
    tax = subtotal * tax_rate
    charge_card(subtotal + tax)
    if price < 0:                      # too late
        raise ValueError("invalid price")

The card has already been charged. Validating first is not merely tidier — it is the difference between a rejected request and a corrupted one:

def place_order(price, quantity, tax_rate):
    if price < 0:
        raise ValueError("price cannot be negative")
    if quantity <= 0:
        raise ValueError("quantity must be positive")
    if not 0 <= tax_rate <= 1:
        raise ValueError("tax_rate must be between 0 and 1")
    ...

The discipline generalises: validate at the edge of your system, then trust the data inside it. Parse the request once at the boundary, convert it into a type that cannot be invalid, and every function downstream stops re-checking. Scattering the same null check through fifteen layers is what happens when nobody owns the boundary.

Exceptions or error values

Two families of mechanism, and the choice is about who is expected to handle the failure.

ExceptionsReturned error values
Visible in the signatureNo — callers must read docsYes — the type says so
Easy to ignoreYes, by not catchingHard; the compiler or linter nags
Cost when thrownStack unwinding, relatively expensiveNone
SuitsGenuinely exceptional, unrecoverable-here failuresExpected outcomes the caller must decide about

The useful test: is this failure part of the normal range of outcomes? A user submitting a duplicate email is expected — model it as a result the caller must handle. A disk becoming unreadable mid-write is not — let it propagate.

Getting this backwards is common in both directions. Using exceptions for ordinary validation turns control flow into invisible jumps. Returning None for a genuine system failure invites callers to ignore it and crash later with a confusing message.

Catch only what you can act on

Conceptual diagramnoyesyesnonoyesSomething failedCan this code act on it?Let it propagateTransient and safe to repeat?Retry with backoffIs the feature essential?Degrade, log, continueFail with a clear, true message
Diagram, as text
  • Something failed leads to Can this code act on it?
  • Can this code act on it? leads to Let it propagate (no)
  • Can this code act on it? leads to Transient and safe to repeat? (yes)
  • Transient and safe to repeat? leads to Retry with backoff (yes)
  • Transient and safe to repeat? leads to Is the feature essential? (no)
  • Is the feature essential? leads to Degrade, log, continue (no)
  • Is the feature essential? leads to Fail with a clear, true message (yes)

A try block should wrap the smallest amount of code that can fail, and catch the narrowest type you know how to handle.

try:
    config = json.loads(path.read_text())
except FileNotFoundError:
    config = DEFAULTS                     # a real recovery
except json.JSONDecodeError:
    raise ConfigError(f"{path} is not valid JSON")   # add context, re-raise

Two anti-patterns dominate real codebases.

Swallowing. except Exception: pass deletes the evidence. The email is never sent, nothing is logged, and the bug is discovered by a customer months later. If you catch it, log it or re-raise it — never neither.

Catching too broadly. A bare except Exception around fifty lines also catches the typo in a variable name, turning a five-second fix into a mystery. Broad catches belong in exactly one place: a top-level handler that logs the failure and returns a generic error to the caller.

When you cannot handle a failure but can explain it, wrap and re-raise. Preserve the original — raise ConfigError(...) from err in Python, %w in Go — or you throw away the stack trace that tells you where it started.

Leave the system in a valid state

The dangerous failures are not the ones that stop work. They are the ones that stop it halfway.

def transfer(source, destination, amount):
    source.debit(amount)
    notify(source.owner)        # raises: the mail server is down
    destination.credit(amount)  # never runs — the money has vanished

Money left the first account and never arrived at the second. No exception surfaces to the user as "your funds are gone", so nobody investigates.

Two rules prevent this:

Make the change atomic. Wrap the state changes in a database transaction so a failure rolls back all of them. Cross-service work needs an explicit pattern — sagas with compensating actions — because there is no shared transaction to roll back.

Do side effects last, outside the transaction. Emails, webhooks and push notifications cannot be rolled back. Commit the state change first, then queue the notification, so a mail outage cannot corrupt your data.

Degrade instead of collapsing

Not every dependency deserves the power to take the page down. Separate what is essential from what is decoration.

If recommendations fail, a product page should render without them. If the reviews service fails, show the product and a quiet placeholder. The judgement is per feature: can a user still accomplish the core task? If yes, catch the failure, log it, and return an empty result.

The failure mode this creates is a slow dependency rather than a broken one. A recommendation service taking 30 seconds to time out holds the whole page hostage, so degradation needs two companions:

Timeouts on every external call. A call with no timeout inherits the timeout of whatever is below it, which may be none. Set one explicitly at every boundary.

A circuit breaker so you stop calling a service that is clearly down. The pattern is a small state machine:

CLOSED     → calls pass through; count consecutive failures
             failures exceed threshold → OPEN

OPEN       → fail immediately without calling; start a cooldown timer
             cooldown elapses → HALF_OPEN

HALF_OPEN  → let one trial call through
             success → CLOSED     failure → OPEN

Failing instantly while OPEN is the point: it frees the threads that would have been blocked waiting, and it gives the struggling service room to recover instead of hammering it with retries.

Retry only what is safe to retry

Retrying a failed call is the obvious response and the easiest way to make an outage worse.

Only retry transient failures. A 500, a timeout or a connection reset may succeed on a second attempt. A 400 or a 404 will fail identically every time; retrying wastes capacity and delays the error the caller needs.

Only retry idempotent operations, or supply an idempotency key so the server can recognise the repeat. Otherwise a lost response turns one payment into two.

Back off exponentially, with jitter. Retrying immediately, in lockstep, from a thousand clients is a self-inflicted denial of service — a thundering herd that arrives precisely when the service is weakest. Randomising the delay spreads the load.

Messages for users, detail for logs

One failure produces two audiences with opposite needs.

The user needs to know what happened and what to do: "That card was declined. Try another payment method." Stack traces and SQL fragments are noise to them and a map of your internals to an attacker.

The log needs everything: the exception type, the stack trace, the request or correlation ID, and the inputs that matter. Include an identifier in the user-facing message so a support ticket maps to a specific log line.

Check your understanding

Common mistakes

Swallowing exceptions. except Exception: pass is the single most expensive line in this lesson. Log it or re-raise it.

Losing the original error. Catching and throwing a fresh exception without chaining discards the stack trace that identifies the real cause.

Using exceptions for control flow. Throwing to exit a loop, or to signal "not found" on a lookup that misses half the time, hides the flow and costs more than a return value.

One generic error type everywhere. If everything raises Exception, callers cannot distinguish a recoverable case from a fatal one and end up catching all or nothing.

Error messages without context. "Operation failed" is unactionable. Say which operation, on which entity, with which value.

No timeout on external calls. The default in most HTTP clients is to wait indefinitely, which converts a slow dependency into a full outage.

Practice

An order endpoint calls a payment provider, writes an order row, and enqueues a confirmation email. Describe what should happen if each one fails, and what the customer is told.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Rank the three by whether the failure is recoverable and whether the customer's money moved.

Show a strong answer

Take them in order, because the correct behaviour depends on what has already happened.

Payment fails. Nothing else has happened yet, so this is the clean case. Do not write the order, do not enqueue anything. Return a clear error: "Payment was declined; you have not been charged." The message must be true — if the failure was a timeout rather than a decline, you do not know whether money moved, and the message must say so: "We could not confirm your payment. Do not retry; we will email you within minutes." Then reconcile against the provider.

Payment succeeds, the order write fails. This is the dangerous one: the customer has been charged and you have no record. Options, in order of preference:

  • Write the order first as pending, then charge, then mark it paid. This inverts the problem — a failure now leaves a pending order with no charge, which is recoverable and invisible to the customer.
  • If the order write genuinely fails after a charge, do not lose it: record the charge somewhere durable (an outbox or an incident queue) and reconcile. Never return a generic 500 and discard the fact that money moved.

Email enqueue fails. The order exists and is paid. The request should succeed. A confirmation email is a nice-to-have; failing the whole order because a queue is unavailable turns a minor outage into lost revenue. Log it, alert on the rate, and let a reconciliation job send missing confirmations later.

The principle: order the steps so the irreversible one happens last, and let a failure after the commit point degrade rather than fail.

Your API returns `500 Internal Server Error` with the body `{ "error": "Something went wrong" }` for every failure. List what this costs, and design what it should return instead.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Think about the client developer, the support agent and the on-call engineer separately.

Show a strong answer

What it costs, by audience:

  • The client developer cannot tell a bug in their request from a bug in your service. They cannot decide whether to retry, fix their input, or escalate — so they retry everything, including the requests that will never succeed.
  • The support agent has nothing to give the customer and nothing to search for.
  • The on-call engineer cannot correlate a user's report with a log entry, because there is no identifier connecting them.

It also mislabels client errors as server errors, which corrupts your own error-rate metrics and can trip alerts and error budgets over requests that were simply invalid.

What it should return:

  • A status code that means something. 400 for malformed input, 422 for well-formed but invalid, 401/403 for authentication and authorisation, 409 for a conflict, 429 for rate limiting, 5xx only when it is genuinely your fault.
  • A stable, machine-readable code — insufficient_funds, not prose. Clients branch on this; prose changes and breaks them.
  • A human-readable message safe to show a user, containing no internal detail.
  • Field-level detail for validation failures, so the client can highlight the right input rather than showing a banner.
  • A correlation ID, returned to the client and written to the logs. This single field is what turns "it broke" into a searchable incident.

What must never appear: stack traces, SQL, internal hostnames or library versions. Those go to the logs, keyed by the correlation ID — detail for logs, messages for users.

Key takeaways

  • Error handling is the second interface your function offers, not an afterthought.
  • Validate at the boundary, then trust the data inside — ideally by parsing into types that cannot be invalid.
  • Use exceptions for the genuinely exceptional and returned errors for expected outcomes the caller must decide about.
  • Catch the narrowest type you can act on; never swallow, and chain when you re-raise so the original cause survives.
  • The dangerous failure is the half-finished one. Make state changes atomic and push unrollbackable side effects outside the transaction.
  • Degrade rather than collapse, backed by explicit timeouts and a circuit breaker so a slow dependency cannot hold the page hostage.
  • Retry only transient failures on idempotent operations, with exponential backoff and jitter.
  • Give users an actionable message, logs the full detail, and share an identifier that links the two.

Further reading

  • RFC 9457, Problem Details for HTTP APIs — a standard shape for machine-readable error responses.
  • Michael Nygard, Release It!, second edition (Pragmatic Bookshelf, 2018) — failing fast and degrading deliberately.
  • Andrew Gerrand, "Errors are values" (The Go Blog, 2015) — the case for errors as ordinary return values.
  • Joe Armstrong, Making Reliable Distributed Systems in the Presence of Software Errors (doctoral thesis, 2003) — let it crash, and supervise.
  • Google SRE, Site Reliability Engineering (O'Reilly, 2016), chapter 22 — retries as an amplifier.

Check yourself

3 questions in this lesson. Answer each once to complete it.

Practice this

Next lessonConcurrency & Thread Safety
Lessons in this track