Files
webber/webber-api/docs/fastapi-best-practices.md
T
jpmschweitzerandClaude Opus 4.5 3b58fa4f8b
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 2m27s
refactor: reorganize into monorepo with separate subprojects
Structure webber into three independent subprojects:
- webber-api/: FastAPI backend server with all agent code
- webber-cli/: Standalone CLI client (renamed from cli/ to webber_cli/)
- webber-sandbox/: Test project for functional testing

Key changes:
- Each subproject has its own .venv (Python 3.12+)
- Added sandbox.sh for managing test project templates
- Created sandbox-templates/ with calculator-cli and empty starter
- Updated CI/CD for prefixed tags (api/v*, cli/v*)
- Added comprehensive AGENTS.md with operational instructions
- Added gitignore filtering to glob and grep tools
- Created pyproject.toml for each subproject

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 10:37:47 +01:00

23 KiB

FastAPI Best Practices

A comprehensive guide for building production-grade FastAPI applications. Based on patterns from zhanymkanov/fastapi-best-practices with additional patterns for logging, authentication, and scalable architecture.


1. Project Structure

Domain-Based Organization (NOT File-Type Based)

Do NOT group files by type (e.g., one huge routers/ folder). Group by domain/module inside a src/ directory.

project/
├── AGENTS.md                    # AI/developer guidelines
├── README.md                    # Project overview
├── CHANGELOG.md                 # Version history
├── pyproject.toml               # Package metadata
├── requirements.txt             # Production dependencies only
├── requirements-dev.txt         # Dev/test dependencies
├── .env.example                 # Environment template
│
├── src/
│   ├── __init__.py
│   ├── main.py                  # FastAPI app, lifespan (NO routes here)
│   │
│   ├── shared/                  # Cross-cutting concerns
│   │   ├── __init__.py
│   │   ├── base.py              # BaseController, BaseSchema
│   │   ├── config.py            # Pydantic BaseSettings
│   │   ├── logging.py           # Logger decorator + centralized setup
│   │   ├── exceptions.py        # Custom exception hierarchy
│   │   ├── auth.py              # Authentication utilities
│   │   └── context.py           # Request context (user provider, etc.)
│   │
│   └── domains/                 # Feature domains
│       ├── __init__.py
│       ├── router.py            # Root router - composes all domain routers
│       │
│       ├── health/              # Health check domain
│       │   ├── __init__.py
│       │   ├── router.py        # Routes
│       │   └── controller.py    # Business logic
│       │
│       └── {domain}/            # Each feature domain
│           ├── __init__.py
│           ├── router.py        # Domain routes
│           ├── controller.py    # Business logic
│           ├── schemas.py       # Pydantic models
│           ├── service.py       # External service calls (optional)
│           └── exceptions.py    # Domain-specific exceptions (optional)
│
├── tests/
│   ├── __init__.py
│   ├── conftest.py              # Pytest fixtures
│   └── test_{domain}.py
│
└── docs/
    └── architecture.md

Key Principles

  1. Clean main.py: Only app creation, middleware, lifespan. NO routes.
  2. Domain routers: Each domain has router.py. Root domains/router.py composes them.
  3. Shared utilities: Cross-cutting concerns in shared/ - import from there, not across domains.
  4. Self-contained domains: Each domain can be understood in isolation.

2. Base Patterns

BaseController

from abc import ABC, abstractmethod
from fastapi import APIRouter


class BaseController(ABC):
    """
    Base controller with lazy router instantiation.

    All domain controllers inherit from this and implement create_router().
    """

    def __init__(self, prefix: str, tags: list[str]):
        self.prefix = prefix
        self.tags = tags
        self._router = None

    @abstractmethod
    def create_router(self) -> APIRouter:
        """Create and configure the FastAPI router with all routes."""
        pass

    @property
    def router(self) -> APIRouter:
        """Lazy router instantiation."""
        if self._router is None:
            self._router = self.create_router()
        return self._router

Usage:

# src/domains/health/controller.py
class HealthController(BaseController):
    def __init__(self):
        super().__init__(prefix="", tags=["Health"])

    def create_router(self) -> APIRouter:
        router = APIRouter(tags=self.tags)

        @router.get("/health")
        async def health_check():
            return {"status": "healthy"}

        return router

