Input & UX

Designing an Exception Hierarchy for a Python CLI

Give a Python CLI one base error class with exit codes and hints, translate library exceptions at the boundary, and handle everything in one entry point.

Updated

In a small CLI, error handling starts as typer.echo("error: ..."); raise typer.Exit(1) scattered through the commands. It works until the logic moves into functions that are also called from tests, from a second command and from a background job — none of which want a function that prints and exits. Then the questions pile up: which exit code does a missing config file get? Who prints the hint about logging in? Why does a network timeout show a forty-line httpx traceback in one command and a friendly message in another? The answer that scales is an exception hierarchy: a small set of error classes that carry a message, a hint and an exit code, raised by the core code and turned into output in exactly one place. This guide designs that hierarchy, shows where library exceptions are translated, writes the single entry point that handles everything, and tests each layer. It belongs to the error handling and exit codes topic.

Prerequisites

The shape of the hierarchy

An exception hierarchy for a CLI A base CLI error class with subclasses for usage errors, configuration errors, not-found errors, remote service errors and authentication errors, each with its own exit code. An exception hierarchy for a CLI MytoolError message + exit_code + hint UsageError exit 2 ConfigError exit 78 NotFound exit 1 ServiceError exit 69 AuthError exit 77 one handler at the top turns any of them into a message and an exit code Core code raises meaningful errors; only the top layer knows about printing and exiting.

One base class, MytoolError, means "an expected failure the user can act on". Every subclass is a category with its own exit code, chosen from the conventional values so that scripts can distinguish them: 2 for usage, 69 (EX_UNAVAILABLE) when a service cannot be reached, 77 (EX_NOPERM) for authentication, 78 (EX_CONFIG) for configuration. Anything that is not a MytoolError is, by definition, a bug.

That last rule is the valuable one. It turns error handling from "catch what might go wrong" into a classification: if the user can fix it, raise a MytoolError with a hint; if they cannot, let it propagate, and the entry point will label it as a bug.

The recipe

The error classes are deliberately tiny. The exit code is a class attribute, so a category has one code everywhere, and the hint is optional:

# src/mytool/errors.py
from __future__ import annotations


class MytoolError(Exception):
    """An expected failure: the user can act on the message."""

    exit_code = 1

    def __init__(self, message: str, *, hint: str | None = None) -> None:
        super().__init__(message)
        self.message = message
        self.hint = hint


class UsageError(MytoolError):
    exit_code = 2


class NotFound(MytoolError):
    exit_code = 1


class ServiceError(MytoolError):
    exit_code = 69          # EX_UNAVAILABLE


class AuthError(MytoolError):
    exit_code = 77          # EX_NOPERM


class ConfigError(MytoolError):
    exit_code = 78          # EX_CONFIG

Translate at the boundary

Where exceptions are translated Layers of a CLI and what happens to exceptions at each: libraries raise their own errors, services translate them into CLI errors, and one top-level handler prints and exits. Where exceptions are translated Top-level handler cli MytoolError → message + exit code; anything else → bug report Services translate httpx.ConnectError → ServiceError("cannot reach ...") Core and libraries raise raise their own specific exceptions Translate at the boundary where you know what the failure means to the user.

Libraries raise their own exceptions — FileNotFoundError, tomllib.TOMLDecodeError, httpx.ConnectError. The right place to turn them into MytoolErrors is the layer that knows what the failure means to the user: the service functions that load config and call the API. They know that a missing file is "run mytool init" and a 401 is "log in again"; the library does not, and the top-level handler is too far away to know:

# src/mytool/services.py
from __future__ import annotations

import tomllib
from pathlib import Path

import httpx

from mytool.errors import AuthError, ConfigError, NotFound, ServiceError


def load_profile(path: Path, name: str) -> dict:
    try:
        data = tomllib.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        raise ConfigError(f"config file {path} does not exist",
                          hint="run 'mytool init' to create one") from None
    except tomllib.TOMLDecodeError as exc:
        raise ConfigError(f"{path} is not valid TOML: {exc}") from exc
    try:
        profile = data["profiles"][name]
    except KeyError:
        raise ConfigError(f'profile "{name}" is not defined in {path}') from None
    if "token" not in profile:
        raise AuthError(f'profile "{name}" has no token',
                        hint=f'run "mytool auth login --profile {name}"')
    return profile


