error-handling · git:20260317.e64d556 · 2026-03-17 · sha256 8b473df24c805591
error-handling git:20260317.e64d556A
Immutable. This exact content is served forever at /api/v1/blob/8b473df24c805591.
---
name: error-handling
description: "Error handling strategy — exception hierarchies, retry patterns, circuit breakers, graceful degradation, and error boundaries. Use when designing error handling, implementing retries, or building resilient systems. TRIGGER when: error handling, exception, retry, circuit breaker, fallback, graceful degradation, resilience. DO NOT TRIGGER when: writing tests, documentation, config changes, simple bug fixes."
allowed-tools: [Read, Grep, Glob]
---
# Error Handling
Patterns for building resilient Python systems. Focus on recoverability, not just catching exceptions.
## Core Principle
**Handle what you can recover from. Propagate what you can't. Never swallow errors silently.**
## Exception Hierarchy
Design exceptions that help the caller decide what to do.
```python
class AppError(Exception):
"""Base for all application errors. Always catchable as a group."""
class ConfigError(AppError):
"""Configuration is invalid or missing. Not retryable."""
class ExternalServiceError(AppError):
"""External dependency failed. May be retryable."""
class RateLimitError(ExternalServiceError):
"""Rate limit hit. Retryable after delay."""
def __init__(self, message: str, retry_after: float = 60.0):
super().__init__(message)
self.retry_after = retry_after
class ValidationError(AppError):
"""Input data is invalid. Not retryable without fixing input."""
def __init__(self, message: str, field: str | None = None):
super().__init__(message)
self.field = field
```
### Rules
- **One base exception per library/package** — callers can catch everything with one class
- **Categorize by recoverability** — retryable vs not-retryable is the most important distinction
- **Include context** — what failed, what was expected, how to fix it
- **Never inherit from BaseException** — only `Exception` subclasses
## Retry Pattern
Use for transient failures (network, rate limits, temporary unavailability).
```python
import time
from typing import TypeVar, Callable
T = TypeVar("T")
def retry(
fn: Callable[..., T],
*,
max_attempts: int = 3,
backoff_base: float = 1.0,
retryable: tuple[type[Exception], ...] = (ExternalServiceError,),
) -> T:
"""Retry with exponential backoff. Only retries specific exceptions."""
last_error: Exception | None = None
for attempt in range(max_attempts):
try:
return fn()
except retryable as e:
last_error = e
if attempt < max_attempts - 1:
delay = backoff_base * (2 ** attempt)
time.sleep(delay)
raise last_error # type: ignore[misc]
```
### Retry Rules
- **Always cap max attempts** — infinite retries = infinite loops
- **Always use backoff** — hammering a failing service makes it worse
- **Only retry specific exceptions** — retrying `ValidationError` is pointless
- **Log each retry** — silent retries hide problems
- FORBIDDEN: `except Exception: retry` — catches everything including bugs
## Circuit Breaker
Prevent cascading failures when a dependency is down.
```python
import time
from enum import Enum
class CircuitState(Enum):
CLOSED = "closed" # Normal operation
OPEN = "open" # Failing, reject immediately
HALF_OPEN = "half_open" # Testing if recovered
class CircuitBreaker:
def __init__(
self,
failure_threshold: int = 5,
recovery_timeout: float = 60.0,
):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failure_count = 0
self.state = CircuitState.CLOSED
self.last_failure_time = 0.0
def call(self, fn: Callable[..., T], *args, **kwargs) -> T:
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
raise ExternalServiceError(
f"Circuit breaker open. Retry after {self.recovery_timeout}s"
)
try:
result = fn(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure()
raise
def _on_success(self) -> None:
self.failure_count = 0
self.state = CircuitState.CLOSED
def _on_failure(self) -> None:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
```
## Graceful Degradation
When a non-critical component fails, continue with reduced functionality.
```python
def get_user_profile(user_id: str) -> UserProfile:
"""Get full profile. Falls back to basic profile if enrichment fails."""
profile = get_basic_profile(user_id) # Must succeed
try:
profile.preferences = get_preferences(user_id)
except ExternalServiceError:
profile.preferences = DEFAULT_PREFERENCES # Acceptable fallback
try:
profile.avatar = get_avatar(user_id)
except ExternalServiceError:
profile.avatar = None # Optional, safe to skip
return profile
```
### When to Degrade vs When to Fail
| Situation | Action |
|-----------|--------|
| Core data unavailable | **Fail** — partial data is worse than no data |
| Enrichment/decoration fails | **Degrade** — return basic result |
| Logging/metrics fail | **Degrade** — never block business logic for observability |
| Auth/security check fails | **Fail** — never degrade security |
## Error Boundaries
Contain failures to prevent them from propagating through the system.
```python
def process_batch(items: list[Item]) -> BatchResult:
"""Process items with per-item error isolation."""
results = []
errors = []
for item in items:
try:
result = process_single(item)
results.append(result)
except AppError as e:
errors.append(ItemError(item_id=item.id, error=str(e)))
# Continue processing remaining items
return BatchResult(
successful=results,
failed=errors,
total=len(items),
)
```
### Boundary Placement
- **Between user input and processing** — validate at the boundary
- **Between internal code and external calls** — wrap external errors
- **Between batch items** — isolate per-item failures
- **At plugin/hook entry points** — never crash the host application
## Error Messages
Every error message must answer three questions:
```python
# BAD
raise ValueError("Invalid input")
# GOOD
raise ValueError(
f"Expected JSON file but got {path.suffix!r} file: {path}\n"
f"Supported formats: .json, .jsonl\n"
f"See: docs/data-format.md"
)
```
1. **What happened?** — "Expected JSON file but got .csv file"
2. **What was expected?** — "Supported formats: .json, .jsonl"
3. **How to fix it?** — "See: docs/data-format.md"
## Anti-Patterns
| Anti-Pattern | Why It's Wrong | Correct Pattern |
|-------------|---------------|-----------------|
| `except: pass` | Swallows all errors silently | Catch specific, log, or re-raise |
| `except Exception as e: print(e)` | No stack trace, no re-raise | `logging.exception(...)` or re-raise |
| Returning error codes | Callers forget to check | Raise exceptions |
| Catching too broadly | Hides bugs | Catch the narrowest exception possible |
| Re-raising without context | Loses original cause | `raise NewError(...) from original` |
| Try/except around every line | Unreadable, hides flow | One try block per logical operation |