health_controller = HealthController()

BaseSchema

from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict


class BaseSchema(BaseModel):
    """Base Pydantic model with standardized configuration."""

    model_config = ConfigDict(
        strict=False,
        populate_by_name=True,
        use_enum_values=True,
        validate_assignment=True,
        json_encoders={
            datetime: lambda v: v.isoformat() if v else None
        }
    )

    def dict_without_none(self) -> dict[str, Any]:
        """Return model as dict, excluding None values."""
        return {k: v for k, v in self.model_dump().items() if v is not None}

3. Configuration

Pydantic Settings

# src/shared/config.py
import tomllib
from pathlib import Path
from functools import lru_cache
from pydantic_settings import BaseSettings


def _get_version() -> str:
    """Load version from pyproject.toml."""
    try:
        with open(Path(__file__).parent.parent.parent / "pyproject.toml", "rb") as f:
            return tomllib.load(f).get("project", {}).get("version", "0.0.0")
    except FileNotFoundError:
        return "0.0.0"


class Settings(BaseSettings):
    """Application settings loaded from environment."""

    # Application
    app_name: str = "MyApp"
    app_version: str = _get_version()
    debug: bool = False

    # Server
    host: str = "0.0.0.0"
    port: int = 8000

    # Logging
    log_level: str = "INFO"

    # CORS
    cors_origins: list[str] = ["http://localhost:3000"]
    cors_credentials: bool = True
    cors_methods: list[str] = ["*"]
    cors_headers: list[str] = ["*"]

    class Config:
        env_file = ".env"
        case_sensitive = False
        extra = "ignore"


@lru_cache()
def get_settings() -> Settings:
    """Cached settings singleton."""
    return Settings()

4. Logging with Temporal Benchmarking

Logger Decorator

# src/shared/logging.py
import functools
import asyncio
import time
import logging
import sys
from pathlib import Path
from typing import Callable, Optional
from contextvars import ContextVar
from dataclasses import dataclass, field
from uuid import uuid4


# === Trace Context ===

@dataclass
class TraceSpan:
    """Represents a timed execution span."""
    name: str
    trace_id: str
    parent_id: Optional[str] = None
    span_id: str = field(default_factory=lambda: uuid4().hex[:8])
    start_time: float = field(default_factory=time.perf_counter)
    end_time: Optional[float] = None

    @property
    def duration_ms(self) -> float:
        end = self.end_time or time.perf_counter()
        return (end - self.start_time) * 1000


_current_span: ContextVar[Optional[TraceSpan]] = ContextVar('current_span', default=None)
_trace_id: ContextVar[Optional[str]] = ContextVar('trace_id', default=None)


def get_current_trace_id() -> Optional[str]:
    """Get current trace ID for log correlation."""
    return _trace_id.get()


# === Setup ===

def setup_logging(log_level: str = "INFO") -> None:
    """Configure application logging."""
    log_dir = Path("logs")
    log_dir.mkdir(exist_ok=True)

    logging.basicConfig(
        level=getattr(logging, log_level.upper()),
        format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
        handlers=[
            logging.StreamHandler(sys.stdout),
            logging.FileHandler(log_dir / "app.log", encoding="utf-8")
        ]
    )

    # Quiet noisy libraries
    for name in ["httpx", "httpcore", "uvicorn.access"]:
        logging.getLogger(name).setLevel(logging.WARNING)


def get_logger(name: str) -> logging.Logger:
    """Get a logger instance."""
    return logging.getLogger(name)


# === Decorator ===