def get_site(client: httpx.Client, name: str) -> dict:
    try:
        response = client.get(f"/sites/{name}")
    except httpx.TransportError as exc:
        raise ServiceError(f"cannot reach {client.base_url}: {exc}",
                           hint="check your network or --api-url") from exc
    if response.status_code == 404:
        raise NotFound(f'site "{name}" does not exist')
    if response.status_code in (401, 403):
        raise AuthError("the API rejected the token", hint='run "mytool auth login"')
    if response.status_code >= 500:
        raise ServiceError(f"the API failed with HTTP {response.status_code}",
                           hint="try again in a minute")
    response.raise_for_status()
    return response.json()

raise ... from exc keeps the original exception as __cause__, so debug logs still show the underlying httpx error; from None drops it where it adds nothing (a KeyError for a missing profile). Commands themselves stay free of error handling:

# src/mytool/cli.py (commands)
@app.command()
def show(name: str, profile: str = "default") -> None:
    """Show one site."""
    settings = load_profile(CONFIG, profile)
    with make_client(settings) as client:
        site = get_site(client, name)
    typer.echo(f"{site['name']} {site['status']}")

One handler at the top

The entry point in pyproject.toml points at run, not at the Typer app. It is the only code that prints errors and chooses exit codes:

# src/mytool/cli.py (entry point)
def run(argv: list[str] | None = None) -> None:
    """Entry point: the only place that turns exceptions into messages and exit codes."""
    try:
        app(args=argv, prog_name="mytool")   # Click still handles --help and usage errors (exit 2)
    except MytoolError as exc:
        print(f"error: {exc.message}", file=sys.stderr)
        if exc.hint:
            print(f"hint: {exc.hint}", file=sys.stderr)
        sys.exit(exc.exit_code)
    except Exception as exc:  # noqa: BLE001 - the last line of defence
        log.debug("unexpected error", exc_info=True)
        print(f"internal error: {type(exc).__name__}: {exc} - this is a bug; "
              "rerun with --debug and report it", file=sys.stderr)
        sys.exit(70)                             # EX_SOFTWARE
[project.scripts]
mytool = "mytool.cli:run"

The app runs in Click's normal standalone mode, so --help, parse errors (exit 2) and typer.Exit keep working exactly as before; only exceptions that escape a command reach the two except clauses. The difference between the two outcomes is visible at a glance:

Expected errors versus bugs Terminal output contrasting an expected CLI error with a hint, and an unexpected exception labelled as a bug with advice to rerun with debug output. Expected errors versus bugs bash $ mytool deploy web error: profile "prod" has no token hint: run "mytool auth login --profile prod" $ mytool deploy web internal error: KeyError: 'region' - this is a bug; rerun with --debug Expected failures get a hint; bugs get a clear label and somewhere to look.

Design rules that keep it healthy

  • Few classes, chosen by what the user does next. A class earns its place when it needs a different exit code or a different kind of hint. SiteNotFound, BuildNotFound and UserNotFound are one class, NotFound, with different messages.
  • Messages state the problem; hints state the action. "profile "prod" has no token" and "run "mytool auth login --profile prod"" are two different sentences, and keeping them apart lets JSON output report them as separate fields, as in reporting machine-readable errors in JSON mode.
  • Core code never prints or exits. Functions raise; only the entry point writes to stderr and calls sys.exit. That keeps the core reusable from tests, other commands and other programs.
  • Do not catch Exception anywhere else. A broad except in the middle of the code turns bugs into misleading "expected" errors. Catch the specific library exception you are translating, and nothing more.
  • Bugs get their own exit code. 70 (EX_SOFTWARE) lets a wrapper or CI job tell "the tool is broken" from "the input was wrong". The friendly traceback handling in friendly error messages and tracebacks plugs into the second except clause.

UX considerations

  • One line, then a hint. The error line should fit a terminal and read as a sentence. The hint, when there is one, is the command to run next — copy-pasteable, with the user's own values filled in.
  • Name the thing. "site "web" does not exist" beats "not found". Every MytoolError message should include the value that caused it.
  • Keep Ctrl+C separate. KeyboardInterrupt is not an Exception subclass, so the bug handler does not catch it; handle interruption as described in handling KeyboardInterrupt cleanly.
  • Log the cause. The from exc chain and a debug-level log with exc_info=True mean --debug shows the full story without the user ever seeing it by default; see adding verbose and quiet logging flags.

Testing the behaviour

Each layer is tested at its own level: the translation in the service functions with httpx.MockTransport, the exit-code table as a whole, and the entry point end to end:

# tests/test_errors.py
import httpx
import pytest

from mytool import cli
from mytool.errors import AuthError, ConfigError, MytoolError, NotFound, ServiceError
from mytool.services import get_site, load_profile


def client_for(handler) -> httpx.Client:
    return httpx.Client(base_url="https://api.test", transport=httpx.MockTransport(handler))


@pytest.mark.parametrize("status, error", [(404, NotFound), (401, AuthError), (503, ServiceError)])
def test_http_statuses_become_cli_errors(status, error):
    with pytest.raises(error):
        get_site(client_for(lambda r: httpx.Response(status)), "web")


def test_transport_errors_become_service_errors():
    def boom(request):
        raise httpx.ConnectError("connection refused", request=request)
    with pytest.raises(ServiceError, match="cannot reach"):
        get_site(client_for(boom), "web")


def test_missing_config_has_a_hint(tmp_path):
    with pytest.raises(ConfigError) as info:
        load_profile(tmp_path / "missing.toml", "default")
    assert info.value.exit_code == 78 and "mytool init" in info.value.hint


def test_exit_codes_match_the_documented_table():
    codes = {cls.__name__: cls.exit_code for cls in MytoolError.__subclasses__()}
    assert codes == {"UsageError": 2, "NotFound": 1, "ServiceError": 69, "AuthError": 77, "ConfigError": 78}


def exit_code_of(argv) -> int:
    with pytest.raises(SystemExit) as info:
        cli.run(argv)
    return info.value.code


def test_entry_point_prints_message_hint_and_exit_code(tmp_path, monkeypatch, capsys):
    monkeypatch.chdir(tmp_path)
    (tmp_path / "mytool.toml").write_text('[profiles.prod]\napi_url = "https://x"\n')
    assert exit_code_of(["show", "web", "--profile", "prod"]) == 77
    err = capsys.readouterr().err
    assert err == 'error: profile "prod" has no token\nhint: run "mytool auth login --profile prod"\n'


def test_bugs_are_labelled_as_bugs(monkeypatch, capsys):
    def broken(*args):
        raise KeyError("region")
    monkeypatch.setattr(cli, "load_profile", broken)
    assert exit_code_of(["show", "web"]) == 70
    assert "internal error: KeyError" in capsys.readouterr().err


def test_usage_errors_still_exit_2():
    assert exit_code_of(["show"]) == 2

The table test is the one that protects scripts: exit codes are part of the public interface, and changing one should be a deliberate edit to a test, not an accident in a refactor. The last test guards against a subtle mistake when writing the entry point — running the app with standalone_mode=False makes Click's own parse errors escape as exceptions, and a catch-all handler would then report a missing argument as a bug with exit code 70.

Conclusion

A CLI's error handling scales when it is designed rather than accumulated: one base class meaning "the user can fix this", a handful of subclasses chosen by exit code and kind of hint, translation from library exceptions at the layer that knows what they mean, and a single entry point that prints error: and hint: lines for expected failures and labels everything else as a bug with its own exit code. Core code raises and never prints, commands stay free of try blocks, and a test pins the exit-code table so scripts can keep relying on it.

Frequently asked questions

Should the error classes subclass Click's ClickException?

It is tempting, because Click would then print and exit for you. The cost is that core code imports the CLI framework, and — with recent Typer releases bundling their own copy of Click — the class you subclass may not be the one Typer catches. A plain Exception hierarchy handled in your own entry point works with any framework and in any caller.

Where do usage errors raised by my own validation go?

If the check happens while parsing, raise typer.BadParameter or click.BadParameter so the framework reports it with usage text. If it happens later, in core code that cannot import the framework, raise your own UsageError (exit 2) — see validating dependent and conflicting options.

How do I exit with a code without an error message?

For a non-error result such as "nothing changed" (for example --check exiting 1 when files would change), raise typer.Exit(code) from the command. That is control flow, not an error, and Click handles it without printing anything.

Should errors be translated in the HTTP client or in the service functions?

Transport failures that mean the same thing everywhere — cannot connect, timeout — can be translated once in a shared client wrapper. Status codes whose meaning depends on the call (a 404 is "site not found" in one place and "no builds yet" in another) belong in the function that made the call.

How many exit codes should a CLI document?

As few as scripts actually need to distinguish. Most tools manage with 0, 1, 2 and one or two specific codes; add a code only when someone has a reason to branch on it, and list them in the help epilogue or README.