Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 7 – Errors, exceptions and context managers

Designing error handling for real programs

Where to catch, log, convert or re-raise errors in Python, how to show users safe messages and exit statuses, and how to retry temporary failures.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 20 minutes

What you will learn

  • Decide where to catch, log, convert or re-raise errors
  • Write safe, actionable error messages and return correct exit statuses
  • Log uncaught exceptions with sys.excepthook and threading.excepthook
  • Add retries with backoff and jitter for temporary failures

Before you start

A helper that catches an error, prints a line and carries on can turn one bad price out of three into a crash in another function that no longer names the price, or into a wrong total with no error at all. Good error handling is mostly a decision about where each error is dealt with. Helpers raise and let the error travel. At each module boundary, a low-level error such as ValueError becomes a custom exception: An exception class a program defines for its own errors, derived from Exception, such as SeatTakenError. It can keep the details of the error as attributes for the code that handles it. More in “Raising exceptions and custom error classes” of your own domain, such as OrderError, chained to the original. At the program’s top-level boundary, such as main() or a request handler, the error is caught: the details go to a log, the user gets a short message that says what to do, and the program returns an exit status: The small number a program hands back to whatever started it: 0 for success, anything else for failure. Shells, CI jobs and schedulers read it; by convention 2 means a wrong command line and 1 any other error. or an error response.

Below you will build each layer in examples you can run, add two hooks for what slips past them, and retry temporary failures with waits measured on 1,000 clients that fail together.

Most functions should let an error travel

When an exception reaches your code, there are four reasonable choices:

  1. Let it propagate. Most functions should not catch anything. If a function cannot do anything useful about an error, the error belongs to its caller.
  2. Add context and pass it on. e.add_note("while reading orders.csv") adds a line to the traceback, and raise OrderError(...) from e replaces a technical error with one the caller understands, keeping the original as __cause__: The earlier exception named by raise ... from ..., the stated reason for the new one. The traceback prints it first, followed by "The above exception was the direct cause of the following exception". More in “Exception chaining, notes and tracebacks”.
  3. Handle it. Recover for real: use a default, skip a bad record and count it, retry a call that failed for a temporary reason.
  4. Report it at the boundary. Log the details, tell the user what happened and what to do, and set the exit status or the response.

The tempting fifth choice, catching an error deep inside a helper, printing something and carrying on, is the one to avoid. Here read_price() does exactly that:

An error handled too early Python · swallowed.py
def read_price(text):
    try:
        return float(text)
    except ValueError:
        print("could not read price")  # handled too early: the caller cannot tell
        return None


def order_total(lines):
    return sum(read_price(price) * int(qty) for price, qty in lines)


try:
    print(order_total([("12.50", "2"), ("abc", "1"), ("4.00", "3")]))
except Exception as e:
    print(f"the caller sees {type(e).__name__}: {e}")

Output

could not read price
the caller sees TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 swallowed.py

The real problem is the price "abc". Step through the three order lines and watch what happens to its ValueError:

Interactive · message by message One bad price, handled too early Step through the three order lines of swallowed.py. Watch what happens to the ValueError of the bad price, and what the caller sees instead.
One bad price, handled too early, step 8 of 8. Messages so far: The caller to order_total(): order_total(lines); order_total() to read_price(): read_price("12.50"); read_price() to order_total(): returns 12.5; order_total() to read_price(): read_price("abc"); read_price() to order_total(): returns None; order_total() to The caller: TypeError.The callerprint(order_total(lines))order_total()price times quantityread_price()float(text), or Noneorder_total(lines)read_price("12.50")returns 12.5read_price("abc")returns NoneTypeErrorthe caller sees a TypeError

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Three order lines. The caller passes three lines to order_total(), each a price and a quantity, ("12.50", "2"), ("abc", "1") and ("4.00", "3"), inside a try statement.
  2. A good price. order_total() asks read_price() for the first price.
  3. 12.5 times 2. float("12.50") works, so read_price() returns 12.5, and order_total() multiplies it by the quantity, 2.
  4. The bad price. For the second line, read_price() gets the text "abc".
  5. Caught too early. float("abc") raises ValueError, whose message names the bad value. read_price() catches it, prints "could not read price" and returns None, and the ValueError is gone.
  6. None travels on. order_total() cannot tell None from a price, so it multiplies None by the quantity, 1.
  7. A different error. None * 1 raises TypeError: unsupported operand type(s) for *: 'NoneType' and 'int', in a function that did nothing wrong.
  8. What the caller sees. The TypeError reaches the caller, which prints it. Nothing in it names the value "abc" or its line, so finding the real problem now means reading the code.

Because read_price() swallowed the ValueError and returned None, the error that finally escapes is a TypeError about NoneType, raised in another function, and it no longer says which value was bad. A caught exception that is not really handled turns one clear error into a later, confusing one, or into a wrong total with no error at all.

Key idea

Catch an error only where you can do something about it; everywhere else, let it travel, adding context on the way if you know something the error does not.

Checkpoint

Check: What to do with an error that reaches your code

  1. One question In swallowed.py the caller sees a TypeError about NoneType. Where did the real problem happen?

    Choose one answer.

    A hint

    Follow the None back to the function that made it up.

    Show the answer: One question

    Answer: In read_price(), where float("abc") raised a ValueError that was caught and turned into None

    read_price() caught the ValueError of "abc", printed a line and returned None. The error that finally escaped, a TypeError in order_total(), says nothing about the bad price: one clear error became a later, confusing one.

A module turns low-level errors into one error of its own

A module’s callers should not need to know how it works inside. The diagram shows the three layers an error passes on its way up, and what each layer does with it:

Three layers: helpers raise, a module boundary converts errors into domain errors, the top-level boundary catches and reports.Helpers: open(), float(), anetwork call Raise. Add context withadd_note(); never printand carry onModule boundary: load_orders(),an API client Convert to a domain error:raise OrderError(...) from e.Retry temporary failures hereTop-level boundary: main(),a request handler, a job runner Catch, log the details, showa short message, return anexit status or a responseFileNotFoundError,ValueError, TimeoutErrorOrderError, withthe cause chained

Where each error is handled, from the helpers up to the boundary

Text description of the diagram