def logged(
    logger: logging.Logger = None,
    slow_threshold_ms: float = 100.0,
    warn_threshold_ms: float = 500.0,
    include_args: bool = False,
):
    """
    Decorator for automatic function logging with temporal benchmarking.

    Args:
        logger: Logger instance (defaults to module logger)
        slow_threshold_ms: Log INFO if execution exceeds this (default 100ms)
        warn_threshold_ms: Log WARNING if execution exceeds this (default 500ms)
        include_args: Include function arguments in log (careful with sensitive data)

    Usage:
        @logged()
        async def my_function(): ...

        @logged(slow_threshold_ms=50, warn_threshold_ms=200)
        def critical_path(): ...
    """
    def decorator(func: Callable):
        nonlocal logger
        if logger is None:
            logger = logging.getLogger(func.__module__)

        func_name = f"{func.__module__}.{func.__qualname__}"

        def _create_span() -> TraceSpan:
            parent = _current_span.get()
            trace_id = _trace_id.get() or uuid4().hex[:16]
            if _trace_id.get() is None:
                _trace_id.set(trace_id)
            return TraceSpan(
                name=func_name,
                trace_id=trace_id,
                parent_id=parent.span_id if parent else None,
            )

        def _log_completion(span: TraceSpan, error: Exception = None):
            span.end_time = time.perf_counter()
            duration = span.duration_ms
            tid = span.trace_id[:8]

            if error:
                logger.error(f"[{tid}] {func_name} FAILED after {duration:.2f}ms: {error}", exc_info=True)
            elif duration >= warn_threshold_ms:
                logger.warning(f"[{tid}] {func_name} SLOW: {duration:.2f}ms (threshold: {warn_threshold_ms}ms)")
            elif duration >= slow_threshold_ms:
                logger.info(f"[{tid}] {func_name} completed in {duration:.2f}ms")
            else:
                logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")

        @functools.wraps(func)
        async def async_wrapper(*args, **kwargs):
            span = _create_span()
            token = _current_span.set(span)
            logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
            try:
                result = await func(*args, **kwargs)
                _log_completion(span)
                return result
            except Exception as e:
                _log_completion(span, error=e)
                raise
            finally:
                _current_span.reset(token)

        @functools.wraps(func)
        def sync_wrapper(*args, **kwargs):
            span = _create_span()
            token = _current_span.set(span)
            logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
            try:
                result = func(*args, **kwargs)
                _log_completion(span)
                return result
            except Exception as e:
                _log_completion(span, error=e)
                raise
            finally:
                _current_span.reset(token)

        return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
    return decorator


# === Context Manager ===

class trace_span:
    """
    Context manager for manual span creation.

    Usage:
        with trace_span("database_query"):
            result = db.execute(query)

        async with trace_span("external_api_call"):
            response = await client.get(url)
    """
    def __init__(self, name: str, logger: logging.Logger = None):
        self.name = name
        self.logger = logger or logging.getLogger(__name__)
        self.span: Optional[TraceSpan] = None
        self.token = None

    def __enter__(self):
        parent = _current_span.get()
        trace_id = _trace_id.get() or uuid4().hex[:16]
        if _trace_id.get() is None:
            _trace_id.set(trace_id)

        self.span = TraceSpan(
            name=self.name,
            trace_id=trace_id,
            parent_id=parent.span_id if parent else None,
        )
        self.token = _current_span.set(self.span)
        self.logger.debug(f"[{self.span.trace_id[:8]}] -> {self.name}")
        return self.span

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.span:
            self.span.end_time = time.perf_counter()
            duration = self.span.duration_ms
            tid = self.span.trace_id[:8]
            if exc_val:
                self.logger.error(f"[{tid}] {self.name} FAILED: {duration:.2f}ms")
            else:
                self.logger.debug(f"[{tid}] {self.name}: {duration:.2f}ms")
        if self.token:
            _current_span.reset(self.token)
        return False

    async def __aenter__(self):
        return self.__enter__()

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        return self.__exit__(exc_type, exc_val, exc_tb)

Example output:

DEBUG [a1b2c3d4] -> src.domains.users.controller.get_user
DEBUG [a1b2c3d4] -> src.domains.users.service.fetch_from_db
DEBUG [a1b2c3d4] src.domains.users.service.fetch_from_db: 12.34ms
DEBUG [a1b2c3d4] src.domains.users.controller.get_user completed in 15.67ms
WARN  [a1b2c3d4] src.domains.reports.controller.generate SLOW: 523.45ms

5. Request Context (User Provider)

Singleton Pattern with ContextVars

# src/shared/context.py
from dataclasses import dataclass
from typing import Optional
from contextvars import ContextVar


@dataclass
class User:
    """Authenticated user context."""
    id: str
    email: str
    tenant_id: Optional[str] = None
    roles: list[str] = None

    def __post_init__(self):
        if self.roles is None:
            self.roles = []


