# 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](./nested-typer-exceptions/nested-typer-exception-explosion.py) ```python # 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](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_typer_echo.py) Create a custom exception class that handles user-friendly output: ```python # 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:** ```python # 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 e` to preserve the chain for debugging ### Pattern 2: Custom Exit Exception with Rich Console **Full example:** [nested-typer-exception-explosion_corrected_rich_console.py](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_rich_console.py) For applications using Rich for output: ```python # 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:** ```python # 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](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_typer_echo.py) ```python # 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) ```text ╭───────────────────── 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) ```text Invalid JSON in broken.json at line 1, column 1: Expecting value ``` ## Rules for Exception Handling in Typer CLIs ### ✅ DO 1. **Let exceptions propagate in helper functions** - Most functions should not have try/except 2. **Catch only where you add meaningful context** - JSON parsing, validation, etc. 3. **Immediately raise AppExit** - Don't re-wrap multiple times 4. **Use custom exception classes** - Inherit from `typer.Exit` and handle output in `__init__` 5. **Document what exceptions bubble up** - Use docstring "Raises:" sections 6. **Use `from e` when wrapping** - Preserves exception chain for debugging ### ❌ DON'T 1. **NEVER catch and re-wrap at every layer** - This creates exception chain explosion 2. **NEVER use `except Exception as e:` as a safety net** - Too broad, catches things you can't handle 3. **NEVER check `isinstance` to avoid double-wrapping** - This is a symptom you're doing it wrong 4. **NEVER convert exceptions to return values** - Use exceptions, not `{"success": False, "error": "..."}` patterns 5. **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: - [Terminating](https://github.com/fastapi/typer/blob/master/docs/tutorial/terminating.md) - [Exceptions](https://github.com/fastapi/typer/blob/master/docs/tutorial/exceptions.md) - [Printing](https://github.com/fastapi/typer/blob/master/docs/tutorial/printing.md) ## Demonstration Scripts See [assets/nested-typer-exceptions/](./nested-typer-exceptions/) for complete working examples. **Quick start:** See [README.md](./nested-typer-exceptions/README.md) for script overview and running instructions. ### [nested-typer-exception-explosion.py](./nested-typer-exceptions/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](./nested-typer-exceptions/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 has `if isinstance(e, ConfigError): raise` checks - 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](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_typer_echo.py) - Correct Pattern with typer.echo **What you'll find:** - Custom `AppExit` class extending `typer.Exit` that calls `typer.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](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_rich_console.py) - Correct Pattern with Rich Console **What you'll find:** - Custom `AppExitRich` class extending `typer.Exit` that calls `console.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: ```bash # 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.