The diagram shows three layers of a program, with errors travelling upwards.

  1. At the bottom are the helpers, such as open(), float() or a network call. They raise exceptions and may add context with add_note(), but they never print a message and carry on as if nothing had happened.
  2. In the middle is a module boundary, such as a function that loads orders or a client for an API. Low-level errors such as FileNotFoundError, ValueError or TimeoutError arrive here and are converted into an error of the program's domain, for example raise OrderError(...) from e, which keeps the original as the cause. Temporary failures are retried at this layer.
  3. At the top is the program's boundary: the main() function of a command-line tool, a request handler or a job runner. The domain error, with its cause chained, arrives here. This layer catches it, logs the details for developers, shows the user a short message, and returns an exit status or an error response.

A function that loads orders may fail because a file is missing, a number does not parse or a column is absent, which raise FileNotFoundError, ValueError and KeyError: three exception types for one kind of failure. Callers want one answer to one question, “did loading the orders fail, and how do I tell the user?”. So the module catches the low-level errors it expects at its edge and raises its own exception, with a message written for the person who will read it:

A command-line tool: domain errors, messages and exit statuses

Python (try_cli.py) · cli/try_cli.py

import sys

from orders_cli import main

sys.stderr = sys.stdout  # show error messages in the same output, in order
for argv in (["orders.csv"], [], ["missing.csv"], ["bad_orders.csv"]):
    print("$", " ".join(["python3 orders_cli.py", *argv]))
    print("exit status", main(argv))

Output

$ python3 orders_cli.py orders.csv
3 orders, total 1155.50
exit status 0
$ python3 orders_cli.py
usage: orders_cli.py FILE
exit status 2
$ python3 orders_cli.py missing.csv
error: missing.csv does not exist; check the name and the folder
exit status 1
$ python3 orders_cli.py bad_orders.csv
error: bad_orders.csv, line 3: price and qty must be numbers
exit status 1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 try_cli.py

Python (orders_cli.py) · cli/orders_cli.py

"""Total the orders in a CSV file: python3 orders_cli.py FILE"""
import csv
import sys


class OrderError(Exception):
    """A problem the user can fix; the message says what to do."""


def load_orders(path):
    try:
        with open(path, newline="", encoding="utf-8") as f:
            rows = list(csv.DictReader(f))
    except FileNotFoundError as e:
        raise OrderError(f"{path} does not exist; check the name and the folder") from e
    orders = []
    for line, row in enumerate(rows, start=2):  # line 1 holds the column names
        try:
            orders.append((float(row["price"]), int(row["qty"])))
        except (KeyError, TypeError, ValueError) as e:
            raise OrderError(f"{path}, line {line}: price and qty must be numbers") from e
    return orders