_current_user: ContextVar[Optional[User]] = ContextVar('current_user', default=None)


class UserProvider:
    """
    Singleton for request-scoped user context.

    Set once per request in middleware, accessible everywhere without
    passing user through function parameters.
    """
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def set_user(self, user: User) -> None:
        _current_user.set(user)

    def get_user(self) -> Optional[User]:
        return _current_user.get()

    def clear_user(self) -> None:
        _current_user.set(None)

    @property
    def current_user(self) -> Optional[User]:
        return self.get_user()


# Global singleton
user_provider = UserProvider()


def get_current_user() -> Optional[User]:
    """Convenience function to get current user."""
    return user_provider.get_user()


def require_user() -> User:
    """Get current user or raise if not authenticated."""
    user = user_provider.get_user()
    if user is None:
        raise ValueError("No authenticated user in context")
    return user

Usage in middleware:

@app.middleware("http")
async def auth_middleware(request: Request, call_next):
    # Validate token, get user...
    user = await validate_token(request)
    if user:
        user_provider.set_user(user)
    try:
        response = await call_next(request)
        return response
    finally:
        user_provider.clear_user()

Usage in any function:

from src.shared.context import get_current_user, require_user

async def some_business_logic():
    user = require_user()  # Raises if not authenticated
    # Use user.id, user.tenant_id, etc.

6. Application Entry Point

Clean main.py

# src/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from src.shared.config import get_settings
from src.shared.logging import setup_logging, get_logger
from src.domains.router import root_router

