# Mapping Custom Exceptions to HTTP Status Codes in the Open Notebook FastAPI API

> Learn how Open Notebook maps custom exceptions to HTTP status codes in FastAPI. Get consistent, actionable API error responses with this effective technique.

- Repository: [Luis Novo/open-notebook](https://github.com/lfnovo/open-notebook)
- Tags: how-to-guide
- Published: 2026-07-02

---

**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`](https://github.com/lfnovo/open-notebook/blob/main/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`](https://github.com/lfnovo/open-notebook/blob/main/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`](https://github.com/lfnovo/open-notebook/blob/main/api/exception_handlers.py) (or alternatively within [`api/main.py`](https://github.com/lfnovo/open-notebook/blob/main/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:

```python

# 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`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/services/notebook_service.py) raises custom exceptions to signal failure conditions. This keeps the service layer agnostic of HTTP transport details:

```python

# 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`](https://github.com/lfnovo/open-notebook/blob/main/api/routers/notebooks.py) call these services directly. When `get_notebook` raises `NotebookNotFound`, the global handler converts it to a 404 response automatically:

```python

# 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.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/exceptions.py) to represent business logic errors like missing notebooks or authentication failures.
- FastAPI exception handlers in [`api/exception_handlers.py`](https://github.com/lfnovo/open-notebook/blob/main/api/exception_handlers.py) map each custom exception to a specific HTTP status code (e.g., 404, 401, 429) using the `@app.exception_handler` decorator.
- 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_error` standardizes 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`](https://github.com/lfnovo/open-notebook/blob/main/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`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/exceptions.py). Then add a handler function in [`api/exception_handlers.py`](https://github.com/lfnovo/open-notebook/blob/main/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.