15 KiB
Exception Handling in Python CLI Applications with Typer
The Problem: Exception Chain Explosion
AI-generated code commonly creates a catastrophic anti-pattern where every function catches and re-wraps exceptions, creating massive exception chains (200+ lines of output) for simple errors like "file not found".
Example of the problem:
Full example: nested-typer-exception-explosion.py
# From: nested-typer-exception-explosion.py (simplified - see full file for all 7 layers)
# Layer 1
def read_file(path):
try:
return path.read_text()
except FileNotFoundError as e:
raise ConfigError(f"File not found: {path}") from e
except Exception as e:
raise ConfigError(f"Failed to read: {e}") from e
# Layer 2
def load_config(path):
try:
contents = read_file(path)
return json.loads(contents)
except ConfigError as e:
raise ConfigError(f"Config load failed: {e}") from e
except Exception as e:
raise ConfigError(f"Unexpected error: {e}") from e
# Layer 3... Layer 4... Layer 5... Layer 6... Layer 7...
# Each layer wraps the exception again
Result: Single FileNotFoundError becomes a 6-layer exception chain with 220 lines of output.
The Correct Solution: Typer's Exit Pattern
Based on Typer's official documentation and best practices:
Pattern 1: Custom Exit Exception with typer.echo
Full example: nested-typer-exception-explosion_corrected_typer_echo.py
Create a custom exception class that handles user-friendly output:
# From: nested-typer-exception-explosion_corrected_typer_echo.py
import typer
class AppExit(typer.Exit):
"""Custom exception for graceful application exits."""
def __init__(self, code: int | None = None, message: str | None = None):
self.code = code
self.message = message
if message is not None:
if code is None or code == 0:
typer.echo(self.message)
else:
typer.echo(self.message, err=True)
super().__init__(code=code)
Usage in helper functions:
# From: nested-typer-exception-explosion_corrected_typer_echo.py
def load_json_file(file_path: Path) -> dict:
"""Load JSON from file.
Raises:
AppExit: If file cannot be loaded or parsed
"""
contents = file_path.read_text(encoding="utf-8") # Let FileNotFoundError bubble
try:
return json.loads(contents)
except json.JSONDecodeError as e:
# Only catch where we can add meaningful context
raise AppExit(
code=1,
message=f"Invalid JSON in {file_path} at line {e.lineno}, column {e.colno}: {e.msg}"
) from e
Key principles:
- Helper functions let exceptions bubble naturally
- Only catch at points where you have enough context for a good error message
- Immediately raise
AppExit- don't re-wrap multiple times - Use
from eto preserve the chain for debugging
Pattern 2: Custom Exit Exception with Rich Console
Full example: nested-typer-exception-explosion_corrected_rich_console.py
For applications using Rich for output:
# From: nested-typer-exception-explosion_corrected_rich_console.py
from rich.console import Console
import typer
normal_console = Console()
err_console = Console(stderr=True)
class AppExitRich(typer.Exit):
"""Custom exception using Rich console for consistent formatting."""
def __init__(
self,
code: int | None = None,
message: str | None = None,
console: Console = normal_console
):
self.code = code
self.message = message
if message is not None:
console.print(self.message)
super().__init__(code=code)
Usage:
# From: nested-typer-exception-explosion_corrected_rich_console.py
def validate_config(data: dict) -> dict:
"""Validate config structure.
Raises:
AppExitRich: If validation fails
"""
if not data:
raise AppExitRich(code=1, message="Config cannot be empty", console=err_console)
if not isinstance(data, dict):
raise AppExitRich(
code=1,
message=f"Config must be a JSON object, got {type(data)}",
console=err_console
)
return data
Complete Example: Correct Pattern
Full example: nested-typer-exception-explosion_corrected_typer_echo.py
# From: nested-typer-exception-explosion_corrected_typer_echo.py
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["typer>=0.19.2"]
# ///
import json
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer()
class AppExit(typer.Exit):
"""Custom exception for graceful exits with user-friendly messages."""
def __init__(self, code: int | None = None, message: str | None = None):
if message is not None:
if code is None or code == 0:
typer.echo(message)
else:
typer.echo(message, err=True)
super().__init__(code=code)
# Helper functions - let exceptions bubble naturally
def read_file_contents(file_path: Path) -> str:
"""Read file contents.
Raises:
FileNotFoundError: If file doesn't exist
PermissionError: If file isn't readable
"""
return file_path.read_text(encoding="utf-8")
def parse_json_string(content: str) -> dict:
"""Parse JSON string.
Raises:
json.JSONDecodeError: If JSON is invalid
"""
return json.loads(content)
# Only catch where we add meaningful context
def load_json_file(file_path: Path) -> dict:
"""Load and parse JSON file.
Raises:
AppExit: If file cannot be loaded or parsed
"""
contents = read_file_contents(file_path)
try:
return parse_json_string(contents)
except json.JSONDecodeError as e:
raise AppExit(
code=1,
message=f"Invalid JSON in {file_path} at line {e.lineno}, column {e.colno}: {e.msg}"
) from e
def validate_config(data: dict, source: str) -> dict:
"""Validate config structure.
Raises:
AppExit: If validation fails
"""
if not data:
raise AppExit(code=1, message="Config cannot be empty")
if not isinstance(data, dict):
raise AppExit(code=1, message=f"Config must be a JSON object, got {type(data)}")
return data
def load_config(file_path: Path) -> dict:
"""Load and validate configuration.
Raises:
AppExit: If config cannot be loaded or is invalid
"""
try:
data = load_json_file(file_path)
except (FileNotFoundError, PermissionError):
raise AppExit(code=1, message=f"Failed to load config from {file_path}")
return validate_config(data, str(file_path))
@app.command()
def main(config_file: Annotated[Path, typer.Argument()]) -> None:
"""Load and process configuration file."""
config = load_config(config_file)
typer.echo(f"Config loaded successfully: {config}")
if __name__ == "__main__":
app()
Output Comparison
Anti-Pattern Output (220 lines)
╭───────────────────── Traceback (most recent call last) ──────────────────────╮
│ ... json.loads() ... │
│ ... 40 lines of traceback ... │
╰──────────────────────────────────────────────────────────────────────────────╯
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
The above exception was the direct cause of the following exception:
╭───────────────────── Traceback (most recent call last) ──────────────────────╮
│ ... parse_json_string() ... │
│ ... 40 lines of traceback ... │
╰──────────────────────────────────────────────────────────────────────────────╯
ConfigError: Invalid JSON in broken.json at line 1, column 1: Expecting value
The above exception was the direct cause of the following exception:
[... 4 more layers of this ...]
Correct Pattern Output (1 line)
Invalid JSON in broken.json at line 1, column 1: Expecting value
Rules for Exception Handling in Typer CLIs
✅ DO
- Let exceptions propagate in helper functions - Most functions should not have try/except
- Catch only where you add meaningful context - JSON parsing, validation, etc.
- Immediately raise AppExit - Don't re-wrap multiple times
- Use custom exception classes - Inherit from
typer.Exitand handle output in__init__ - Document what exceptions bubble up - Use docstring "Raises:" sections
- Use
from ewhen wrapping - Preserves exception chain for debugging
❌ DON'T
- NEVER catch and re-wrap at every layer - This creates exception chain explosion
- NEVER use
except Exception as e:as a safety net - Too broad, catches things you can't handle - NEVER check
isinstanceto avoid double-wrapping - This is a symptom you're doing it wrong - NEVER convert exceptions to return values - Use exceptions, not
{"success": False, "error": "..."}patterns - NEVER catch exceptions you can't handle - Let them propagate
When to Catch Exceptions
Catch when:
- You can add meaningful context (filename, line number, etc.)
- You're at a validation boundary and can provide specific feedback
- You need to convert a technical error to user-friendly message
Don't catch when:
- You're just going to re-raise it
- You can't add any useful information
- You're in a helper function that just transforms data
Fail Fast by Default
What you DON'T want:
- ❌ Nested try/except that re-raise with redundant messages
- ❌ Bare exception catching (
except Exception:) - ❌ Graceful degradation without requirements
- ❌ Failover/fallback logic without explicit need
- ❌ "Defensive" catch-all handlers that mask problems
What IS fine:
- ✅ Let exceptions propagate naturally
- ✅ Add try/except only where recovery is actually needed
- ✅ Validation at boundaries (user input, external APIs)
- ✅ Clear, specific exception types
Reference: Typer Documentation
Official Typer guidance on exits and exceptions:
Demonstration Scripts
See assets/nested-typer-exceptions/ for complete working examples.
Quick start: See README.md for script overview and running instructions.
nested-typer-exception-explosion.py - The Anti-Pattern
What you'll find:
- Complete executable script demonstrating 7 layers of exception wrapping
- Every function catches exceptions and re-wraps with
from e - Creates ConfigError custom exception at each layer
- No isinstance checks - pure exception chain explosion
What happens when you run it:
- Single JSON parsing error generates ~220 lines of output
- 7 separate Rich-formatted traceback blocks
- "The above exception was the direct cause of the following exception" repeated 6 times
- Obscures the actual error (invalid JSON) in pages of traceback
Run it: ./nested-typer-exception-explosion.py broken.json
nested-typer-exception-explosion_naive_workaround.py - The isinstance Band-Aid
What you'll find:
- Same 7-layer structure as the explosion example
- Each
except Exception as e:block hasif isinstance(e, ConfigError): raisechecks - Shows how AI attempts to avoid double-wrapping by checking exception type
- Treats the symptom (double-wrapping) instead of the cause (catching everywhere)
What happens when you run it:
- Still shows nested tracebacks but slightly reduced output (~80 lines)
- Demonstrates why isinstance checks appear in AI-generated code
- Shows this is a workaround, not a solution
Run it: ./nested-typer-exception-explosion_naive_workaround.py broken.json
nested-typer-exception-explosion_corrected_typer_echo.py - Correct Pattern with typer.echo
What you'll find:
- Custom
AppExitclass extendingtyper.Exitthat callstyper.echo()in__init__ - Helper functions that let exceptions bubble naturally (no try/except)
- Only catches at specific points where meaningful context can be added
- Immediately raises
AppExit- no re-wrapping through multiple layers - Complete executable example with PEP 723 metadata
What happens when you run it:
- Clean 1-line error message:
Invalid JSON in broken.json at line 1, column 1: Expecting value - No traceback explosion
- User-friendly output using typer.echo for stderr
Run it: ./nested-typer-exception-explosion_corrected_typer_echo.py broken.json
nested-typer-exception-explosion_corrected_rich_console.py - Correct Pattern with Rich Console
What you'll find:
- Custom
AppExitRichclass extendingtyper.Exitthat callsconsole.print()in__init__ - Same exception bubbling principles as typer.echo version
- Uses Rich Console for consistent formatting with rest of CLI
- Allows passing different console instances (normal_console vs err_console)
What happens when you run it:
- Same clean 1-line output as typer.echo version
- Uses Rich console for output instead of typer.echo
- Demonstrates pattern for apps already using Rich for terminal output
Run it: ./nested-typer-exception-explosion_corrected_rich_console.py broken.json
Running the Examples
All scripts use PEP 723 inline script metadata and can be run directly:
# Run any script directly (uv handles dependencies automatically)
./script-name.py broken.json
# Or explicitly with uv
uv run script-name.py broken.json
The scripts will create broken.json if it doesn't exist.