Module 00: Error Handling Strategies
Why this matters
Every backend you'll ever write is mostly a machine for dealing with things
going wrong. The database connection drops mid-query. A downstream API returns
a 503. A user sends a JSON body that's missing a required field. A bug you
wrote three months ago divides by zero on exactly one row of production data.
The happy path — the code that runs when everything works — is the small,
easy part. The difference between a hobby script and a production service is
almost entirely in how it behaves when the happy path doesn't happen.
The failure mode that ends careers isn't the loud crash. It's the silent
one: the except: pass that swallowed a KeyError so a background job quietly
stopped processing payments for six hours, and nobody noticed because there was
no error, no log, no alert — just a number that slowly stopped going up. Loud
failures get fixed. Silent failures get discovered by customers. This whole
track is, at heart, about making a service fail loudly, safely, and
legibly — and error handling is the foundation the rest of it is built on.
This module is deliberately framework-light. Before you reach for FastAPI's exception handlers (module 01), you need a clear model of what an error is, which errors you should catch and which you should let propagate, and what it means to handle an error well versus merely making it stop appearing on your screen. Get this wrong and every later module — logging, metrics, alerting — is instrumenting a lie.
Concepts
Three kinds of errors: syntax, runtime, logical
Not all errors are the same, and the response to each is different.
Syntax errors are malformed code — a missing colon, an unclosed bracket. In Python these are caught at import/parse time, before your program runs at all. You cannot ship a syntax error to production in the normal sense; it fails immediately and locally. These are the cheapest errors because they're caught earliest. (Type checkers like
mypyand linters push even more errors into this "caught before running" bucket — that's their entire value.)Runtime errors happen while the program is running: a
KeyError, aConnectionError, aTimeoutError, aZeroDivisionError. The code is valid Python; the conditions at runtime made an operation impossible. These are what "error handling" usually means — they're the ones you catch, translate, retry, or let propagate. They can't be found by reading the code alone because they depend on data and on the state of the world.Logical errors are the worst: the code runs without raising anything, and produces the wrong answer. You compute a discount as
price * ratewhen it should beprice * (1 - rate). No exception fires. Nothing logs. The only defense is tests, assertions, and monitoring that checks outputs against expectations, not just "did it throw." A logical error that doesn't throw is exactly the silent failure this track exists to stamp out.
The mental model: push errors as far toward "syntax" as you can. A bug a type checker catches is cheaper than one a test catches, which is cheaper than one that raises in production, which is cheaper than a logical error a customer reports a week later. Every technique in this module is about moving errors earlier and making the ones that remain impossible to ignore.
cheapest ┌──────────┬─────────┬─────────────┬──────────────────┐ costliest
to fix │ syntax │ type/ │ runtime │ logical error │ to fix
│ error │ lint │ error │ (wrong answer, │
│ │ error │ (raises) │ no exception) │
└──────────┴─────────┴─────────────┴──────────────────┘
caught: parse pre-run in prod maybe never
time (mypy) (traceback) (customer finds it)
◄──────── push errors leftward ────────
Fail-fast vs fail-safe
When something goes wrong, you have two broad strategies, and choosing the right one per situation is a real engineering judgement call.
Fail-fast means: stop immediately, loudly, at the first sign that an assumption is violated. If a required config value is missing at startup, crash on boot rather than starting up and serving broken responses. If a function receives an argument that should be impossible, raise rather than guess. Fail-fast is about not continuing in a corrupted state — because every step you take after the corruption makes the eventual damage worse and the root cause harder to find.
Fail-safe (or fail-soft) means: when a non-essential part fails,
degrade to a reduced-but-working state instead of taking everything down. If
your recommendation service is down, show the page without recommendations
rather than returning a 500 for the whole page. If your cache is unreachable,
fall back to the database (slower, but correct) rather than erroring.
The art is knowing which to apply where:
- Fail-fast on startup and on invariant violations. Missing secrets, unparseable config, a database schema mismatch — crash now, before you take traffic. A pod that fails its readiness probe (module 09, and the k8s track) and never receives traffic is far better than one serving errors.
- Fail-safe on the request path for non-critical dependencies. The user's core action should still work when the "nice to have" pieces are down.
- Fail-fast on the request path for critical dependencies. If the
primary database is down, there is no safe degraded state for a "create
order" endpoint — return a clean
503, don't pretend the order succeeded.
The dangerous middle ground is accidental fail-safe: a broad try/except
that catches everything and returns a default, silently converting a critical
failure into a wrong-but-quiet answer. That's not fail-safe design; that's a
swallowed error wearing a disguise.
something went wrong
│
┌────────────┴────────────┐
startup / invariant? request path?
│ │
FAIL-FAST ┌──────────┴──────────┐
crash on boot, critical dep? non-critical dep?
don't take │ │
traffic FAIL-FAST FAIL-SAFE
clean 503, degrade + LOG + meter
don't fake it (serve reduced result)
Graceful degradation
Graceful degradation is fail-safe applied deliberately across a whole system. The principle: rank your features by how essential they are, and shed the non-essential ones under failure rather than collapsing entirely.
A concrete example on an e-commerce product page:
| Component | Essential? | On failure |
|---|---|---|
| Product name, price, "buy" button | Yes | Fail the request (503) — the page is pointless without these |
| Inventory count ("3 left!") | No | Hide the badge, serve the page |
| Personalized recommendations | No | Show a generic "popular items" fallback, or nothing |
| Recently-viewed items | No | Omit silently |
The implementation pattern is a scoped try/except (or a timeout with a
fallback) around each non-essential piece, so one failing component can't
take down the whole response. The critical distinction from "swallowing":
degradation is a deliberate, logged, and often metered decision — you log
that recommendations failed and increment a counter (module 06), so the
degradation is visible even though the user's request succeeded. Silent
swallowing hides the failure; graceful degradation contains it while keeping
it observable.
Catching errors early vs letting them propagate silently
There's a persistent beginner instinct to wrap everything in try/except
"to be safe." This is almost always wrong. The right questions are: can this
layer actually do something useful about this error? and if I catch it here,
does the information survive?
Catch an error at a layer only if that layer can meaningfully act on it —
retry, translate to a domain error, fall back, or add context and re-raise.
If a layer can't do anything useful, it should let the error propagate to one
that can. The worst pattern is catching an error deep in the stack, logging a
useless "something went wrong", and returning None — now the caller gets a
None it didn't expect, the real exception and its traceback are gone, and the
None causes a second, unrelated crash somewhere far away with no connection
to the real cause. You've turned one clear error into two confusing ones.
The healthy default is: let exceptions propagate to a place that can handle them properly (in a web app, that's the global handler from module 01), and only catch earlier when you're adding real value. "Catch early" means catch the specific error you can handle early — not catch everything early.
Never swallow errors
"Swallowing" an error means catching it and then neither handling it nor re-raising it, so it vanishes without a trace. The canonical crimes:
# CRIME 1: the bare except that eats everything, including KeyboardInterrupt
try:
result = process(payload)
except: # noqa: E722 — catches SystemExit, KeyboardInterrupt too!
result = None # the real error is gone forever
# CRIME 2: catch, "log", lose the cause
try:
charge_card(order)
except Exception:
logger.error("payment failed") # WHICH failure? no exception, no traceback
return {"ok": True} # and we told the user it worked. it did not.
# CRIME 3: broad catch that hides bugs you didn't anticipate
try:
total = sum(item.price for item in cart)
except Exception:
total = 0 # a typo in `.price` (AttributeError) now silently zeroes the bill
If you must catch broadly (e.g. a top-level worker loop that must not die), the rule is: catch, log the full exception with its traceback, and then either re-raise or record the failure somewhere it will be noticed — never just continue as if nothing happened.
try:
result = process(payload)
except Exception:
logger.exception("failed to process payload", extra={"payload_id": payload.id})
metrics_failed.inc() # make it visible in monitoring (module 06)
raise # or: route to a dead-letter queue, then continue
logger.exception(...) (inside an except block) automatically attaches the
active exception's full traceback — this is the single most important habit in
this module. logger.error("...") without it throws the traceback away.
Custom exception types
Python's built-in exceptions (ValueError, KeyError) describe mechanical
problems. Your application has domain problems: "this order can't be
cancelled because it already shipped," "this user isn't allowed to do that,"
"this coupon expired." Modelling those as custom exception types lets you
handle them precisely — a ShippedOrderError can map to a 409 Conflict,
while an InsufficientPermissionsError maps to 403, and both are
distinguishable from an unexpected KeyError (which is a bug, → 500).
class AppError(Exception):
"""Base for all expected, domain-level errors in this app."""
class NotFoundError(AppError):
def __init__(self, resource: str, resource_id: str):
self.resource = resource
self.resource_id = resource_id
super().__init__(f"{resource} {resource_id!r} not found")
class ConflictError(AppError):
"""A request that conflicts with current state (e.g. already shipped)."""
class PermissionError(AppError):
"""The caller is authenticated but not allowed to do this."""
Two design points that pay off constantly:
- A common base class (
AppError) lets a global handler distinguish your expected domain errors (safe to translate into a clean client message) from unexpected exceptions (bugs — log loudly, return a generic500). This exact split is the backbone of module 01. - Carry structured data on the exception (
resource,resource_id) not just a string message — so handlers and logs can use the fields, not re-parse a sentence.
Logging errors with stack traces
An error you can't diagnose is barely better than a silent one. The whole point of catching-and-logging (as opposed to letting it crash, which also logs a traceback) is to record enough context to fix it: what operation failed, on what input, and the full traceback showing where.
try:
user = fetch_user(user_id)
except DatabaseError:
logger.exception(
"failed to fetch user",
extra={"user_id": user_id, "operation": "fetch_user"},
)
raise
The traceback tells you where and how; the extra fields tell you on
what. A traceback alone often isn't enough — "DatabaseError on line 40" is
much less useful than the same traceback plus user_id=8123, operation= fetch_user. Module 04 formalizes what to put in a log line and why
structured (key/value) beats free-text; for now, the habit to build is:
every caught error gets its traceback AND the context needed to reproduce
it.
Command reference
| Tool / pattern | What it does | When to use |
|---|---|---|
raise |
Propagate an exception up the stack | Default — let a competent layer handle it |
raise NewError(...) from err |
Re-raise a new type, preserving the original cause | Translating a low-level error to a domain one |
raise ... from None |
Suppress the original cause in the chain | Rarely — when the underlying cause is noise/leaky |
except SpecificError: |
Catch exactly one (or a tuple of) known type(s) | Whenever you catch — be specific |
logger.exception(msg, extra=...) |
Log message + active traceback (inside except) |
Every caught error you don't re-raise immediately |
logger.error(msg, exc_info=True) |
Same traceback capture, outside a bare except |
When you have the exception object explicitly |
contextlib.suppress(Error) |
Deliberately ignore a specific expected error | Only when ignoring is genuinely correct |
custom AppError hierarchy |
Distinguish domain errors from bugs | Always, in any non-trivial service |
assert |
Check an invariant, fail-fast (dev only) | Guarding "impossible" states; not for validation |
A note on assert: it documents and enforces invariants, but Python strips
assert statements when run with -O (optimized mode), so never use it
for input validation or security checks — use an explicit raise for anything
that must hold in production.
Exception chaining, worth internalizing:
try:
row = db.query(...)
except psycopg.OperationalError as err:
# translate the low-level driver error into a domain error,
# but keep the original as __cause__ so the traceback shows BOTH.
raise ServiceUnavailableError("database unreachable") from err
The from err is what preserves the chain. The traceback will read
"ServiceUnavailableError … The above exception was the direct cause of the
following" — you get your clean domain error and the root cause. Dropping
from err (or worse, from None) throws away the diagnostic gold.
Hands-on exercises
Create a fresh project folder error-handling/ with a virtualenv. You mostly
need the standard library here; install structlog for the later exercises:
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install structlog
1. Classify three errors
Write a script with three functions, each triggering one category:
# 1. runtime error
def runtime_bug():
data = {"a": 1}
return data["b"] # KeyError at runtime
# 2. logical error (runs fine, wrong answer)
def apply_discount(price, rate):
return price * rate # BUG: should be price * (1 - rate)
# 3. syntax error — put this in a separate file and try to import it
# def broken( : # uncomment in syntax_demo.py to see the parse error
Run each. Expected: runtime_bug() raises KeyError: 'b' with a traceback;
apply_discount(100, 0.2) returns 20 with no error (the dangerous one —
it looks fine); the syntax file fails to import at all. Write one sentence
for each on when the error was caught (parse time / run time / never).
2. See what a swallowed error costs you
def charge(order_id):
raise ConnectionError("payment gateway timeout")
# BAD
def checkout_bad(order_id):
try:
charge(order_id)
return {"ok": True}
except Exception:
return {"ok": True} # swallowed — customer thinks they paid
print(checkout_bad("A1")) # {'ok': True} — a lie
Expected: checkout_bad returns success despite the charge failing. Now note
there is no log, no traceback, nothing — this failure is completely
invisible. This is the single most expensive bug pattern in backend
engineering.
3. Fix it with log-and-propagate
Rewrite checkout to catch, log the full traceback, and re-raise:
import structlog
log = structlog.get_logger()
def checkout_good(order_id):
try:
charge(order_id)
return {"ok": True}
except ConnectionError:
log.exception("charge failed", order_id=order_id)
raise # let a competent layer decide the response
Expected: calling checkout_good("A1") prints a structured log line with the
ConnectionError traceback and then propagates the exception. Compare the
console output to exercise 2 — the failure is now impossible to miss.
4. Build a custom exception hierarchy
Create the AppError / NotFoundError / ConflictError hierarchy from
Concepts. Write a cancel_order(order) that raises ConflictError if the
order's status is "shipped" and NotFoundError if order is None. Write a
caller that catches AppError and prints a friendly message, but lets any
other exception propagate:
try:
cancel_order(order)
except NotFoundError as e:
print(f"Nothing to cancel: {e}")
except ConflictError as e:
print(f"Can't cancel: {e}")
# note: no `except Exception` — a real bug should still crash loudly
Expected: domain errors produce friendly messages; if you introduce a typo
(e.g. order.staus) the resulting AttributeError is not caught and crashes
with a traceback — exactly what you want for a bug.
5. Preserve the cause with exception chaining
class ServiceUnavailableError(AppError): ...
def fetch_config():
try:
return open("/etc/app/config.json").read()
except FileNotFoundError as err:
raise ServiceUnavailableError("config missing") from err
Trigger it (the file won't exist) and read the traceback carefully. Expected:
you see ServiceUnavailableError: config missing, and above it "The above
exception was the direct cause…" with the original FileNotFoundError. Now
change from err to from None and re-run — the original cause disappears.
Understand what you just gave up.
6. Graceful degradation with scoped fallbacks
Model a product page assembled from four pieces, where the non-essential ones can fail independently:
def get_product_page(product_id):
product = get_product(product_id) # essential — let it propagate
page = {"product": product}
try:
page["recommendations"] = get_recommendations(product_id)
except Exception:
log.warning("recommendations unavailable", product_id=product_id)
page["recommendations"] = [] # degrade, but LOG it
try:
page["inventory"] = get_inventory(product_id)
except Exception:
log.warning("inventory unavailable", product_id=product_id)
page["inventory"] = None # hide the badge
return page
Make get_recommendations raise. Expected: the page still renders with an
empty recommendations list, a warning is logged, but the essential product
data is untouched. Then make get_product (essential) raise — confirm the
whole request fails, because there's no safe degraded state without it.
7. Fail-fast on startup
Write a load_settings() that reads a required DATABASE_URL from the
environment and raises immediately if it's missing:
import os, sys
def load_settings():
url = os.environ.get("DATABASE_URL")
if not url:
# fail-fast: better to crash on boot than serve broken requests
raise RuntimeError("DATABASE_URL is required but not set")
return {"database_url": url}
if __name__ == "__main__":
try:
settings = load_settings()
except RuntimeError as e:
print(f"FATAL: {e}", file=sys.stderr)
sys.exit(1) # non-zero exit — an orchestrator will not route traffic here
Run it once without DATABASE_URL (crashes with exit code 1) and once with it
set. Expected: no DATABASE_URL → immediate fatal exit; set → clean start.
This is the pattern module 02/03 formalizes with Pydantic Settings.
8. Diagnose and fix: the error handler that hides the real cause
You're handed this worker loop. Reports say "the queue processor sometimes stops making progress but never crashes." Find and fix the two bugs.
def process_forever(queue):
while True:
job = queue.get()
try:
handle(job)
except Exception as e:
log.error("job failed") # bug A
continue # bug B?
Solution
Bug A — the traceback is thrown away. log.error("job failed") records a
useless message with no exception info: you can't tell whether it was a
transient TimeoutError (retryable) or an AttributeError (a code bug that
will fail every time). Fix: log.exception("job failed", job_id=job.id) (or
log.error(..., exc_info=True)) so the full traceback and the offending job's
id are recorded.
Bug B is subtler — continue isn't wrong by itself, but unconditional
continue-on-everything is. A worker loop should keep running past a single
bad job (that's legitimate fail-safe design — one poison message shouldn't kill
the consumer). The bug is that it continues silently and identically for
every error type, so a systemic failure (e.g. the database is down and every
job now fails) looks exactly like one bad job — the loop spins, logs a flood of
useless "job failed" lines, and makes no progress, "never crashing" exactly as
reported. The fix is to make the failure visible and actionable: log with the
traceback (Bug A), increment a failure metric (module 06) so a spike is
alertable (module 08), and route the poison job to a dead-letter queue instead
of dropping it. Optionally, a circuit-breaker: if N consecutive jobs fail, stop
and fail-fast rather than spin forever.
def process_forever(queue, dead_letter):
consecutive_failures = 0
while True:
job = queue.get()
try:
handle(job)
consecutive_failures = 0
except Exception:
log.exception("job failed", job_id=job.id) # fix A: traceback + context
jobs_failed_total.inc() # fix: make it visible
dead_letter.put(job) # fix: don't lose the job
consecutive_failures += 1
if consecutive_failures >= 10:
log.critical("10 consecutive failures — halting", extra={"last_job": job.id})
raise # fail-fast on a systemic problem
The lesson: "never crashes" is not automatically good. A loop that never crashes but silently stops doing useful work is worse than one that crashes, because at least a crash gets noticed. Fail-safe (survive one bad job) and fail-fast (halt on a systemic problem) are both correct — applied to the right situations.
Independent challenge
No code given. Using only what's in this module, take a plain function
transfer_funds(from_acct, to_acct, amount) that talks to a database and an
external fraud-check API, and give it a complete error-handling strategy.
Decide, and write down your reasoning for, each of these: which failures are
fail-fast and which are fail-safe; which get a custom exception type and what
the hierarchy is; where you catch versus let-propagate; what each caught error
logs (and whether it re-raises); and which non-essential step (hint: the fraud
check might be advisory) could degrade gracefully versus which (the actual
debit/credit) must never silently succeed-or-fail. You should not need a web
framework — this is pure error-handling design, the exact thinking module 01
will then wire into FastAPI.
Hint
The money movement itself is the critical, must-fail-fast, must-never-swallow
operation — a swallowed error here is the exercise-2 catastrophe with real
dollars. The fraud check is where the interesting judgement lives: is a fraud
API timeout a reason to block a legitimate transfer (fail-closed, treat
unavailable as "deny") or to allow it and flag for review (fail-open, degrade
gracefully)? There's no universally right answer — but there is a wrong
answer, which is to make that decision accidentally via a broad try/except
instead of deliberately. Whatever you choose, it must be logged and metered so
the degradation is visible.
Common mistakes & troubleshooting
- Bare
except:(no exception type). CatchesKeyboardInterruptandSystemExittoo, so you can't even Ctrl-C the process. Always catchExceptionat the broadest, and prefer specific types. log.error("failed")instead oflog.exception(...)inside an except block. Throws the traceback away — the single most common way people cripple their own debugging. Insideexcept, uselogger.exception.- Broad
try/exceptthat returns a default. Silently converts unexpected bugs (a typo, a missing attribute) into wrong-but-quiet answers. Catch the specific error you expect; let the rest propagate. raise NewError()withoutfrom err. Loses the original cause from the traceback. Useraise NewError(...) from errwhen translating.- Using
assertfor validation.python -Ostrips asserts; the check vanishes in production. Use an explicitraisefor anything that must hold. - Catching too early "to be safe." If the layer can't do anything useful with the error, don't catch it there — let it reach a layer that can.
- Treating "it never crashes" as success. A service that silently stops doing work is worse than one that crashes loudly. Loud beats silent, always.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What are the three categories of error, and at what point (parse time / run time / possibly never) is each typically caught? Which is the most dangerous and why?
- Give a concrete example each of when fail-fast is the right strategy and when fail-safe is, and name the dangerous "accidental fail-safe" pattern.
- What does it mean to "swallow" an error, and what's the minimum you must do if you genuinely need to catch broadly in, say, a top-level worker loop?
- Why is
logger.exception(...)(orexc_info=True) inside an except block so important compared tologger.error("something failed")? - What's the point of a custom
AppErrorbase class shared by all your domain exceptions — how does a global handler use it? - What does
raise NewError(...) from errgive you thatraise NewError(...)alone does not?
Answers
- Syntax errors — caught at parse/import time, before the program runs. Runtime errors — caught while running (they depend on data/state). Logical errors — the code runs and raises nothing but produces a wrong answer, so they may be caught never (until a customer or a test notices). Logical errors are the most dangerous precisely because there's no exception to alert you — they're the archetypal silent failure.
- Fail-fast: a missing required secret/config at startup — crash on boot
rather than serve broken responses (also: violated invariants). Fail-safe:
a non-essential dependency (recommendations, cache) failing on the request
path — degrade to a reduced-but-correct response. The dangerous accidental
version is a broad
try/exceptthat returns a default, silently turning a critical failure into a wrong-but-quiet answer. - Swallowing = catching an exception and then neither handling it meaningfully
nor re-raising it, so it vanishes with no log/trace. If you must catch
broadly, the minimum is: log the full exception with its traceback
(
logger.exception), make it visible to monitoring (increment a failure metric / dead-letter the item), and then either re-raise or record the failure somewhere it will be noticed — never justcontinueas if fine. logger.exception(andlogger.error(..., exc_info=True)) automatically attaches the active exception's full traceback and type; a plainlogger.error("failed")records only a message, throwing away the where and the how — you can't tell a retryable transient error from a code bug.- A shared base lets a global handler cleanly split expected domain errors
(subclasses of
AppError— safe to translate into a specific, friendly client response) from unexpected exceptions (bugs — log loudly, return a generic500). It's the singleexcept AppErrorvsexcept Exceptionfork that module 01 is built around. from errpreserves the original exception as the__cause__, so the traceback shows both your clean domain error and the underlying root cause ("the above exception was the direct cause of…"). Without it, you keep only the new error and lose the diagnostic trail to what actually went wrong.
Further reading & sources
- Python: Errors and Exceptions - the language reference on exceptions, chaining (
raise ... from), andtry/exceptsemantics used throughout this module. - Python logging:
logger.exception- the standard-library method that captures the active traceback, the single most important logging habit here. - Google SRE Book — Handling Overload - how graceful degradation and load-shedding keep a service usefully alive instead of collapsing entirely.
- Martin Fowler — Fail Fast - the canonical short essay on why crashing loudly at the first sign of a broken assumption beats limping on.
- AWS Builders' Library — Timeouts, retries, and backoff with jitter - practical guidance on retrying transient failures without amplifying an outage.
Free PDF notes
Download these notes as a printable PDF, with every diagram and interview questions: SD-C-011: API Error Handling. Each section in one file: Phase 2, APIs (PDF).
Next
01-global-error-handlers-and-user-facing-errors
— you now have a strategy and a custom exception hierarchy; next you'll wire
that hierarchy into FastAPI with a single centralized handler that turns your
domain errors into safe, consistent client responses and your bugs into clean
500s that never leak a stack trace.