settings = get_settings()
setup_logging(settings.log_level)
logger = get_logger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application startup and shutdown."""
    logger.info("=" * 60)
    logger.info(f"Starting {settings.app_name} v{settings.app_version}")
    logger.info(f"Debug: {settings.debug}")
    logger.info("=" * 60)

    # Initialize resources (DB connections, caches, etc.)

    yield

    # Cleanup resources
    logger.info("Shutting down")


app = FastAPI(
    title=settings.app_name,
    version=settings.app_version,
    docs_url="/docs",
    redoc_url=None,
    lifespan=lifespan,
    debug=settings.debug,
)

# CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=settings.cors_credentials,
    allow_methods=settings.cors_methods,
    allow_headers=settings.cors_headers,
)

# Include all routes from domain router
app.include_router(root_router)


# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
    logger.error(f"Unhandled exception: {exc}", exc_info=True)
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error", "type": type(exc).__name__}
    )

Root Router

# src/domains/router.py
from fastapi import APIRouter
from src.domains.health.controller import health_controller
# from src.domains.users.controller import users_controller
# from src.domains.items.controller import items_controller

root_router = APIRouter()

# Include all domain routers
root_router.include_router(health_controller.router)
# root_router.include_router(users_controller.router, prefix="/users", tags=["Users"])
# root_router.include_router(items_controller.router, prefix="/items", tags=["Items"])

7. Dependency Management

CRITICAL: Always Start with Latest Safe Versions

When setting up a new project, ALWAYS search for the latest version of each package and verify it has no known CVEs.

Do NOT copy version numbers from old projects or templates. Package versions in documentation become outdated quickly.

Process for each dependency:

  1. Search: "{package} pypi latest version {current_year}"
  2. Check PyPI directly: https://pypi.org/project/{package}/
  3. Search: "{package} CVE vulnerability {current_year}"
  4. Verify no unpatched CVEs affect the latest version
  5. Pin to the verified latest safe version

Example search queries:

"fastapi pypi latest version 2026"
"starlette CVE vulnerability 2026"
"pydantic-ai pypi latest version 2026"

requirements.txt (Production)

# Production Dependencies
# Minor version pinning (~=) allows patch updates for security fixes
# CVE check date: YYYY-MM-DD  <-- UPDATE THIS DATE
# CVE sources: PyPI, GitHub Advisories, Snyk, NVD

# Core - VERIFY LATEST VERSIONS BEFORE USE
fastapi~=0.128.0
starlette~=0.50.0                      # CVE-2025-62727, CVE-2025-54121 fixed
uvicorn[standard]~=0.40.0
pydantic~=2.12.4                       # CVE-2024-3772 (ReDoS) fixed in 2.4.0+
pydantic-settings~=2.12.0

# HTTP client
httpx~=0.28.1
aiofiles~=25.1.0

# Utilities
python-multipart~=0.0.21
python-dotenv~=1.2.1

requirements-dev.txt (Development)

# Development Dependencies
# NOT included in production Docker image
# CVE check date: YYYY-MM-DD  <-- UPDATE THIS DATE

-r requirements.txt

# Testing - VERIFY LATEST VERSIONS BEFORE USE
pytest~=9.0.2
anyio~=4.12.1                          # Includes pytest-anyio plugin
pytest-cov~=7.0.0

# Security auditing
pip-audit~=2.9.0

# Type checking
mypy~=1.19.1

# Linting (optional)
# ruff~=0.9.0

CVE Check Process

Before adding or updating dependencies:

  1. Check PyPI for security advisories: https://pypi.org/project/{package}/
  2. GitHub Security Advisories: https://github.com/advisories
  3. Snyk vulnerability database: https://snyk.io/vuln
  4. NVD: https://nvd.nist.gov/vuln/search
  5. Run pip-audit: pip-audit before releases

Document decisions in requirements.txt:

package~=1.2.0  # CVE-YYYY-XXXXX: pinned due to vulnerability in < 1.2.0

8. Exception Handling

Custom Exception Hierarchy

# src/shared/exceptions.py
from typing import Any, Optional


class AppException(Exception):
    """Base exception for application errors."""

    def __init__(
        self,
        message: str,
        status_code: int = 500,
        error_code: Optional[str] = None,
        details: Optional[dict[str, Any]] = None,
    ):
        self.message = message
        self.status_code = status_code
        self.error_code = error_code or self.__class__.__name__
        self.details = details or {}
        super().__init__(message)

    def to_dict(self) -> dict[str, Any]:
        return {
            "error": self.error_code,
            "message": self.message,
            "details": self.details,
        }


class NotFoundError(AppException):
    def __init__(self, resource: str, identifier: Any):
        super().__init__(
            message=f"{resource} not found: {identifier}",
            status_code=404,
            details={"resource": resource, "identifier": str(identifier)},
        )


class ValidationError(AppException):
    def __init__(self, message: str, field: Optional[str] = None):
        super().__init__(
            message=message,
            status_code=422,
            details={"field": field} if field else {},
        )


class AuthenticationError(AppException):
    def __init__(self, message: str = "Authentication required"):
        super().__init__(message=message, status_code=401)


class AuthorizationError(AppException):
    def __init__(self, message: str = "Permission denied"):
        super().__init__(message=message, status_code=403)


class ConflictError(AppException):
    def __init__(self, message: str):
        super().__init__(message=message, status_code=409)


class RateLimitError(AppException):
    def __init__(self, retry_after: int = 60):
        super().__init__(
            message="Rate limit exceeded",
            status_code=429,
            details={"retry_after": retry_after},
        )

Exception Handler

# In main.py
from src.shared.exceptions import AppException

@app.exception_handler(AppException)
async def app_exception_handler(request, exc: AppException):
    return JSONResponse(
        status_code=exc.status_code,
        content=exc.to_dict(),
    )

9. Testing

conftest.py

# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from src.main import app


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.fixture
async def client():
    """Async test client."""
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test"
    ) as ac:
        yield ac

Example Test

# tests/test_health.py
import pytest


@pytest.mark.anyio
async def test_health_check(client):
    response = await client.get("/health")
    assert response.status_code == 200
    data = response.json()
    assert data["status"] == "healthy"

10. Summary Checklist

Project Setup

  • Domain-based directory structure (src/domains/)
  • Shared utilities in src/shared/
  • Clean main.py (no routes, only app setup)
  • Root router composing domain routers

Patterns

  • BaseController with lazy router
  • BaseSchema with standard config
  • Pydantic Settings for configuration
  • Logger decorator with temporal benchmarking
  • UserProvider singleton for request context
  • Custom exception hierarchy

Dependencies

  • Minor version pinning (~=)
  • Separate prod/dev requirements
  • CVE check before updates
  • pip-audit in CI/CD

Quality

  • Pytest with async support
  • Type hints throughout
  • Docstrings on public APIs
  • CHANGELOG.md maintained