AI Reading
Quick summary of this article
FastAPI provides a robust system for handling errors in API applications, from basic HTTP exceptions to custom error handling with global handlers. This module covers how to raise HTTP exceptions, create custom exception classes, implement global exception handlers, log errors effectively, and structure consistent API error responses for clients.
- HTTPException is the built-in way to return standard error responses with status codes and messages, and you can add custom headers to error responses when needed.
- Custom exception classes allow you to define specific error types with additional attributes like error codes and database names, making error handling more organized.
- Global exception handlers catch all exceptions of a specific type and return standardized JSON responses, ensuring consistent error formatting across the entire application.
- Logging errors with Python's logging module and appropriate severity levels is essential for debugging and monitoring, and you should always include tracebacks using exc_info=True.
- Using Pydantic models to define structured error response schemas helps API consumers handle errors programmatically with consistent fields like error flag, message, error code, and details.
Introduction
In any professional FastAPI application, things will go wrong. A user might request a resource that doesn’t exist, submit invalid data, or your database might be temporarily unavailable. How you handle these errors determines the reliability and user-friendliness of your API. This is where FastAPI Exception Handling becomes essential.
FastAPI provides a robust system for managing errors, from simple HTTP exceptions to complex custom error handling with global handlers. In this chapter, you will learn how to raise HTTP exceptions, create custom exception classes, implement a global exception handler, log errors effectively, and structure API error responses for your clients.
By the end of this module, you will be able to build APIs that gracefully handle errors, provide meaningful feedback to users, and maintain clean logs for debugging. This is a critical skill for any job-oriented FastAPI developer.
HTTP Exceptions
The most common way to handle errors in FastAPI is by raising HTTPException. This is a built-in class that allows you to return standard HTTP error responses with a status code and a message.
Basic HTTPException Usage
Let’s start with a simple example. Imagine you have an endpoint that returns user details. If the user ID is not found, you should return a 404 error.
from fastapi import FastAPI, HTTPException
app = FastAPI()
# A dummy database of users
users_db = {
1: {"name": "Alice", "email": "alice@example.com"},
2: {"name": "Bob", "email": "bob@example.com"},
}
@app.get("/users/{user_id}")
async def get_user(user_id: int):
if user_id not in users_db:
raise HTTPException(status_code=404, detail="User not found")
return users_db[user_id]
Explanation:
- Line 1: We import
HTTPExceptionfrom FastAPI. - Lines 4-7: A simple dictionary acts as our database.
- Line 10: The endpoint takes a
user_idas a path parameter. - Lines 11-12: If the user ID does not exist in our database, we raise an
HTTPExceptionwith status code 404 and a detail message. - Line 13: If the user exists, we return the user data.
Adding Headers to HTTPException
Sometimes you need to add custom headers to your error response, for example, to indicate a retry time or to provide a unique error ID.
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/items/{item_id}")
async def read_item(item_id: int):
if item_id == 0:
headers = {"X-Error-Code": "invalid_id", "Retry-After": "30"}
raise HTTPException(
status_code=400,
detail="Item ID cannot be zero",
headers=headers
)
return {"item_id": item_id}
Explanation:
- Line 7: We define a custom header dictionary.
- Lines 8-12: When raising the exception, we pass the
headersparameter. FastAPI will include these headers in the response.
Common Status Codes for HTTPException
- 400 Bad Request: Invalid input from the client.
- 401 Unauthorized: Missing or invalid authentication.
- 403 Forbidden: Authenticated but not allowed to access.
- 404 Not Found: Resource does not exist.
- 422 Unprocessable Entity: Validation error (FastAPI uses this for Pydantic validation).
- 500 Internal Server Error: Unexpected server-side error.
Custom Exceptions
While HTTPException is great for simple cases, real-world applications often need more specific error types. Custom exceptions allow you to define your own error classes with additional attributes, making your code more organized and your error responses more informative.
Creating a Custom Exception Class
Let’s create a custom exception for handling database-related errors.
class DatabaseError(Exception):
def __init__(self, message: str, error_code: str, db_name: str):
self.message = message
self.error_code = error_code
self.db_name = db_name
super().__init__(self.message)
Explanation:
- Line 1: We define a custom exception class that inherits from Python’s built-in
Exception. - Lines 2-5: The constructor accepts a
message, anerror_code(for internal tracking), and thedb_namewhere the error occurred. - Line 6: We call the parent constructor with the message so it behaves like a standard exception.
Raising Custom Exceptions
Now, let’s use this custom exception in a simulated database operation.
from fastapi import FastAPI
app = FastAPI()
class DatabaseError(Exception):
def __init__(self, message: str, error_code: str, db_name: str):
self.message = message
self.error_code = error_code
self.db_name = db_name
super().__init__(self.message)
def get_user_from_database(user_id: int):
# Simulate a database connection failure
if user_id == 999:
raise DatabaseError(
message="Connection to database failed",
error_code="DB_CONN_ERR",
db_name="users_db"
)
# Simulate a successful query
return {"id": user_id, "name": "Alice"}
@app.get("/users/{user_id}")
async def read_user(user_id: int):
user = get_user_from_database(user_id)
return user
Explanation:
- Lines 12-16: We simulate a database error when user_id is 999. We raise our custom
DatabaseErrorwith specific details. - Line 21: If the endpoint calls
get_user_from_databasewith user_id 999, the custom exception is raised.
However, if you run this code and hit the endpoint with user_id 999, FastAPI will return a 500 Internal Server Error with a generic message. This is because FastAPI does not know how to handle our custom exception yet. We need an exception handler.
Global Exception Handler
A global exception handler allows you to catch all exceptions of a specific type (or all exceptions) and return a standardized response. This is crucial for maintaining consistent API error responses across your entire application.
Handling Custom Exceptions Globally
Let’s add a handler for our DatabaseError.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
class DatabaseError(Exception):
def __init__(self, message: str, error_code: str, db_name: str):
self.message = message
self.error_code = error_code
self.db_name = db_name
super().__init__(self.message)
@app.exception_handler(DatabaseError)
async def database_error_handler(request: Request, exc: DatabaseError):
return JSONResponse(
status_code=500,
content={
"error": True,
"message": exc.message,
"error_code": exc.error_code,
"db_name": exc.db_name,
"type": "DatabaseError"
}
)
def get_user_from_database(user_id: int):
if user_id == 999:
raise DatabaseError(
message="Connection to database failed",
error_code="DB_CONN_ERR",
db_name="users_db"
)
return {"id": user_id, "name": "Alice"}
@app.get("/users/{user_id}")
async def read_user(user_id: int):
user = get_user_from_database(user_id)
return user
Explanation:
- Line 12: We use the
@app.exception_handler()decorator to register a handler forDatabaseError. - Lines 13-21: The handler function takes a
Requestobject and the exception instance. It returns aJSONResponsewith a structured error payload. - Lines 15-20: The response includes an
errorflag, a human-readablemessage, an internalerror_code, thedb_name, and thetypeof error.
Now, when a DatabaseError is raised, FastAPI will catch it and return a clean JSON response instead of a generic 500 error.
Handling All Unhandled Exceptions
You can also create a catch-all handler for any exception that is not specifically handled.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"error": True,
"message": "An unexpected error occurred. Please try again later.",
"type": "InternalServerError"
}
)
Note: Be careful with a catch-all handler. It will override all other exception handlers if placed after them. Always place specific handlers before the general one.
Overriding FastAPI’s Default Exception Handlers
You can also override the default handlers for HTTPException and RequestValidationError to customize the response format.
from fastapi import FastAPI, Request, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(HTTPException)
async def custom_http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={
"error": True,
"message": exc.detail,
"status_code": exc.status_code
}
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"error": True,
"message": "Validation failed",
"details": exc.errors()
}
)
Logging Errors
Logging is essential for debugging and monitoring your application in production. FastAPI works seamlessly with Python’s built-in logging module. You should log errors with appropriate severity levels so you can track issues without overwhelming your log storage.
Setting Up Basic Logging
import logging
from fastapi import FastAPI, HTTPException
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.FileHandler("app.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
app = FastAPI()
@app.get("/divide/{a}/{b}")
async def divide(a: float, b: float):
if b == 0:
logger.error("Division by zero attempted: a=%s, b=%s", a, b)
raise HTTPException(status_code=400, detail="Cannot divide by zero")
result = a / b
logger.info("Division successful: %s / %s = %s", a, b, result)
return {"result": result}
Explanation:
- Lines 4-10: We configure logging to write to both a file (
app.log) and the console. The format includes timestamp, logger name, severity level, and message. - Line 12: We create a logger instance for this module.
- Lines 17-18: When division by zero is attempted, we log an error with the specific values.
- Line 20: On success, we log an info message.
Logging in Exception Handlers
You should always log exceptions in your global handlers to capture stack traces.
import logging
import traceback
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
app = FastAPI()
class DatabaseError(Exception):
pass
@app.exception_handler(DatabaseError)
async def database_error_handler(request: Request, exc: DatabaseError):
logger.error("DatabaseError occurred: %s", exc, exc_info=True)
return JSONResponse(
status_code=500,
content={"error": True, "message": "Database operation failed"}
)
@app.exception_handler(Exception)
async def general_handler(request: Request, exc: Exception):
logger.error("Unhandled exception: %s", exc, exc_info=True)
return JSONResponse(
status_code=500,
content={"error": True, "message": "Internal server error"}
)
Key Points:
- Use
exc_info=Trueto include the full traceback in the log. - Use different log levels:
debugfor development,infofor normal operations,warningfor potential issues,errorfor failures, andcriticalfor severe problems. - Never log sensitive information like passwords or API keys.
API Error Responses
Consistent error responses are crucial for API consumers. A well-structured error response makes it easy for frontend developers and other services to handle errors programmatically.
Structured Error Response Model
Use Pydantic models to define your error response schema.
from pydantic import BaseModel
from typing import Optional, List, Any
class ErrorResponse(BaseModel):
error: bool = True
message: str
error_code: Optional[str] = None
details: Optional[Any] = None
type: str = "GenericError"
class ValidationErrorResponse(ErrorResponse):
type: str = "ValidationError"
details: List[dict] = []
Using the Error Response Model
from fastapi import FastAPI, Request, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Optional, List, Any
app = FastAPI()
class ErrorResponse(BaseModel):
error: bool = True
message: str
error_code: Optional[str] = None
details: Optional[Any] = None
type: str = "GenericError"
class ValidationErrorResponse(ErrorResponse):
type: str = "ValidationError"
details: List[dict] = []
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
response = ErrorResponse(
message=exc.detail,
error_code=f"HTTP_{exc.status_code}",
type="HTTPException"
)
return JSONResponse(
status_code=exc.status_code,
content=response.model_dump()
)
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
response = ValidationErrorResponse(
message="Request validation failed",
error_code="VALIDATION_ERR",
details=exc.errors()
)
return JSONResponse(
status_code=422,
content=response.model_dump()
)
Explanation:
- Lines 8-14: We define a base
ErrorResponsemodel with fields forerror,message,error_code,details, andtype. - Lines 16-18: A specialized
ValidationErrorResponseextends the base model with a default type and a list of details. - Lines 20-27: In the HTTP exception handler, we create an
ErrorResponseinstance and convert it to a dictionary usingmodel_dump(). - Lines 29-36: For validation errors, we use the
ValidationErrorResponsemodel to include detailed validation errors.
Practical Example: Complete Exception Handling System
Let’s put everything together into a single, practical example.
import logging
from typing import Optional, List, Any
from fastapi import FastAPI, Request, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.FileHandler("api.log"), logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
# Pydantic models for error responses
class ErrorResponse(BaseModel):
error: bool = True
message: str
error_code: Optional[str] = None
details: Optional[Any] = None
type: str = "GenericError"
class ValidationErrorResponse(ErrorResponse):
type: str = "ValidationError"
details: List[dict] = []
# Custom exceptions
class DatabaseError(Exception):
def __init__(self, message: str, error_code: str, db_name: str):
self.message = message
self.error_code = error_code
self.db_name = db_name
super().__init__(self.message)
class AuthenticationError(Exception):
def __init__(self, message: str, user_id: Optional[int] = None):
self.message = message
self.user_id = user_id
super().__init__(self.message)
# Initialize FastAPI app
app = FastAPI()
# Exception handlers
@app.exception_handler(DatabaseError)
async def database_error_handler(request: Request, exc: DatabaseError):
logger.error("Database error in %s: %s", exc.db_name, exc.message, exc_info=True)
response = ErrorResponse(
message=exc.message,
error_code=exc.error_code,
type="DatabaseError"
)
return JSONResponse(status_code=500, content=response.model_dump())
@app.exception_handler(AuthenticationError)
async def auth_error_handler(request: Request, exc: AuthenticationError):
logger.warning("Authentication failed for user %s: %s", exc.user_id, exc.message)
response = ErrorResponse(
message=exc.message,
error_code="AUTH_ERR",
type="AuthenticationError"
)
return JSONResponse(status_code=401, content=response.model_dump())
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.info("HTTP exception: %s - %s", exc.status_code, exc.detail)
response = ErrorResponse(
message=exc.detail,
error_code=f"HTTP_{exc.status_code}",
type="HTTPException"
)
return JSONResponse(status_code=exc.status_code, content=response.model_dump())
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
logger.warning("Validation error: %s", exc.errors())
response = ValidationErrorResponse(
message="Request validation failed",
error_code="VALIDATION_ERR",
details=exc.errors()
)
return JSONResponse(status_code=422, content=response.model_dump())
@app.exception_handler(Exception)
async def general_handler(request: Request, exc: Exception):
logger.critical("Unhandled exception: %s", exc, exc_info=True)
response = ErrorResponse(
message="An unexpected error occurred",
error_code="INTERNAL_ERR",
type="InternalServerError"
)
return JSONResponse(status_code=500, content=response.model_dump())
# Example endpoints
@app.get("/users/{user_id}")
async def get_user(user_id: int):
if user_id == 0:
raise HTTPException(status_code=400, detail="Invalid user ID")
if user_id == 999:
raise DatabaseError(
message="Could not connect to database",
error_code="DB_CONN_ERR",
db_name="users_db"
)
if user_id < 0:
raise AuthenticationError(
message="Invalid authentication token",
user_id=user_id
)
return {"id": user_id, "name": "Alice"}
Common Mistakes
- Not logging exceptions: Many beginners raise exceptions but forget to log them. Always log errors with appropriate context.
- Exposing sensitive information: Avoid returning stack traces or internal details in production error responses. Log them, but return generic messages.
- Using generic HTTP status codes: Use specific status codes (404 for not found, 400 for bad request, etc.) instead of always returning 500.
- Inconsistent error response format: Always return the same structure (e.g.,
{error, message, error_code}) for all errors. - Forgetting to handle validation errors: FastAPI automatically validates request data, but you should customize the validation error response for better user experience.
- Overusing catch-all handlers: A catch-all handler should be your last resort. Always try to handle specific exceptions first.
Practice Task
Create a small FastAPI application that manages a list of books. Implement the following:
- Create a custom exception called
BookNotFoundErrorwith fields forbook_idandmessage. - Create a custom exception called
InvalidBookDataErrorwith fields forfieldandmessage. - Implement a global exception handler for both custom exceptions.
- Add a catch-all handler for any other exceptions.
- Log all errors with appropriate severity levels.
- Use a structured error response model (like
ErrorResponsefrom the examples). - Create endpoints:
GET /books/{book_id}– Returns a book or raisesBookNotFoundError.POST /books– Accepts a JSON body and validates thattitleis not empty. RaisesInvalidBookDataErrorif validation fails.
Test your API using curl or the interactive docs at /docs. Verify that errors return consistent, structured JSON responses.
Summary
In this module, you learned the complete system of FastAPI Exception Handling. We started with the basic HTTPException and progressed to creating custom exception classes for specific error scenarios. You now know how to implement global exception handlers that catch and format errors consistently across your entire application.
We covered the importance of logging errors with context and severity levels, which is crucial for debugging in production. You also learned how to structure your API error responses using Pydantic models, ensuring that your API consumers always receive predictable, well-documented error payloads.
Key takeaways:
- Use
HTTPExceptionfor standard HTTP errors. - Create custom exception classes for domain-specific errors.
- Register exception handlers with
@app.exception_handler(). - Always log errors with
exc_info=Trueto capture stack traces. - Use Pydantic models to define consistent error response schemas.
FAQs
1. What is the difference between HTTPException and custom exceptions?
HTTPException is a built-in FastAPI class that automatically returns an HTTP response with a status code and detail message. Custom exceptions are Python classes you define for specific business logic errors. You must register handlers for custom exceptions to return proper HTTP responses.
2. Can I have multiple exception handlers for the same exception type?
No, only one handler can be registered per exception type. If you register multiple handlers for the same exception, the last one registered will be used.
3. How do I return custom headers in error responses?
You can pass a headers parameter to HTTPException when raising it. For custom exceptions, you can set headers directly in the JSONResponse object within your exception handler.
4. Should I always use a catch-all exception handler?
Yes, but only as a safety net. Always try to handle specific exceptions first. The catch-all handler should log the error and return a generic 500 response without exposing internal details.
5. How can I test my exception handlers?
You can use FastAPI’s TestClient to simulate requests that trigger exceptions. For example, call an endpoint with invalid data or a non-existent resource, and assert that the response status code and body match your expected error format.
You have now mastered exception handling in FastAPI. In the next module, Module 13: Middleware and CORS, you will learn how to process requests and responses globally using middleware, and how to configure Cross-Origin Resource Sharing (CORS) to allow your API to be accessed from different domains. These are essential skills for building production-ready APIs that integrate with frontend applications.
More Practical Examples
Let’s explore additional real-world scenarios where exception handling in FastAPI becomes essential. These examples go beyond basic HTTP exceptions and demonstrate how to handle edge cases gracefully.
Handling Database Connection Errors
When your API interacts with a database, connection failures can occur. Here’s how to handle them properly:
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
import sqlite3
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: int):
try:
conn = sqlite3.connect("database.db")
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
user = cursor.fetchone()
conn.close()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return {"user_id": user[0], "name": user[1], "email": user[2]}
except sqlite3.OperationalError as e:
# Handle database-specific errors
error_detail = f"Database error: {str(e)}"
return JSONResponse(
status_code=500,
content={"error": "Database connection failed", "detail": error_detail}
)
except Exception as e:
# Catch-all for unexpected errors
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(e)}
)
This example shows how to catch database-specific exceptions separately from general exceptions. The sqlite3.OperationalError catches issues like missing tables or corrupted databases, while the general exception handler catches everything else. Notice we use JSONResponse directly to return structured error data.
Validation Errors with Pydantic
FastAPI automatically validates request data using Pydantic models. You can customize how validation errors are presented:
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
app = FastAPI()
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=20)
email: str = Field(..., pattern=r"^[w.-]+@[w.-]+.w+$")
age: int = Field(..., ge=0, le=150)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
error_msg = {
"field": " -> ".join(str(loc) for loc in error["loc"]),
"message": error["msg"],
"type": error["type"]
}
errors.append(error_msg)
return JSONResponse(
status_code=422,
content={"error": "Validation failed", "details": errors}
)
@app.post("/users/")
async def create_user(user: UserCreate):
# If validation passes, this code runs
return {"message": "User created successfully", "user": user}
This custom validation handler transforms the default Pydantic error format into a more user-friendly structure. Each error includes the field name, a human-readable message, and the error type. This is especially useful when building APIs for frontend applications that need clear error messages.
Handling External API Failures
When your FastAPI app calls external services, you need to handle timeouts and failures gracefully:
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/weather/{city}")
async def get_weather(city: str):
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.weather.com/v1/{city}",
timeout=10.0 # 10 seconds timeout
)
response.raise_for_status() # Raises HTTPStatusError for 4xx/5xx
return response.json()
except httpx.TimeoutException:
raise HTTPException(
status_code=504,
detail="Weather service timed out. Please try again later."
)
except httpx.HTTPStatusError as e:
# Map external API errors to our own
if e.response.status_code == 404:
raise HTTPException(
status_code=404,
detail=f"City '{city}' not found"
)
raise HTTPException(
status_code=502,
detail="Weather service returned an error"
)
except httpx.RequestError as e:
raise HTTPException(
status_code=503,
detail="Weather service is currently unavailable"
)
This example demonstrates proper error handling when integrating with third-party APIs. Each exception type is handled separately to provide specific error messages. The timeout parameter prevents your API from hanging indefinitely if the external service is slow.
Class-Based Example
For larger applications, organizing exception handling into classes improves code maintainability. Here’s a class-based approach using FastAPI’s dependency injection:
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from typing import Optional
class DatabaseError(Exception):
"""Custom exception for database-related errors"""
def __init__(self, message: str, status_code: int = 500):
self.message = message
self.status_code = status_code
super().__init__(self.message)
class ValidationError(Exception):
"""Custom exception for data validation errors"""
def __init__(self, message: str, field: Optional[str] = None):
self.message = message
self.field = field
self.status_code = 400
super().__init__(self.message)
class ExternalServiceError(Exception):
"""Custom exception for external service failures"""
def __init__(self, service_name: str, message: str):
self.service_name = service_name
self.message = message
self.status_code = 502
super().__init__(self.message)
# Global exception handlers for custom exceptions
@app.exception_handler(DatabaseError)
async def database_error_handler(request: Request, exc: DatabaseError):
return JSONResponse(
status_code=exc.status_code,
content={
"error": "Database error",
"detail": exc.message,
"type": "DatabaseError"
}
)
@app.exception_handler(ValidationError)
async def validation_error_handler(request: Request, exc: ValidationError):
content = {
"error": "Validation error",
"detail": exc.message,
"type": "ValidationError"
}
if exc.field:
content["field"] = exc.field
return JSONResponse(status_code=exc.status_code, content=content)
@app.exception_handler(ExternalServiceError)
async def external_service_error_handler(request: Request, exc: ExternalServiceError):
return JSONResponse(
status_code=exc.status_code,
content={
"error": f"External service error: {exc.service_name}",
"detail": exc.message,
"type": "ExternalServiceError"
}
)
# Service class using custom exceptions
class UserService:
def __init__(self, db_connection: str = "default"):
self.db_connection = db_connection
async def get_user(self, user_id: int):
try:
# Simulated database operation
if user_id < 0:
raise ValidationError(
message="User ID cannot be negative",
field="user_id"
)
# Simulate database lookup
if user_id == 0:
raise DatabaseError("User database is corrupted")
# Simulate external service call
if user_id == 999:
raise ExternalServiceError(
service_name="EmailService",
message="Failed to fetch user preferences"
)
return {"id": user_id, "name": "John Doe", "email": "john@example.com"}
except (DatabaseError, ValidationError, ExternalServiceError):
# Re-raise custom exceptions to be handled by global handlers
raise
except Exception as e:
# Convert unexpected errors to DatabaseError
raise DatabaseError(f"Unexpected error: {str(e)}")
@app.get("/users/{user_id}")
async def get_user(user_id: int, service: UserService = Depends()):
user = await service.get_user(user_id)
return {"success": True, "data": user}
This class-based approach offers several advantages:
- Separation of concerns: Custom exceptions are defined separately from business logic
- Reusability: The
UserServiceclass can be used across multiple endpoints - Consistent error responses: Global handlers ensure all errors follow the same format
- Type safety: Each exception carries specific information about the error
Step-by-Step Exercise
Let’s build a complete exception handling system for a simple task management API. Follow these steps to practice what you’ve learned:
Step 1: Project Setup
Create a new file called task_api.py and set up the basic FastAPI app:
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import List, Optional
import logging
app = FastAPI()
# In-memory task storage
tasks = {}
task_id_counter = 1
Step 2: Define Custom Exceptions
class TaskNotFoundError(Exception):
def __init__(self, task_id: int):
self.task_id = task_id
self.message = f"Task with ID {task_id} not found"
super().__init__(self.message)
class TaskValidationError(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(self.message)
Step 3: Create Global Exception Handlers
@app.exception_handler(TaskNotFoundError)
async def task_not_found_handler(request, exc: TaskNotFoundError):
return JSONResponse(
status_code=404,
content={"error": "Task not found", "detail": exc.message}
)
@app.exception_handler(TaskValidationError)
async def task_validation_handler(request, exc: TaskValidationError):
return JSONResponse(
status_code=400,
content={"error": "Validation error", "detail": exc.message}
)
@app.exception_handler(Exception)
async def general_exception_handler(request, exc: Exception):
logging.error(f"Unhandled exception: {str(exc)}")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": "An unexpected error occurred"}
)
Step 4: Implement API Endpoints
class TaskCreate(BaseModel):
title: str
description: Optional[str] = None
completed: bool = False
class Task(BaseModel):
id: int
title: str
description: Optional[str]
completed: bool
@app.post("/tasks/", response_model=Task)
async def create_task(task: TaskCreate):
global task_id_counter
if not task.title.strip():
raise TaskValidationError("Task title cannot be empty")
new_task = Task(
id=task_id_counter,
title=task.title,
description=task.description,
completed=task.completed
)
tasks[task_id_counter] = new_task
task_id_counter += 1
return new_task
@app.get("/tasks/{task_id}", response_model=Task)
async def get_task(task_id: int):
if task_id not in tasks:
raise TaskNotFoundError(task_id)
return tasks[task_id]
@app.get("/tasks/", response_model=List[Task])
async def list_tasks():
return list(tasks.values())
@app.put("/tasks/{task_id}", response_model=Task)
async def update_task(task_id: int, task_update: TaskCreate):
if task_id not in tasks:
raise TaskNotFoundError(task_id)
if not task_update.title.strip():
raise TaskValidationError("Task title cannot be empty")
updated_task = Task(
id=task_id,
title=task_update.title,
description=task_update.description,
completed=task_update.completed
)
tasks[task_id] = updated_task
return updated_task
@app.delete("/tasks/{task_id}")
async def delete_task(task_id: int):
if task_id not in tasks:
raise TaskNotFoundError(task_id)
del tasks[task_id]
return {"message": f"Task {task_id} deleted successfully"}
Step 5: Test Your API
Run the application and test the endpoints:
# Start the server
uvicorn task_api:app --reload
# Test creating a task
curl -X POST "http://localhost:8000/tasks/"
-H "Content-Type: application/json"
-d '{"title": "Learn FastAPI", "description": "Study exception handling"}'
# Test getting a non-existent task (should return 404)
curl "http://localhost:8000/tasks/999"
# Test creating a task with empty title (should return 400)
curl -X POST "http://localhost:8000/tasks/"
-H "Content-Type: application/json"
-d '{"title": ""}'
Step 6: Add Logging
Enhance your error handling with proper logging:
import logging
from datetime import datetime
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('api_errors.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
@app.exception_handler(TaskNotFoundError)
async def task_not_found_handler(request, exc: TaskNotFoundError):
logger.warning(f"Task not found: {exc.task_id} - IP: {request.client.host}")
return JSONResponse(
status_code=404,
content={"error": "Task not found", "detail": exc.message}
)
Interview and Job Use Cases
Exception handling in FastAPI is a common topic in technical interviews and real-world job scenarios. Here’s what you need to know:
Interview Questions You Might Face
- “How do you handle validation errors in FastAPI?”
Explain that FastAPI automatically validates request data using Pydantic models, but you can customize the response usingRequestValidationErrorexception handlers. - “What’s the difference between HTTPException and custom exceptions?”
HTTPExceptionis built-in and returns standard HTTP error responses. Custom exceptions allow you to define application-specific errors with custom attributes and handling logic. - “How would you handle database connection failures in a production API?”
Discuss using try-except blocks around database operations, implementing retry logic, and returning appropriate HTTP status codes (503 for unavailable, 500 for unexpected errors). - “What logging strategy would you implement for error tracking?”
Mention using Python’s logging module with different levels (ERROR, WARNING, INFO), rotating file handlers, and integrating with monitoring tools like Sentry or ELK stack.
Real-World Job Scenarios
Scenario 1: E-commerce API
In an e-commerce application, you might need to handle:
- Payment gateway failures (502 Bad Gateway)
- Out-of-stock products (409 Conflict)
- Invalid coupon codes (400 Bad Request)
- User authentication errors (401 Unauthorized)
Scenario 2: Social Media Platform
Common exceptions include:
- Rate limiting (429 Too Many Requests)
- Content moderation failures (422 Unprocessable Entity)
- File upload size exceeded (413 Payload Too Large)
- Friend request already sent (409 Conflict)
Production Best Practices
# Example logging configuration for production
export LOG_LEVEL=ERROR
export LOG_FILE=/var/log/fastapi/app.log
export SENTRY_DSN=https://your-sentry-dsn@sentry.io/12345
# Production-ready exception handling
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
sentry_sdk.init(
dsn="your-sentry-dsn",
integrations=[FastApiIntegration()],
traces_sample_rate=1.0
)
@app.exception_handler(Exception)
async def production_error_handler(request, exc: Exception):
# Send to Sentry for monitoring
sentry_sdk.capture_exception(exc)
# Log the error
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
# Don't expose internal details in production
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "request_id": request.state.request_id}
)
Extra Beginner FAQs
1. What’s the difference between raise HTTPException and return JSONResponse?
HTTPException is a special exception that FastAPI catches and converts to a JSON response. It’s cleaner for most cases because it integrates with FastAPI’s exception handling system. JSONResponse is a direct response object that bypasses exception handling. Use HTTPException unless you need to return a response without triggering exception handlers.
2. Should I catch all exceptions in every endpoint?
No! That would make your code repetitive and hard to maintain. Instead, use global exception handlers for common errors and only catch specific exceptions in endpoints when you need special handling. The global handler acts as a safety net for unexpected errors.
3. How do I return different status codes for different errors?
Create custom exception classes that include a status_code attribute. Then, in your global handler, use that attribute to set the response status code. This keeps your endpoint code clean while allowing different error types to return different HTTP statuses.
4. What’s the best way to log errors without exposing sensitive data?
Use structured logging with Python’s logging module. Log the error type, timestamp, and a sanitized message. Never log passwords, API keys, or personal user data. Consider using a logging service like Sentry or Datadog that automatically filters sensitive information.
5. How do I test my exception handling code?
Use FastAPI’s TestClient to simulate different error scenarios. Create test cases that trigger each exception type and verify the response status code and body. This ensures your error handling works correctly before deploying to production.
# Example test for exception handling
from fastapi.testclient import TestClient
client = TestClient(app)
def test_task_not_found():
response = client.get("/tasks/999")
assert response.status_code == 404
assert response.json()["error"] == "Task not found"
def test_empty_title():
response = client.post("/tasks/", json={"title": ""})
assert response.status_code == 400
assert response.json()["error"] == "Validation error"
By mastering exception handling in FastAPI, you’ll build more reliable and maintainable APIs that gracefully handle errors and provide clear feedback to users and developers alike.
