Mapping Custom Exceptions to HTTP Status Codes in the Open Notebook FastAPI API
Open Notebook maps its domain-specific custom exceptions to precise HTTP status codes using FastAPI's global exception handlers, ensuring API consumers receive consistent, actionable error responses instead of generic 500 errors.
Open Notebook uses a structured error handling strategy to translate internal domain errors into standard HTTP responses. By defining a custom exception hierarchy in open_notebook/exceptions.py and registering dedicated handlers in the API layer, the codebase maintains a strict separation between business logic and transport concerns. This article explains how the project implements mapping custom exceptions to HTTP status codes using FastAPI's exception handler registry.
The Custom Exception Hierarchy
Domain errors are defined as specific Python exception classes in open_notebook/exceptions.py. Each class represents a distinct failure condition, allowing the service layer to communicate intent without knowing HTTP specifics.
| Custom Exception | HTTP Status | Meaning |
|---|---|---|
| NotebookNotFound | 404 Not Found | Requested notebook ID does not exist. |
| InvalidSource | 400 Bad Request | Malformed or unsupported source file/URL provided. |
| AuthenticationError | 401 Unauthorized | Missing or invalid credentials for an AI provider. |
| PermissionDenied | 403 Forbidden | Authenticated user lacks permission to modify the resource. |
| RateLimitExceeded | 429 Too Many Requests | Client exceeded the allowed request quota. |
| ServerError | 500 Internal Server Error | Unhandled runtime failure base class. |
These inherit from a common base where appropriate, allowing handlers to catch specific errors or broad categories.
Global Exception Handler Registration
The actual mapping custom exceptions to HTTP status codes occurs in api/exception_handlers.py (or alternatively within api/main.py). Here, FastAPI's @app.exception_handler decorator binds each Python exception to a function that returns a JSONResponse with the appropriate status code.
The project uses a private _json_error utility to enforce a consistent error payload structure across all handlers:
# api/exception_handlers.py
from fastapi import Request, FastAPI
from fastapi.responses import JSONResponse
from open_notebook.exceptions import (
NotebookNotFound,
InvalidSource,
AuthenticationError,
PermissionDenied,
RateLimitExceeded,
ServerError,
)
import logging
logger = logging.getLogger(__name__)
app = FastAPI()
def _json_error(message: str, status_code: int):
"""Builds a standardized JSON error response."""
return JSONResponse(
status_code=status_code,
content={"detail": message},
)
@app.exception_handler(NotebookNotFound)
async def notebook_not_found_handler(request: Request, exc: NotebookNotFound):
return _json_error(str(exc), 404)
@app.exception_handler(InvalidSource)
async def invalid_source_handler(request: Request, exc: InvalidSource):
return _json_error(str(exc), 400)
@app.exception_handler(AuthenticationError)
async def auth_error_handler(request: Request, exc: AuthenticationError):
return _json_error(str(exc), 401)
@app.exception_handler(PermissionDenied)
async def permission_denied_handler(request: Request, exc: PermissionDenied):
return _json_error(str(exc), 403)
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
return _json_error(str(exc), 429)
@app.exception_handler(ServerError)
async def server_error_handler(request: Request, exc: ServerError):
logger.error("Unhandled server error: %s", exc)
return _json_error("An unexpected error occurred.", 500)
Routers are imported after these handlers are registered, ensuring any exception raised downstream is automatically intercepted and transformed.
Practical Implementation
Raising Exceptions from the Service Layer
Business logic in open_notebook/services/notebook_service.py raises custom exceptions to signal failure conditions. This keeps the service layer agnostic of HTTP transport details:
# open_notebook/services/notebook_service.py
from open_notebook.exceptions import NotebookNotFound
async def get_notebook(notebook_id: str):
notebook = await repository.fetch_notebook(notebook_id)
if notebook is None:
raise NotebookNotFound(f"Notebook '{notebook_id}' does not exist.")
return notebook
Automatic HTTP Response Conversion
API routes in api/routers/notebooks.py call these services directly. When get_notebook raises NotebookNotFound, the global handler converts it to a 404 response automatically:
# api/routers/notebooks.py
from fastapi import APIRouter
from open_notebook.services.notebook_service import get_notebook
router = APIRouter()
@router.get("/{notebook_id}")
async def read_notebook(notebook_id: str):
# Raises NotebookNotFound if missing; handler returns 404
notebook = await get_notebook(notebook_id)
return {"data": notebook}
The resulting HTTP exchange looks like this:
GET /api/notebooks/unknown-id HTTP/1.1
Host: localhost
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"detail": "Notebook 'unknown-id' does not exist."
}
Summary
- Open Notebook defines domain-specific exceptions in
open_notebook/exceptions.pyto represent business logic errors like missing notebooks or authentication failures. - FastAPI exception handlers in
api/exception_handlers.pymap each custom exception to a specific HTTP status code (e.g., 404, 401, 429) using the@app.exception_handlerdecorator. - The service layer raises these exceptions naturally, while the API layer handles the translation to JSON responses, ensuring a clean separation of concerns.
- A utility function
_json_errorstandardizes the error payload structure across all mapped exceptions.
Frequently Asked Questions
What is the benefit of using custom exceptions instead of HTTPException directly?
Custom exceptions keep the core business logic independent of the web framework. If the transport layer changes from HTTP to a message queue or CLI, the domain errors remain valid without refactoring status code logic scattered through services.
Where are the exception handlers registered in Open Notebook?
Handlers are registered in api/exception_handlers.py using FastAPI's @app.exception_handler decorator. This module is imported and initialized before API routers are included, ensuring global coverage for all endpoint code.
How can I add a new custom exception and status code mapping?
First, define the new exception class in open_notebook/exceptions.py. Then add a handler function in api/exception_handlers.py decorated with @app.exception_handler(YourNewException), returning _json_error(str(exc), your_status_code). Restart the application to load the new mapping.
What happens if an exception is not mapped to a handler?
Unmapped exceptions bubble up as standard Python exceptions. FastAPI's default behavior catches these and returns a 500 Internal Server Error without a structured JSON body, bypassing the _json_error formatting and logging defined for known errors.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →