home / skills / jiatastic / open-python-skills / error-handling
This skill helps you implement robust Python error handling for FastAPI and asyncio, promoting predictable boundaries and clean client responses.
npx playbooks add skill jiatastic/open-python-skills --skill error-handlingReview the files below or copy the command above to add this skill to your agents.
---
name: error-handling
description: >
Python error handling patterns for FastAPI, Pydantic, and asyncio. Follows "Let it crash"
philosophy - raise exceptions, catch at boundaries. Covers HTTPException, global exception
handlers, validation errors, background task failures. Use when: (1) Designing API error
responses, (2) Handling RequestValidationError, (3) Managing async exceptions,
(4) Preventing stack trace leakage, (5) Designing custom exception hierarchies.
---
# Error Handling
Production-ready error handling for Python APIs using the **Let it crash** philosophy.
## Design Philosophy
**Let it crash** - Don't be defensive. Let exceptions propagate naturally and handle them at boundaries.
```python
# BAD - Too defensive, obscures errors
@app.get("/users/{user_id}")
async def get_user(user_id: int):
try:
user = await user_service.get(user_id)
if not user:
raise HTTPException(404, "Not found")
return user
except DatabaseError as e:
raise HTTPException(500, "Database error")
except Exception as e:
logger.exception("Unexpected error")
raise HTTPException(500, "Internal error")
# GOOD - Let exceptions propagate, handle at boundary
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = await user_service.get(user_id)
if not user:
raise UserNotFoundError(user_id)
return user
```
## Core Principles
1. **Raise low, catch high** - Throw exceptions where errors occur, handle at API boundaries
2. **Domain exceptions** - Create semantic exceptions, not generic ones
3. **Global handlers** - Use `@app.exception_handler()` for centralized error formatting
4. **No bare except** - Always catch specific exceptions
5. **Preserve context** - Use `raise ... from error` to keep original traceback
## Quick Start
### 1. Define Domain Exceptions
```python
from enum import StrEnum
class ErrorCode(StrEnum):
USER_NOT_FOUND = "user_not_found"
INVALID_CREDENTIALS = "invalid_credentials"
RATE_LIMITED = "rate_limited"
class DomainError(Exception):
"""Base exception for all domain errors."""
def __init__(self, code: ErrorCode, message: str, status_code: int = 400):
self.code = code
self.message = message
self.status_code = status_code
super().__init__(message)
class UserNotFoundError(DomainError):
def __init__(self, user_id: int):
super().__init__(
code=ErrorCode.USER_NOT_FOUND,
message=f"User {user_id} not found",
status_code=404
)
```
### 2. Define Error Response Schema
```python
from pydantic import BaseModel
class ErrorDetail(BaseModel):
code: str
message: str
request_id: str | None = None
class ErrorResponse(BaseModel):
error: ErrorDetail
```
### 3. Register Global Handlers
```python
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
app = FastAPI()
@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError):
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": exc.code, "message": exc.message}}
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": "http_error", "message": str(exc.detail)}}
)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={"error": {"code": "validation_error", "message": "Invalid request"}}
)
@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception):
# Log full error internally
logger.exception("Unhandled error")
# Return safe message to client
return JSONResponse(
status_code=500,
content={"error": {"code": "internal_error", "message": "Internal server error"}}
)
```
### 4. Use in Routes
```python
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = await user_service.get(user_id)
if not user:
raise UserNotFoundError(user_id)
return user
```
## When to Catch Exceptions
Only catch exceptions in these cases:
| Situation | Example |
|-----------|---------|
| **Need to retry** | `tenacity.retry()` for transient failures |
| **Need to transform** | Wrap third-party SDK errors as domain errors |
| **Need to clean up** | Use `finally` or context managers |
| **Need to add context** | `raise DomainError(...) from original` |
## Python + FastAPI Integration
| Layer | Responsibility |
|-------|---------------|
| **Service/Domain** | Raise domain exceptions (`UserNotFoundError`) |
| **Routes** | Let exceptions propagate (no try/except) |
| **Exception Handlers** | Transform to HTTP responses |
| **Middleware** | Add request context (request_id, timing) |
## Common Patterns
### Third-Party SDK Wrapping
```python
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
class ExternalServiceError(DomainError):
def __init__(self, service: str, original: Exception):
super().__init__(
code=ErrorCode.EXTERNAL_SERVICE_ERROR,
message=f"{service} unavailable",
status_code=503
)
self.__cause__ = original
@retry(stop=stop_after_attempt(3), wait=wait_exponential())
async def call_payment_api(data: dict):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post("https://api.payment.com/charge", json=data)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
raise ExternalServiceError("Payment API", e) from e
```
### Background Task Error Handling
```python
from fastapi import BackgroundTasks
async def safe_background_task(task_func, *args, **kwargs):
try:
await task_func(*args, **kwargs)
except Exception as e:
logger.exception(f"Background task failed: {e}")
# Optional: send to dead letter queue or alerting
@app.post("/orders")
async def create_order(order: Order, background_tasks: BackgroundTasks):
result = await order_service.create(order)
background_tasks.add_task(safe_background_task, send_confirmation_email, result.id)
return result
```
## Troubleshooting
| Issue | Cause | Fix |
|-------|-------|-----|
| Stack trace in response | No generic handler | Add `@app.exception_handler(Exception)` |
| Lost original error | Missing `from` | Use `raise NewError() from original` |
| Validation errors leak | Default handler | Override `RequestValidationError` handler |
| Silent failures | Swallowed exceptions | Let exceptions propagate, handle at boundary |
## References
- [Python Patterns](references/python.md) - Exception design, when to catch, SDK wrapping
- [FastAPI Patterns](references/fastapi.md) - HTTPException, global handlers, middleware
- [Pydantic Patterns](references/pydantic.md) - ValidationError, raise in validators
- [Asyncio Patterns](references/asyncio.md) - TaskGroup, timeout, background tasks
- [FastAPI Docs: Handling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/)
- [Pydantic Docs: Error Handling](https://docs.pydantic.dev/latest/errors/errors/)
This skill provides practical error handling patterns for Python backends using FastAPI, Pydantic, and asyncio. It embraces the “Let it crash” philosophy: raise specific domain exceptions where errors occur and convert them to safe HTTP responses at application boundaries. The guidance covers domain exceptions, global handlers, validation errors, async background failures, and preventing stack trace leakage.
Define semantic domain exceptions and a consistent error response schema with Pydantic. Let exceptions bubble up from services and routes, then register FastAPI exception handlers to transform exceptions into JSON responses and safe status codes. Wrap third-party SDK errors into domain errors, log full tracebacks in a single generic handler, and run background tasks inside safe wrappers to capture and report failures.
Should I catch exceptions in service code?
Only when you need to retry, transform third-party errors into domain errors, clean up resources, or add context; otherwise let them propagate to handlers.
How do I prevent stack traces in responses?
Register a generic @app.exception_handler(Exception) that logs the trace internally and returns a minimal internal_error JSON with status 500.