def main(argv):
    """Run the command and return its exit status: 0 success, 1 error, 2 wrong usage."""
    if len(argv) != 1:
        print("usage: orders_cli.py FILE", file=sys.stderr)
        return 2
    try:
        orders = load_orders(argv[0])
    except OrderError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    print(f"{len(orders)} orders, total {sum(price * qty for price, qty in orders):.2f}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

CSV (orders.csv) · cli/orders.csv

item,price,qty
notebook,45.50,3
pen,12.00,10
bag,899.00,1

CSV (bad_orders.csv) · cli/bad_orders.csv

item,price,qty
notebook,45.50,3
pen,abc,10

In orders_cli.py, load_orders() turns FileNotFoundError into OrderError("missing.csv does not exist; check the name and the folder"), and a bad number into an OrderError that names the file and the line. raise ... from e keeps the original exception as __cause__, so a log or a traceback still shows the ValueError underneath, while the message stays readable. main() then needs one except OrderError clause: everything the user can fix arrives as an OrderError, and anything else is a bug in the program. try_cli.py calls main() with four lists of arguments, the way four separate runs from a terminal would, and shows error output in the same stream so the order is clear. Step through the last of them, the file with the bad price, across the three layers of the diagram:

Interactive · message by message A bad price through the three layers Step through main(["bad_orders.csv"]). Watch the ValueError become an OrderError at the module boundary, and what main() makes of it.
A bad price through the three layers, step 7 of 7. Messages so far: main(argv) to load_orders(path): load_orders("bad_orders.csv"); load_orders(path) to float() and int(): float("45.50"), int("3"); load_orders(path) to float() and int(): float("abc"); float() and int() to load_orders(path): ValueError; load_orders(path) to main(argv): OrderError.main(argv)the top-level boundaryload_orders(path)the module boundaryfloat() and int()the helpersload_orders("bad_orders.csv")float("45.50"), int("3")float("abc")ValueErrorOrderErrorexit status 1

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. One argument. try_cli.py calls main(["bad_orders.csv"]). There is exactly one argument, so main() calls load_orders() with it.
  2. Line 2 converts. The file opens and csv.DictReader reads its rows. Line 2, the notebook, converts to a price of 45.5 and a quantity of 3.
  3. Line 3 does not. Line 3, the pen, has the price "abc". float("abc") raises ValueError: could not convert string to float: 'abc'.
  4. Caught at the edge. The ValueError reaches the except clause of load_orders(), which names KeyError, TypeError and ValueError, the ways a row can be bad.
  5. Converted, cause kept. load_orders() raises OrderError("bad_orders.csv, line 3: price and qty must be numbers") from e. The message is written for the user; __cause__ keeps the ValueError for a log or a traceback.
  6. One clause in main(). The OrderError reaches main(), which needs a single except OrderError clause, because everything the user can fix arrives as an OrderError.
  7. A message and a status. main() prints "error: bad_orders.csv, line 3: price and qty must be numbers" to standard error and returns 1, the status sys.exit() hands to the shell when the tool runs for real.

Notice what main() does not catch. A bug, such as a typo in a variable name, is not an OrderError, so it escapes with its full traceback. That is deliberate: a short, friendly message for a bug would hide the one piece of information that lets someone fix it. When you do need a catch-all, for example in a server that must keep serving, catch Exception, never BaseException or a bare except:, so that Ctrl-C (KeyboardInterrupt) and sys.exit() (SystemExit) still work, and log the traceback.

Checkpoint

Check: Converting errors at a module boundary

  1. One question load_orders() raises OrderError(...) from e for line 3 of bad_orders.csv. If that OrderError reached a traceback, what would it show?

    Choose one answer.

    Show the answer: One question

    Answer: The ValueError first, then that it was the direct cause, then the OrderError

    raise OrderError(...) from e sets __cause__ to the ValueError. The user sees only the OrderError's message, which main() prints; a log or a traceback still shows the ValueError underneath, followed by "The above exception was the direct cause of the following exception".

Exit statuses tell the shell what happened

A program tells the shell, a CI job or another program whether it worked through its exit status, a small number. 0 means success and anything else failure. The documentation of sys.exit() notes the Unix habit of 2 for a wrong command line and 1 for every other error, and argparse exits with 2 when it rejects the arguments. Statuses should stay between 0 and 127. Write main(argv) so that it returns the status, and finish the file with sys.exit(main(sys.argv[1:])): tests can call main() with any arguments and check the number, and the shell gets the same number when the program runs for real. These are the statuses a Python program ends with, and where each one comes from:

What happened Exit status Where the number comes from
The command did its job 0 main() returns 0
The command line was wrong 2 main() returns 2, as argparse does
An error the user can fix 1 main() prints the message and returns 1
sys.exit("message") 1 Python prints the message to standard error
An exception that nothing caught, Ctrl-C aside 1 Python prints the traceback first

The four runs of try_cli.py above return 0, 2, 1 and 1: the first three rows. Ctrl-C is the one exception to the last row: since Python 3.8, a KeyboardInterrupt that nothing caught ends the program through the interrupt signal itself, so the shell or the program that started it can tell that the run was interrupted. sys.exit() works by raising SystemExit, so finally clauses and with blocks still clean up on the way out. A message for the user names the problem in their terms and the next step, such as error: bad_orders.csv, line 3: price and qty must be numbers, and goes to standard error, so that it never mixes with output that another program may read from standard output.

Python Online Compiler Run try_cli.py, then add a row with a missing qty to bad_orders.csv and see which message and status you get.

Checkpoint

Check: Exit statuses of a command-line program

  1. Question 1 of 2 orders_cli.py is run with no argument at all. Which exit status does it return?

    Type a number.

    A hint

    The second run of try_cli.py shows it.

    Show the answer: Question 1 of 2

    Answer: 2

    A command line that is wrong gets the status 2, the Unix habit the sys.exit() documentation describes and the status argparse uses. main() prints the usage line to standard error and returns 2.

  2. Question 2 of 2 A nightly export catches every exception in main(), prints it and returns normally. Its scheduler runs the next step only when the export exits with 0. What happens on a night the export fails?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It exits with 0, so the next step runs on a missing or partial export

    The exit status is how shells, CI and schedulers decide whether a step worked. Catching every error and returning normally reports success. Return 1 after the message, or let a bug end the program, which also gives 1.

Users get a sentence, the log gets the traceback

A job that runs unattended, such as a nightly report, has two audiences: the person told that it failed, and the developer who has to work out why. They need different things:

A short message for the user, the traceback for the log Python · logged.py
import io
import logging

log_file = io.StringIO()  # stands in for a log file or a log service
logging.basicConfig(stream=log_file, level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("reports")

RATES = {"standard": 0.18, "reduced": 0.05}


def tax(amount, rate_name):
    return round(amount * RATES[rate_name], 2)


def build_report(lines):
    return [(item, amount, tax(amount, rate)) for item, amount, rate in lines]


def run_job(lines):
    try:
        report = build_report(lines)
    except Exception:
        log.exception("report failed for %d lines", len(lines))
        print("The report could not be built, and nothing was sent. Try again later; if it keeps failing, contact support.")
        return 1
    log.info("report built with %d lines", len(report))
    print("Report ready:", report)
    return 0


run_job([("notebook", 100.0, "standard"), ("rice", 250.0, "reduced")])
run_job([("notebook", 100.0, "standard"), ("rice", 250.0, "reduced"), ("milk", 60.0, "zero")])
print("--- what the log holds ---")
print(log_file.getvalue(), end="")

Output

Report ready: [('notebook', 100.0, 18.0), ('rice', 250.0, 12.5)]
The report could not be built, and nothing was sent. Try again later; if it keeps failing, contact support.
--- what the log holds ---
INFO reports: report built with 2 lines
ERROR reports: report failed for 3 lines
Traceback (most recent call last):
  File "logged.py", line 21, in run_job
    report = build_report(lines)
  File "logged.py", line 16, in build_report
    return [(item, amount, tax(amount, rate)) for item, amount, rate in lines]
                           ~~~^^^^^^^^^^^^^^
  File "logged.py", line 12, in tax
    return round(amount * RATES[rate_name], 2)
                          ~~~~~^^^^^^^^^^^
KeyError: 'zero'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 logged.py

The user sees one sentence: what failed, what did not happen (nothing was sent) and what to do. The log gets logger.exception(), which records the message at the ERROR level together with the full traceback, ending in KeyError: 'zero', a tax rate the code does not know. logger.exception() only makes sense inside an except clause, where there is an exception to record. Log an error once, at the layer that handles it; when every layer logs and re-raises, one failure fills the log with the same traceback several times.

Security

Messages that reach users must not contain tracebacks, file paths, SQL, server or library versions, or secrets: each of those helps an attacker learn how the system is built, which is why OWASP’s error-handling guidance asks for generic responses to unexpected errors and the details in server-side logs. Exception messages often end up in both places, so never put a password, a token or a full connection string into one.

Checkpoint

Check: Messages for people, details for the log

  1. One question In the failed run of logged.py, what does the user see, and what does the log keep?

    Choose one answer.

    Show the answer: One question

    Answer: The user: one sentence saying nothing was sent and what to do. The log: the message at ERROR level and the full traceback, ending in KeyError: 'zero'

    One failure has two audiences. The person told about it gets one sentence on what failed, what did not happen and what to do; the developer gets the traceback in the log, once, from the layer that handled the error.

Two hooks report what nothing caught

Whatever no except clause catches in the main thread goes to sys.excepthook(type, value, traceback) just before the program ends; its default prints the traceback to standard error. An exception that ends a thread goes to threading.excepthook(args) instead, whose args has exc_type, exc_value, exc_traceback and thread. Both can be replaced, which is how a program sends every uncaught failure to its log:

Hooks that report uncaught exceptions Python · hooks/last_resort.py
import sys
import threading


def report_uncaught(exc_type, exc, tb):
    print(f"[log] uncaught {exc_type.__name__}: {exc}", file=sys.stderr)
    print("Something went wrong; the details are in the log.", file=sys.stderr)


def report_thread(args):
    print(f"[log] thread {args.thread.name} failed: {args.exc_type.__name__}: {args.exc_value}", file=sys.stderr)


sys.excepthook = report_uncaught
threading.excepthook = report_thread

worker = threading.Thread(target=lambda: {}["missing"], name="export-worker")
worker.start()
worker.join()
print("the main thread carries on after the thread failed")
raise RuntimeError("config.toml has no [database] table")

Output (exit status 1)

the main thread carries on after the thread failed

Printed as an error (standard error)

[log] thread export-worker failed: KeyError: 'missing'
[log] uncaught RuntimeError: config.toml has no [database] table
Something went wrong; the details are in the log.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 last_resort.py

This example starts a thread, which the browser runner cannot do, so the page shows its recorded output. Step through the run and watch which hook each failure reaches:

Interactive · step by step Where an exception that nothing caught goes Step through last_resort.py. The left column is the thread, the right one the main thread; watch which hook each failure reaches.
Where an exception that nothing caught goes, step 8 of 8. Showing export-worker thread (raised KeyError: 'missing'), threading.excepthook (logged the failure), Main thread (raised RuntimeError), sys.excepthook (printed two lines), Exit status (1: the program failed).start(), join()KeyErrorRuntimeErrorexport-worker threadraised KeyError: 'missing'threading.excepthooklogged the failureMain threadraised RuntimeErrorsys.excepthookprinted two linesExit status1: the program failed

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Two hooks replaced. The program replaces both hooks with its own functions, report_uncaught for sys.excepthook and report_thread for threading.excepthook. Nothing has failed yet.
  2. A thread starts. The main thread starts a thread called export-worker and waits for it with join(). The thread's only work is {}["missing"], a lookup in an empty dictionary.
  3. The thread fails. The lookup raises KeyError: 'missing'. Nothing in the thread catches it, so the thread ends.
  4. The thread's hook. An exception that ends a thread goes to threading.excepthook, not sys.excepthook. report_thread(args) prints "[log] thread export-worker failed: KeyError: 'missing'" from args.thread, args.exc_type and args.exc_value.
  5. The main thread carries on. join() returns and the main thread prints that it carries on. An exception in one thread never stops the others.
  6. An uncaught RuntimeError. The main thread raises RuntimeError("config.toml has no [database] table") and nothing catches it. Just before the program ends, Python calls sys.excepthook with its type, value and traceback.
  7. Reported, not printed raw. report_uncaught prints "[log] uncaught RuntimeError" with the message, then a short sentence for the user, instead of the default traceback.
  8. Still status 1. The program still ends with exit status 1. The hooks decide how a failure is reported, not whether the program survives it.

The failed thread is reported, and the main thread carries on, because an exception in a thread never stops the others. The uncaught RuntimeError then goes to the new sys.excepthook, and the program still ends with status 1: the hooks decide how a failure is reported, not whether the program survives it. SystemExit never reaches sys.excepthook, and threading.excepthook ignores it silently. A similar hook, sys.unraisablehook, receives exceptions that Python cannot raise anywhere, such as one inside a destructor.

Checkpoint

Check: The hooks for uncaught exceptions

  1. One question In last_resort.py, what happens when the export-worker thread fails with KeyError?

    Choose one answer.

    Show the answer: One question

    Answer: threading.excepthook logs it, and the main thread carries on

    The thread ends, threading.excepthook receives args with the thread, the type and the exception, and report_thread prints the log line. The main thread's join() returns, and it prints that it carries on.

Retry only temporary failures, with waits that grow

Some failures go away by themselves: a connection reset, a timeout, a service that answers 503 Service Unavailable while it restarts. Others fail the same way every time until someone changes something: invalid input, a missing permission, a bug. Only the first kind deserves a retry, and only for operations that are safe to repeat. RFC 9110, the HTTP specification, calls a method idempotent: Said of an operation whose intended effect is the same whether it runs once or several times, such as an HTTP GET or PUT. Only such an operation, or one the server can recognise as a repeat, is safe to retry automatically. when sending the same request twice has the same intended effect as sending it once: PUT, DELETE and safe methods such as GET. For any other request it advises against automatic retries, unless the client has some way to know that a repeat does no harm. A payment sent twice is two payments, unless the server can recognise the repeat, for example by an idempotency key that the client sends with both.

A good retry waits longer each time, exponential backoff: Waiting longer before each new attempt, doubling the wait each time up to a cap, so that a failing service gets time to recover instead of a steady stream of retries.: after attempt n fails it waits base_delay × 2ⁿ⁻¹, capped at a maximum. It gives up after a fixed number of attempts and raises the last error, and when a server sends a Retry-After header, it waits at least that long. The helper below adds jitter: A random part of each retry's wait, so that clients that failed at the same moment do not all try again at the same moment., a random part of the wait, and takes the sleep function as a parameter, so a test can record the waits instead of sleeping:

Retry with exponential backoff and jitter Python · retry.py
import random
import time


def retry(fn, attempts=4, base_delay=0.5, max_delay=8.0, retry_on=(ConnectionError, TimeoutError), sleep=time.sleep, rng=None):
    """Call fn(); after a temporary error wait base_delay * 2**n seconds, cap it, add jitter, and try again."""
    if attempts < 1:
        raise ValueError(f"attempts must be at least 1, got {attempts}")
    rng = rng or random.Random()
    for attempt in range(1, attempts + 1):
        try:
            return fn()
        except retry_on as e:
            if attempt == attempts:
                e.add_note(f"gave up after {attempts} attempts")
                raise
            backoff = min(max_delay, base_delay * 2 ** (attempt - 1))
            delay = rng.uniform(0, backoff)  # jitter: anywhere from 0 to the backoff
            print(f"  attempt {attempt} failed ({type(e).__name__}); waiting {delay:.2f} s of up to {backoff:.1f} s")
            sleep(delay)


def flaky(failures):
    """A stand-in for a network call that fails `failures` times, then works."""
    calls = 0

    def call():
        nonlocal calls
        calls += 1
        if calls <= failures:
            raise ConnectionError("connection reset by peer")
        return f"ok after {calls} calls"

    return call


waits = []
rng = random.Random(7)  # seeded, so the run is the same every time
print("two failures, then success:")
print(retry(flaky(2), sleep=waits.append, rng=rng))
print("waited", round(sum(waits), 2), "s in all")

print("a mistake in the request is not retried:")
try:
    retry(lambda: int("12,50"), sleep=waits.append, rng=rng)
except ValueError as e:
    print("  ValueError at once:", e)

print("the service stays down:")
try:
    retry(flaky(10), sleep=waits.append, rng=rng)
except ConnectionError as e:
    print("  ConnectionError:", e, "|", e.__notes__[0])

Output

two failures, then success:
  attempt 1 failed (ConnectionError); waiting 0.16 s of up to 0.5 s
  attempt 2 failed (ConnectionError); waiting 0.15 s of up to 1.0 s
ok after 3 calls
waited 0.31 s in all
a mistake in the request is not retried:
  ValueError at once: invalid literal for int() with base 10: '12,50'
the service stays down:
  attempt 1 failed (ConnectionError); waiting 0.33 s of up to 0.5 s
  attempt 2 failed (ConnectionError); waiting 0.07 s of up to 1.0 s
  attempt 3 failed (ConnectionError); waiting 1.07 s of up to 2.0 s
  ConnectionError: connection reset by peer | gave up after 4 attempts

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retry.py

The random generator is seeded, so the run is the same every time and the waits can be checked. A ValueError is not in retry_on, so it is raised at once: retrying a request that is wrong only repeats the mistake. When every attempt fails, the last ConnectionError is raised again with a note saying how many attempts were made. Step through the first and the last run, and watch the limit of each wait double while the wait itself falls anywhere below it:

Interactive · message by message Retries with waits that grow and are partly random Step through two runs of retry.py. Watch the upper limit of each wait double, and the actual wait fall anywhere below it.
Retries with waits that grow and are partly random, step 11 of 11. Messages so far: retry() to The service: attempt 1; The service to retry(): ConnectionError; retry() to The service: attempt 2; The service to retry(): ConnectionError; retry() to The service: attempt 3; retry() to The caller: "ok after 3 calls"; The service to retry(): ConnectionError × 3; retry() to The service: attempt 4; retry() to The caller: ConnectionError and a note.The callerretry(flaky(10))retry()attempts=4, base 0.5 sThe servicefails ten timesattempt 1ConnectionErrorattempt 2ConnectionErrorattempt 3"ok after 3 calls"ConnectionError × 3attempt 4ConnectionError and a notegave up after 4 attempts

The drawing shows the whole scene; its 11 steps are listed below.

Every step as text
  1. Attempt 1. retry() calls fn(), a stand-in for a network call that fails twice and then works.
  2. A temporary error. The call fails with ConnectionError, which is in retry_on, so retry() will try again.
  3. Up to 0.5 s. The backoff for attempt 1 is base_delay × 2⁰ = 0.5 s. Jitter picks a wait anywhere from 0 to that, here 0.16 s, which the example records instead of sleeping.
  4. Attempt 2. The second attempt fails the same way.
  5. Up to 1.0 s. The backoff doubles to 1.0 s, and the random wait is 0.15 s. A random wait can be shorter than the one before; only its upper limit grows.
  6. Attempt 3 works. The third attempt succeeds, and the call returns "ok after 3 calls".
  7. The result. retry() hands the result to the caller. The two waits came to 0.31 s in all.
  8. A service that stays down. The last run of the example uses a service that fails ten times, more often than retry() will try.
  9. Three failures, three waits. Attempts 1, 2 and 3 fail. Their backoffs are 0.5, 1.0 and 2.0 s, and the random waits 0.33, 0.07 and 1.07 s.
  10. The last attempt. Attempt 4 fails too, and it is the last, so there is no final wait.
  11. Give up, and say so. retry() adds the note "gave up after 4 attempts" to the ConnectionError and raises it again, so the caller sees the real error and how hard the code tried.

Jitter keeps a crowd of retries apart

Jitter matters when many clients fail at the same moment, for example when a server restarts and every client loses its connection at once. With fixed waits they all come back together, again and again. This simulation counts, for 1,000 clients that each retry four times, the most retries that land in any one 10 ms slot:

1,000 clients retrying after the same failure Python · herd.py
import random
from collections import Counter

CLIENTS = 1000
BASE = 0.1  # seconds before the first retry
WINDOW = 0.01  # count retries in 10 ms slots


def busiest_slot(jitter, retries=4, seed=1):
    """1,000 clients fail at the same moment; return the most retries that land in any one 10 ms slot."""
    rng = random.Random(seed)
    slots = Counter()
    for _ in range(CLIENTS):
        t = 0.0
        for n in range(retries):
            backoff = BASE * 2**n
            t += jitter(rng, backoff)
            slots[int(t / WINDOW)] += 1
    return max(slots.values())


plans = {
    "no jitter (wait exactly the backoff)": lambda rng, b: b,
    "small jitter (backoff +/- 20 %)": lambda rng, b: b * rng.uniform(0.8, 1.2),
    "full range (0 to the backoff)": lambda rng, b: rng.uniform(0, b),
}
print(f"{CLIENTS:,} clients fail together, then retry 4 times starting at {BASE} s:")
for name, jitter in plans.items():
    print(f"  {name:38} busiest 10 ms slot: {busiest_slot(jitter):5,} retries")

Output

1,000 clients fail together, then retry 4 times starting at 0.1 s:
  no jitter (wait exactly the backoff)   busiest 10 ms slot: 1,000 retries
  small jitter (backoff +/- 20 %)        busiest 10 ms slot:   262 retries
  full range (0 to the backoff)          busiest 10 ms slot:   155 retries

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 herd.py

Without jitter every one of the 1,000 retries of each round lands in the same 10 ms slot, so the restarted server meets the full crowd four times. A spread of 20 % either way, the jitter of the gRPC connection backoff protocol, brings the busiest slot down to 262. A wait anywhere between 0 and the backoff brings it down to 155, and also gets clients back sooner on average. One more rule from the same arithmetic: retry in one layer only. If a request handler, the client library it uses and the HTTP layer below each make 3 attempts, one user action sends 27 requests to a service that is already failing.

HTTP Status Codes Reference Look up which HTTP statuses suggest a retry may help, such as 429 and 503, and what Retry-After says.

Key idea

Retry only temporary failures of operations that are safe to repeat, in one layer, with waits that double up to a cap, a random part that keeps clients apart, and a limit after which the last error is raised.

Checkpoint

Check: Retries with backoff and jitter

  1. Question 1 of 2 retry() in retry.py has base_delay=0.5 and draws each wait from rng.uniform(0, backoff). After the first failed attempt, what is the longest wait it can choose, in seconds?

    Type a number.

    A hint

    The backoff of attempt n is base_delay × 2 to the power n − 1.

    Show the answer: Question 1 of 2

    Answer: 0.5 s

    For attempt 1 the backoff is 0.5 × 2⁰ = 0.5 s, and the jitter picks a wait anywhere from 0 to that; the run chose 0.16 s. After attempt 2 the limit doubles to 1.0 s, and so on up to max_delay.

  2. Question 2 of 2 In herd.py, 1,000 clients fail together and retry with fixed waits, no jitter. How many retries land in the busiest 10 ms slot?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 1000 retries

    With fixed waits every client retries at the same moment, so all 1,000 retries of a round land in one slot, round after round. A spread of 20 % either way brought the busiest slot down to 262, and a wait anywhere from 0 to the backoff to 155.

Practise: retries with backoff and a command that sets its exit status

The lesson comes down to a checklist for a real program:

  • Each module raises errors of its own domain from a small hierarchy (one base class), chained to the original.
  • Exactly one place catches each kind of error; everything else lets it propagate.
  • main() returns 0, 1 or 2, and the file ends with sys.exit(main(sys.argv[1:])).
  • Users get short messages on standard error, without tracebacks, paths or secrets; the log gets the traceback, once.
  • Uncaught exceptions in the main thread and in threads are logged through sys.excepthook and threading.excepthook.
  • Only temporary failures of idempotent operations are retried, in one layer, with backoff, jitter, a cap and a limit.
  • Clean-up uses with statements, so every path through the program releases what it acquired.

The exercise practises two lines of it: a retry() whose waits double, and a command that turns each outcome into the right message and exit status. Its sample tests pass in fake functions, so nothing really waits or connects:

Exercise · Medium · Python

Retries with backoff and a command that sets its exit status

Complete the two functions in stock_cli.py, a small command that looks up how many items a warehouse service has in stock. Keep the names and parameters: the tests call them with fake functions, so nothing really waits or connects.

retry(fn, attempts=3, base_delay=0.1, retry_on=(ConnectionError, TimeoutError), sleep=time.sleep) calls fn() and returns its result.

  • When fn() raises one of the exceptions in retry_on, wait with sleep(delay) and call it again. The waits double: base_delay, then twice that, then four times, and so on. With the defaults the waits are 0.1 s and then 0.2 s.
  • After attempts calls have failed, raise the last exception again, without a final wait.
  • Any other exception is raised at once, without a retry.
  • attempts below 1 is a mistake by the caller: raise ValueError without calling fn().

cli_main(argv, fetch, sleep=time.sleep) runs the command and returns its exit status. argv is the list of arguments, and fetch(item) is the call to the service: it returns the stock as a number, or raises LookupError for an unknown item, or ConnectionError or TimeoutError when the service does not answer.

  • Not exactly one argument: print usage: stock ITEM to standard error and return 2.
  • Fetch through retry(), with 3 attempts, a base delay of 0.5 s and the sleep you were given.
  • Success: print <item>: <stock> to standard output, such as pen: 40, and return 0.
  • An unknown item: print error: no item called <item> to standard error and return 1, without retrying.
  • The service still not answering after the retries: print error: the stock service is not answering; try again in a few minutes to standard error and return 1.
  • Any other exception is a bug: let it propagate.

Starter code · stock_cli.py

import sys
import time


def retry(fn, attempts=3, base_delay=0.1, retry_on=(ConnectionError, TimeoutError), sleep=time.sleep):
    """Call fn() and return its result, retrying temporary errors with waits that double each time."""
    return fn()


def cli_main(argv, fetch, sleep=time.sleep):
    """Print the stock of the item named in argv and return the exit status: 0 success, 1 error, 2 usage."""
    print(f"{argv[0]}: {fetch(argv[0])}")
    return 0
The sample tests · test_stock_cli.py
import io
from contextlib import redirect_stderr, redirect_stdout

from stock_cli import cli_main, retry


def failing(times, error, value="ok"):
    """A fake call that raises `error` for the first `times` calls, then returns `value`."""
    calls = []

    def call():
        calls.append(1)
        if len(calls) <= times:
            raise error
        return value

    return call, calls


def run_cli(argv, fetch):
    out, err, waits = io.StringIO(), io.StringIO(), []
    with redirect_stdout(out), redirect_stderr(err):
        status = cli_main(argv, fetch, sleep=waits.append)
    return status, out.getvalue(), err.getvalue(), waits


def test_retry_success_needs_no_wait():
    """returns the result of a call that works, without waiting"""
    call, calls = failing(0, ConnectionError("down"))
    waits = []
    assert retry(call, sleep=waits.append) == "ok"
    assert waits == []
    assert len(calls) == 1


def test_retry_waits_double():
    """retries a temporary error, waiting base_delay, then twice as long"""
    call, calls = failing(2, ConnectionError("reset"))
    waits = []
    assert retry(call, attempts=3, base_delay=0.1, sleep=waits.append) == "ok"
    assert waits == [0.1, 0.2]
    assert len(calls) == 3


def test_retry_gives_up():
    """raises the last error after the last attempt, without a final wait"""
    call, calls = failing(9, TimeoutError("no answer"))
    waits = []
    try:
        retry(call, attempts=4, base_delay=1, sleep=waits.append)
    except TimeoutError:
        pass
    else:
        assert False, "retry() did not raise TimeoutError"
    assert waits == [1, 2, 4]
    assert len(calls) == 4


def test_retry_other_errors_at_once():
    """raises an error that is not in retry_on at once"""
    call, calls = failing(5, ValueError("bad request"))
    waits = []
    try:
        retry(call, sleep=waits.append)
    except ValueError:
        pass
    else:
        assert False, "retry() did not raise ValueError"
    assert waits == []
    assert len(calls) == 1


def test_retry_needs_one_attempt():
    """rejects attempts below 1 without calling fn()"""
    call, calls = failing(0, ConnectionError("down"))
    try:
        retry(call, attempts=0, sleep=[].append)
    except ValueError:
        pass
    else:
        assert False, "retry(attempts=0) did not raise ValueError"
    assert calls == []


def test_cli_success():
    """prints the stock and returns 0"""
    status, out, err, waits = run_cli(["pen"], lambda item: 40)
    assert status == 0
    assert out == "pen: 40\n"
    assert err == ""


def test_cli_usage():
    """returns 2 with a usage line when the argument is missing"""
    status, out, err, waits = run_cli([], lambda item: 40)
    assert status == 2
    assert "usage: stock ITEM" in err
    assert out == ""


def test_cli_unknown_item():
    """returns 1 for an unknown item, without retrying"""
    def fetch(item):
        raise LookupError(item)

    status, out, err, waits = run_cli(["rubber"], fetch)
    assert status == 1
    assert err == "error: no item called rubber\n"
    assert waits == []


def test_cli_service_down():
    """returns 1 after 3 attempts with waits of 0.5 s and 1 s"""
    call, calls = failing(9, ConnectionError("refused"))
    status, out, err, waits = run_cli(["pen"], lambda item: call())
    assert status == 1
    assert err == "error: the stock service is not answering; try again in a few minutes\n"
    assert waits == [0.5, 1.0]
    assert len(calls) == 3


def test_cli_bug_propagates():
    """lets an unexpected exception through instead of hiding it"""
    def fetch(item):
        return 1 / 0

    try:
        run_cli(["pen"], fetch)
    except ZeroDivisionError:
        return
    assert False, "cli_main() hid a ZeroDivisionError"
A hint

In retry(), first reject attempts < 1 with ValueError, then loop with for attempt in range(1, attempts + 1): and put return fn() in a try whose except retry_on: either re-raises with a bare raise when attempt == attempts or calls sleep(base_delay * 2 ** (attempt - 1)). Because except names only retry_on, every other exception passes straight through. In cli_main(), check len(argv) first, then call retry(lambda: fetch(item), attempts=3, base_delay=0.5, sleep=sleep) inside a try with one except for LookupError and one for (ConnectionError, TimeoutError). Print errors with print(..., file=sys.stderr).

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then test the ideas of this lesson one question at a time. The session starts where your answers are weakest, and after every choice it tells you what that choice got right or missed:

Practice that adapts to you

Practise this lesson

19 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 19 A function cannot do anything useful about an exception that one of its calls raises. What should it do?

    Choose one answer.

    Show the answer: Question 1 of 19

    Answer: Let it propagate to its caller

    Most functions should not catch anything. An error a function cannot handle travels up to the code that can: a module boundary that converts it, or the top-level boundary that reports it.

  2. Question 2 of 19 What does swallowed.py print?

    What does this program print? Choose one answer.

    def read_price(text):
        try:
            return float(text)
        except ValueError:
            print("could not read price")  # handled too early: the caller cannot tell
            return None
    
    
    def order_total(lines):
        return sum(read_price(price) * int(qty) for price, qty in lines)
    
    
    try:
        print(order_total([("12.50", "2"), ("abc", "1"), ("4.00", "3")]))
    except Exception as e:
        print(f"the caller sees {type(e).__name__}: {e}")
    Show the answer: Question 2 of 19

    Answer: it prints

    could not read price
    the caller sees TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'

    read_price() catches the ValueError itself, prints a line and returns None. The caller multiplies that None by the quantity, so the error the caller finally sees is a TypeError about NoneType, which says nothing about the bad price "abc" or which line it was on. Letting the ValueError propagate, or converting it into an error that names the line, keeps the real cause.

  3. Question 3 of 19 A helper parses one row of an import. Some imports must stop at the first bad row, others must skip bad rows and count them. Where should the decision be made?

    Choose one answer.

    Show the answer: Question 3 of 19

    Answer: In the import loop, which knows the job; the helper raises, perhaps with a note naming the row

    Handle an error where the decision belongs. The helper knows the row is bad; the loop knows whether the job stops or skips and counts. So the helper raises, and the loop catches and decides.

  4. Question 4 of 19 What does raise OrderError(message) from e keep of the original exception e?

    Choose one answer.

    Show the answer: Question 4 of 19

    Answer: The whole exception, as the new one's __cause__

    from e records the original exception as __cause__. The caller handles one domain error with a readable message, and the low-level error is still there for whoever has to debug it.

  5. Question 5 of 19 main() of orders_cli.py catches only OrderError. A typo in load_orders() raises NameError. What happens?

    Choose one answer.

    Show the answer: Question 5 of 19

    Answer: The NameError passes main() and ends the program with its full traceback and exit status 1

    Everything the user can fix arrives as an OrderError; anything else is a bug and should escape with its traceback. A catch-all, where one is needed, catches Exception and logs the traceback, so that Ctrl-C and sys.exit() still work.

  6. Question 6 of 19 Loading orders can fail with FileNotFoundError, ValueError or KeyError. What should the code that calls the loading module have to catch?

    Choose one answer.

    Show the answer: Question 6 of 19

    Answer: One error of the module's own, such as OrderError, with the original chained as its cause

    The module turns each failure it expects, a missing file, a bad number or an absent column, into its own exception with a message for the person who will read it, and keeps the original with from e. Its callers then handle one kind of failure.

  7. Question 7 of 19 A command-line program is started with a missing argument. Which exit status follows the usual Unix convention?

    Choose one answer.

    Show the answer: Question 7 of 19

    Answer: 2

    Zero means success and any other value failure. By convention 2 means the command line itself was wrong, which is also what argparse uses when it rejects the arguments, and 1 is for all other errors. Exit statuses should stay between 0 and 127.

  8. Question 8 of 19 Why does orders_cli.py write main(argv) to return the status, and end with sys.exit(main(sys.argv[1:])), instead of calling sys.exit() inside main()?

    Choose one answer.

    Show the answer: Question 8 of 19

    Answer: Tests can call main() with any arguments and check the number it returns, and the shell still gets the same number

    A main() that returns its status can be called and checked like any function, as try_cli.py does with four lists of arguments. The last line passes the same number to sys.exit(), so a real run reports it to the shell.

  9. Question 9 of 19 A backup script prints error: and a message to standard output on every failure, and exits normally. Monitoring alerts when the script's exit status is not 0. What do you change?

    Choose one answer.

    Show the answer: Question 9 of 19

    Answer: Print the message to standard error and return 1 from main(), so the status says the backup failed

    A program reports failure through its exit status, which shells, schedulers and CI read. Users read the message, which belongs on standard error. Return 1 after it, 2 only for a wrong command line.

  10. Question 10 of 19 Where does a command-line tool print its short error message for the user?

    Choose one answer.

    Show the answer: Question 10 of 19

    Answer: To standard error

    A message for the user names the problem in their terms and the next step, goes to standard error, and comes with a non-zero exit status. The traceback, if any, goes to the log.

  11. Question 11 of 19 What does logger.exception("report failed") record that logger.error("report failed") does not?

    Choose one answer.

    Show the answer: Question 11 of 19

    Answer: The traceback of the exception being handled

    logger.exception() is logger.error() with the exception information added: in logged.py the log holds the message and the traceback, ending in KeyError: 'zero', a tax rate the code does not know.

  12. Question 12 of 19 A failure passes through four layers, and each one catches it, calls logger.exception() and raises it again. What is wrong, and what is the fix?

    Choose one answer.

    Show the answer: Question 12 of 19

    Answer: The log holds the same traceback four times; log it once, at the layer that handles it

    Log an error once, where it is handled, usually the top-level boundary. Layers in between let it propagate, or add context with add_note() or raise ... from e, without logging the same traceback again.

  13. Question 13 of 19 What does sys.excepthook handle?

    Choose one answer.

    Show the answer: Question 13 of 19

    Answer: Exceptions that nothing caught in the main thread, just before the program ends; SystemExit excluded

    sys.excepthook is called with the type, value and traceback of an exception that nothing caught, other than SystemExit; by default it prints the traceback. Exceptions that end a threading.Thread go to threading.excepthook instead. Neither hook can resume the program; they decide how the failure is reported.

  14. Question 14 of 19 report_thread(args) in last_resort.py prints "thread export-worker failed: KeyError: 'missing'". Where do those three pieces come from?

    Choose one answer.

    Show the answer: Question 14 of 19

    Answer: args.thread.name, args.exc_type.__name__ and args.exc_value

    threading.excepthook(args) is called with an object whose attributes are exc_type, exc_value, exc_traceback and thread. last_resort.py replaces it with report_thread, which builds its log line from them.

  15. Question 15 of 19 A team installs a sys.excepthook that logs the error and prints "Something went wrong". Can it keep the program running after an uncaught exception?

    Choose one answer.

    Show the answer: Question 15 of 19

    Answer: No; it runs just before the program ends, which still exits with status 1

    sys.excepthook is the last line of defence for reporting: it can send every uncaught failure to the log. Code that must survive an error handles it in an except clause, at the boundary where recovery makes sense.

  16. Question 16 of 19 Why do retry delays include a random part (jitter)?

    Choose one answer.

    Show the answer: Question 16 of 19

    Answer: So that clients that failed at the same moment do not all retry at the same moment again

    Clients that fail together, for example when a server restarts, would retry together with fixed delays, and hit the server with the same burst each time. In the lesson's simulation of 1,000 clients, fixed delays put all 1,000 retries into one 10 ms slot, while waits drawn anywhere from 0 up to the backoff cut the busiest slot to 155.

  17. Question 17 of 19 A client calls a web service. Which failures are reasonable to retry automatically, with backoff?

    Choose every answer that is right.

    Show the answer: Question 17 of 19

    Answer:

    • A timeout while reading the answer to a GET request
    • 503 Service Unavailable, after the delay its Retry-After header gives

    GET is idempotent, so repeating it is safe, and a 503 with Retry-After says when to try again. A 400 or an expired token fails the same way every time until something changes. A POST that charges money is not idempotent: repeating it may charge twice unless the server can recognise the repeat, for example by an idempotency key.

  18. Question 18 of 19 A request handler retries a call 3 times (3 attempts in all), the client library it calls also makes 3 attempts, and so does the HTTP layer below it. If the service is down, how many requests does one user action send to it?

    Type a number.

    Show the answer: Question 18 of 19

    Answer: 27 requests

    Each layer multiplies the attempts of the one below: 3 × 3 × 3 = 27 requests for one action, sent to a service that is already failing. Retry in one layer only, usually the one closest to the call, and let the layers above report the error.

  19. Question 19 of 19 A POST that charges a card times out, so the client cannot tell whether the charge was made. When may it send the request again automatically?

    Choose one answer.

    Show the answer: Question 19 of 19

    Answer: Only if the server can recognise the repeat, for example by an idempotency key sent with both requests

    RFC 9110 advises clients not to repeat a request that is not idempotent on their own, unless they know the repeat is harmless. When the client sends one key with every attempt of the same payment, the server can tell that a request is a repeat and answer it without charging again.

Interview questions

Fresher to 2–5 years · What exit status should a command-line program return, and why does it matter?

Zero when it succeeded and a non-zero value when it failed, because that number is how shells, CI pipelines, schedulers and other programs decide whether a step worked and whether to run the next one. By the Unix convention that Python’s sys.exit() documentation repeats, 2 means the command line was wrong, which is also what argparse uses, and 1 covers other errors; values should stay between 0 and 127. In Python, write main(argv) to return the status and call sys.exit(main(sys.argv[1:])). An uncaught exception other than Ctrl-C ends the program with status 1, so even a crash reports failure. The classic bug is the opposite: catching every error, printing a message and returning normally, so the program exits with 0 and the automation around it believes it worked.

Key takeaways

  • Most code lets errors propagate; modules convert low-level errors into domain errors with raise ... from e, and the top-level boundary catches, logs and reports.
  • Never catch, print and carry on inside a helper: it replaces a clear error with a confusing one or a wrong result.
  • main(argv) returns the exit status, 0 for success, 2 for a wrong command line and 1 for other errors, and the file ends with sys.exit(main(sys.argv[1:])).
  • Users get short, actionable messages on standard error with no tracebacks, paths or secrets, and logger.exception() puts the traceback in the log, once.
  • sys.excepthook and threading.excepthook report what nothing caught, but they cannot keep the program alive.
  • Retry only temporary failures of idempotent operations, in one layer, with exponential backoff, jitter, a cap and a limit, and respect Retry-After.

Terms in this lesson

exit status
The small number a program hands back to whatever started it: 0 for success, anything else for failure. Shells, CI jobs and schedulers read it; by convention 2 means a wrong command line and 1 any other error.
exponential backoff
Waiting longer before each new attempt, doubling the wait each time up to a cap, so that a failing service gets time to recover instead of a steady stream of retries.
idempotent
Said of an operation whose intended effect is the same whether it runs once or several times, such as an HTTP GET or PUT. Only such an operation, or one the server can recognise as a repeat, is safe to retry automatically.
jitter
A random part of each retry's wait, so that clients that failed at the same moment do not all try again at the same moment.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.