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
- Python 3.11+ (for
tomllib), a Typer or Click CLI, andhttpxif you want to run the service example. - Familiarity with choosing exit codes for CLI tools.
The shape of the hierarchy
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
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:
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,BuildNotFoundandUserNotFoundare 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
Exceptionanywhere else. A broadexceptin 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 secondexceptclause.
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
MytoolErrormessage should include the value that caused it. - Keep Ctrl+C separate.
KeyboardInterruptis not anExceptionsubclass, so the bug handler does not catch it; handle interruption as described in handling KeyboardInterrupt cleanly. - Log the cause. The
from excchain and a debug-level log withexc_info=Truemean--debugshows 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.