Refresh examples for modern stack; promote AGENTS.md to canonical agent ruleset (#89)

* Refresh examples for modern stack; promote AGENTS.md to canonical agent ruleset

Replaces stale code in README.md (python-jose, async_asgi_testclient,
json_encoders, contradictory `Field(ge=18, default=None)`, unused
`model_validator` import) with current equivalents (PyJWT, httpx +
ASGITransport, `@field_serializer`). Adds a top-of-file pointer to
AGENTS.md, a tested-versions line, and inline notes for `Annotated[T,
Depends(...)]` and SQLAlchemy 2.0 async without rewriting every example.
Adds a `dependency_overrides` testing subsection and a BackgroundTasks
vs. task-queue decision table.

Rewrites AGENTS.md as a terse, machine-readable mirror: compatibility
matrix at the top, Do/Don't blocks throughout, an explicit anti-patterns
table covering common AI-agent mistakes, and a quick-reference table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Sync code-only fixes into README_ZH.md

Mirrors the safe code-only changes from the parent commit into the
Chinese translation:
- python-jose -> PyJWT (import + exception class) in both dependency examples
- async_asgi_testclient -> httpx.AsyncClient + ASGITransport (and fixes
  the previously broken example that mixed both imports)
- Pydantic json_encoders -> @field_serializer for the CustomModel example
- Field(ge=18, default=None) -> Field(ge=18) (the constraint and the
  default contradict each other)
- Removes unused `model_validator` import

No prose changes. Existing Chinese descriptions still accurately
describe the new code. Sections newly added in README.md
(BackgroundTasks vs task queue, dependency_overrides, the agent banner,
the version line, and the Annotated/SQLAlchemy 2.0 inline notes) are
not ported here because they require translation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Trim docs/changelog voice from refresh

Pass over the refresh to keep the article in best-practices voice,
not docs or changelog:

- Drop the "Note on syntax" blockquote in README's Dependencies
  section. It hedged ("Both styles work. New code should prefer
  Annotated.") while every example below kept the default-arg form.
  Removing the callout returns the section to its original shape;
  a full Annotated migration can be its own PR.
- Remove the "json_encoders is deprecated in Pydantic v2" inline
  explainer. The code change already says it.
- Rewrite the databases blockquote into a single in-voice line
  ("For new projects, reach for SQLAlchemy 2.0's async API...")
  instead of an apologetic disclaimer.
- Collapse the "Earlier versions of this article recommended
  async_asgi_testclient" blockquote into a direct sentence.
- Drop the "this file wins — README is narrative, this is the spec"
  precedence line in AGENTS.md. The two files are peers; neither
  outranks the other.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yerassyl
2026-04-14 21:00:34 +05:00
committed by GitHub
parent 8fbaf55763
commit 52707b6917
3 changed files with 433 additions and 179 deletions

455
AGENTS.md
View File

@@ -1,160 +1,266 @@
# FastAPI Best Practices for AI Agents # FastAPI Best Practices for AI Agents
This document provides guidelines for AI agents working on FastAPI projects. Follow these conventions when writing or modifying code. A machine-readable companion to [README.md](./README.md) for AI coding agents
working in FastAPI projects. Same rules, restructured for fast pattern matching:
version pins, Do/Don't blocks, anti-patterns, and a quick-reference table.
## Compatibility Matrix
Pin to these versions or newer. Examples in this file assume them.
| Dependency | Minimum | Notes |
|------------------|-----------|------------------------------------------------------|
| Python | 3.11 | Required for `StrEnum` and `X \| Y` union syntax |
| FastAPI | 0.115 | `Annotated[T, Depends(...)]` is the idiomatic form |
| Pydantic | 2.7 | v1 APIs (`json_encoders`, `.dict()`) are removed |
| pydantic-settings| 2.4 | Lives in a separate package since Pydantic v2 |
| SQLAlchemy | 2.0 | Use the async API (`AsyncSession`, `async_sessionmaker`) |
| Alembic | 1.13 | Async-aware migrations |
| httpx | 0.27 | Use `ASGITransport` for in-process tests |
| PyJWT | 2.9 | Use this, not the unmaintained `python-jose` |
| ruff | 0.6 | Replaces black, isort, autoflake |
## Project Structure ## Project Structure
Organize code by domain, not by file type. Organize by domain, not by file type. One package per bounded context.
``` ```
src/ src/
├── {domain}/ # e.g., auth/, posts/, aws/ ├── {domain}/ # e.g., auth/, posts/, aws/
│ ├── router.py # API endpoints │ ├── router.py # API endpoints
│ ├── schemas.py # Pydantic models │ ├── schemas.py # Pydantic models
│ ├── models.py # Database models │ ├── models.py # SQLAlchemy ORM models
│ ├── service.py # Business logic │ ├── service.py # Business logic
│ ├── dependencies.py # Route dependencies │ ├── dependencies.py # Route dependencies
│ ├── config.py # Environment variables │ ├── config.py # Domain-scoped BaseSettings
│ ├── constants.py # Constants and error codes │ ├── constants.py # Constants and error codes
│ ├── exceptions.py # Domain-specific exceptions │ ├── exceptions.py # Domain-specific exceptions
│ └── utils.py # Helper functions │ └── utils.py # Helper functions
├── config.py # Global configuration ├── config.py # Global BaseSettings
├── models.py # Global models ├── models.py # Shared Pydantic / ORM bases
├── exceptions.py # Global exceptions ├── exceptions.py # Global exceptions
├── database.py # Database connection ├── database.py # Async engine + session factory
└── main.py # FastAPI app initialization └── main.py # FastAPI app + lifespan
``` ```
**Import Convention**: Use explicit module names when importing across domains: **Cross-domain imports**: always use the explicit module name. Never `from src.auth import *`.
```python ```python
from src.auth import constants as auth_constants from src.auth import constants as auth_constants
from src.notifications import service as notification_service from src.notifications import service as notification_service
from src.posts.constants import ErrorCode as PostsErrorCode
``` ```
## Async Routes ## Async Routes
### Rules ### Decision rule
- `async def` routes: Use ONLY non-blocking I/O (`await` calls)
- `def` routes (sync): Use for blocking I/O (runs in threadpool automatically) | Route does this | Use |
- CPU-intensive work: Offload to Celery or multiprocessing |----------------------------------------|-------------|
| `await`-able non-blocking I/O | `async def` |
| Blocking I/O (no async client exists) | `def` (sync, runs in threadpool) |
| Mix of both | `async def` + `run_in_threadpool` for the blocking part |
| CPU-bound work (>50 ms compute) | Offload to a worker process (Celery / RQ / Arq) |
### Do / Don't
### Common Mistakes to Avoid
```python ```python
# WRONG: Blocking call in async route # DON'T — blocking call inside async route freezes the entire event loop
@router.get("/bad") @router.get("/bad")
async def bad_route(): async def bad():
time.sleep(10) # Blocks entire event loop time.sleep(10) # blocks every request on this worker
return {"status": "done"} return {"ok": True}
# CORRECT: Non-blocking in async route # DO — sync route lets FastAPI run it in a threadpool
@router.get("/good") @router.get("/sync-ok")
async def good_route(): def sync_ok():
await asyncio.sleep(10) time.sleep(10) # blocks one threadpool worker, not the loop
return {"status": "done"} return {"ok": True}
# CORRECT: Sync route for blocking operations # DO — async route with awaitable sleep
@router.get("/also-good") @router.get("/async-ok")
def sync_route(): async def async_ok():
time.sleep(10) # Runs in threadpool await asyncio.sleep(10) # yields control, loop keeps serving requests
return {"status": "done"} return {"ok": True}
```
### Using Sync Libraries in Async Context # DO — async route that has to call a sync library
```python
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
@router.get("/") @router.get("/wrap")
async def call_sync_library(): async def wrap():
result = await run_in_threadpool(sync_client.make_request, data=my_data) result = await run_in_threadpool(legacy_sync_client.fetch, "id")
return result return result
``` ```
### Threadpool caveats
- Default Starlette threadpool size is 40. Saturating it slows every sync route.
- Threads cost more than coroutines. Don't use sync routes "just because."
## Pydantic ## Pydantic
### Use Built-in Validators ### Use built-in validators
```python ```python
from pydantic import BaseModel, EmailStr, Field from enum import StrEnum
from pydantic import AnyUrl, BaseModel, EmailStr, Field
class MusicBand(StrEnum):
AEROSMITH = "AEROSMITH"
QUEEN = "QUEEN"
ACDC = "AC/DC"
class UserCreate(BaseModel): class UserCreate(BaseModel):
username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$") first_name: str = Field(min_length=1, max_length=128)
username: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_-]+$")
email: EmailStr email: EmailStr
age: int = Field(ge=18) age: int = Field(ge=18) # required, must be >= 18
favorite_band: MusicBand | None = None
website: AnyUrl | None = None
``` ```
### Custom Base Model > **Don't** write `Field(ge=18, default=None)`. The constraint and the default contradict
Create a shared base model for consistent serialization: > each other. Decide: required (`Field(ge=18)`) or optional (`int | None = Field(default=None, ge=18)`).
### Custom base model — modern serialization
`json_encoders` is deprecated in Pydantic v2. Use `@field_serializer` for per-field rules,
or annotate a custom type with `PlainSerializer`.
```python ```python
from pydantic import BaseModel, ConfigDict from datetime import datetime
from zoneinfo import ZoneInfo
from pydantic import BaseModel, ConfigDict, field_serializer
class CustomModel(BaseModel): class CustomModel(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(populate_by_name=True)
json_encoders={datetime: datetime_to_gmt_str},
populate_by_name=True, @field_serializer("*", when_used="json", check_fields=False)
) def _serialize_datetimes(self, value):
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=ZoneInfo("UTC"))
return value.strftime("%Y-%m-%dT%H:%M:%S%z")
return value
``` ```
### Split BaseSettings by Domain ### Split BaseSettings by domain
`pydantic-settings` is its own package since Pydantic v2.
```python ```python
# src/auth/config.py # src/auth/config.py
from datetime import timedelta
from pydantic_settings import BaseSettings, SettingsConfigDict
class AuthConfig(BaseSettings): class AuthConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="AUTH_", env_file=".env", extra="ignore")
JWT_ALG: str JWT_ALG: str
JWT_SECRET: str JWT_SECRET: str
JWT_EXP: int = 5 JWT_EXP_MINUTES: int = 5
REFRESH_TOKEN_KEY: str
REFRESH_TOKEN_EXP: timedelta = timedelta(days=30)
SECURE_COOKIES: bool = True
auth_settings = AuthConfig() auth_settings = AuthConfig()
``` ```
## Dependencies ## Dependencies
### Use for Validation, Not Just DI ### Use Annotated, not default-arg `Depends(...)`
`Annotated[T, Depends(...)]` is the idiomatic form since FastAPI 0.95 and avoids
gotchas with default values.
```python ```python
async def valid_post_id(post_id: UUID4) -> dict[str, Any]: # DO — modern Annotated form
from typing import Annotated
from fastapi import Depends
PostDep = Annotated[dict, Depends(valid_post_id)]
@router.get("/posts/{post_id}")
async def get_post(post: PostDep):
return post
# Avoid — default-argument form (still works, but legacy)
@router.get("/posts/{post_id}")
async def get_post(post: dict = Depends(valid_post_id)):
return post
```
### Validate inside dependencies (not just inject)
```python
async def valid_post_id(post_id: UUID4) -> dict:
post = await service.get_by_id(post_id) post = await service.get_by_id(post_id)
if not post: if not post:
raise PostNotFound() raise PostNotFound()
return post return post
@router.get("/posts/{post_id}")
async def get_post(post: dict[str, Any] = Depends(valid_post_id)):
return post
``` ```
### Chain Dependencies ### Chain dependencies for reuse
```python ```python
async def valid_owned_post( async def valid_owned_post(
post: dict[str, Any] = Depends(valid_post_id), post: Annotated[dict, Depends(valid_post_id)],
token_data: dict[str, Any] = Depends(parse_jwt_data), token_data: Annotated[dict, Depends(parse_jwt_data)],
) -> dict[str, Any]: ) -> dict:
if post["creator_id"] != token_data["user_id"]: if post["creator_id"] != token_data["user_id"]:
raise UserNotOwner() raise UserNotOwner()
return post return post
``` ```
### Key Rules ### Rules
- Dependencies are cached per request (same dependency called multiple times = one execution) - Dependencies are **cached per request**. Same `Depends(x)` called 5 times in one request → `x` runs once.
- Prefer `async` dependencies to avoid threadpool overhead - Prefer `async def` dependencies. Sync deps run in the threadpool — wasted overhead for small CPU-only checks.
- Use consistent path variable names to enable dependency reuse - Use **the same path-variable name** across endpoints when you want to share a dependency (e.g. `profile_id` in both `/profiles/{profile_id}` and `/creators/{profile_id}`).
## REST Conventions ## Authentication — JWT
Use **`PyJWT`**, not `python-jose` (unmaintained).
Use consistent path variable names for dependency reuse:
```python ```python
# Both use profile_id, enabling shared valid_profile_id dependency import jwt # PyJWT
GET /profiles/{profile_id} from jwt.exceptions import InvalidTokenError
GET /creators/{profile_id}
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALG])
except InvalidTokenError as exc:
raise InvalidCredentials() from exc
``` ```
## Database ## Database — SQLAlchemy 2.0 async
### Naming Conventions Prefer SQLAlchemy 2.0's async API. `encode/databases` is in maintenance mode — don't pick it for new projects.
- Use `lower_case_snake` format
- Singular table names: `post`, `user`, `post_like`
- Group related tables with prefix: `payment_account`, `payment_bill`
- DateTime suffix: `_at` (e.g., `created_at`)
- Date suffix: `_date` (e.g., `birth_date`)
### Set Explicit Index Names
```python ```python
# src/database.py
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
engine = create_async_engine(str(settings.DATABASE_URL), pool_pre_ping=True)
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncSession:
async with SessionFactory() as session:
yield session
```
### Naming conventions
- `lower_case_snake`
- Singular tables: `post`, `user`, `post_like`
- Group with prefix: `payment_account`, `payment_bill`
- `_at` suffix for `datetime`, `_date` suffix for `date`
- Use the same FK column name everywhere it appears (`profile_id`, not `user_id` in some tables and `profile_id` in others)
### Index naming convention
```python
from sqlalchemy import MetaData
POSTGRES_INDEXES_NAMING_CONVENTION = { POSTGRES_INDEXES_NAMING_CONVENTION = {
"ix": "%(column_0_label)s_idx", "ix": "%(column_0_label)s_idx",
"uq": "%(table_name)s_%(column_0_name)s_key", "uq": "%(table_name)s_%(column_0_name)s_key",
@@ -162,89 +268,180 @@ POSTGRES_INDEXES_NAMING_CONVENTION = {
"fk": "%(table_name)s_%(column_0_name)s_fkey", "fk": "%(table_name)s_%(column_0_name)s_fkey",
"pk": "%(table_name)s_pkey", "pk": "%(table_name)s_pkey",
} }
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)
``` ```
### SQL-First Approach ### SQL-first, Pydantic-second
Prefer database-level operations for: - Do joins, aggregation, and JSON shaping in SQL — Postgres is faster than CPython at this.
- Complex joins - Hydrate the result into Pydantic only for response validation, not for transformation.
- Data aggregation
- Building nested JSON responses
## Migrations (Alembic) ## Background work — BackgroundTasks vs Celery
- Keep migrations static and reversible | Use BackgroundTasks when… | Use Celery / Arq / RQ when… |
- Use descriptive file names: `2022-08-24_post_content_idx.py` |------------------------------------------|--------------------------------------------|
- Configure in alembic.ini: | Task is < 1 second | Task takes seconds to minutes |
```ini | Failure can be silently dropped | You need retries, dead-letter, or visibility|
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s | Task is in-process (send email, log row) | Task is CPU-heavy or needs a separate pool |
``` | You don't need scheduling | You need cron, ETA, or rate limiting |
## API Documentation
### Hide Docs in Production
```python ```python
SHOW_DOCS_ENVIRONMENT = ("local", "staging") from fastapi import BackgroundTasks
app_configs = {"title": "My API"} @router.post("/signup")
if ENVIRONMENT not in SHOW_DOCS_ENVIRONMENT: async def signup(data: SignupIn, bg: BackgroundTasks):
app_configs["openapi_url"] = None user = await service.create_user(data)
bg.add_task(send_welcome_email, user.email) # fire-and-forget, in-process
app = FastAPI(**app_configs) return user
``` ```
### Document Endpoints Properly > BackgroundTasks run **after the response is sent, in the same worker process**. If the
```python > worker dies, the task is lost. There is no retry. Don't use them for anything you'd
@router.post( > page on.
"/endpoints",
response_model=DefaultResponseModel,
status_code=status.HTTP_201_CREATED,
description="Description of the endpoint",
tags=["Category"],
responses={
status.HTTP_201_CREATED: {"model": CreatedResponse},
status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse},
},
)
```
## Testing ## Testing
Use async test client from the start: ### Async client from day one
```python ```python
import pytest import pytest
from httpx import AsyncClient, ASGITransport from httpx import AsyncClient, ASGITransport
from src.main import app
@pytest.fixture @pytest.fixture
async def client(): async def client():
async with AsyncClient( transport = ASGITransport(app=app)
transport=ASGITransport(app=app), async with AsyncClient(transport=transport, base_url="http://test") as ac:
base_url="http://test" yield ac
) as client:
yield client
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_endpoint(client: AsyncClient): async def test_create_post(client: AsyncClient):
resp = await client.post("/posts") resp = await client.post("/posts", json={"title": "hi"})
assert resp.status_code == 201 assert resp.status_code == 201
``` ```
> **Don't** use `async_asgi_testclient` — it's unmaintained. The example above (httpx +
> `ASGITransport`) is the supported path.
### Override dependencies in tests
Don't monkeypatch internals. Use FastAPI's built-in `dependency_overrides`.
```python
from src.auth.dependencies import parse_jwt_data
from src.main import app
def fake_user():
return {"user_id": "00000000-0000-0000-0000-000000000001"}
@pytest.fixture(autouse=True)
def _override_auth():
app.dependency_overrides[parse_jwt_data] = fake_user
yield
app.dependency_overrides.clear()
```
## Migrations (Alembic)
- Migrations must be static and reversible.
- Use the async template: `alembic init -t async migrations`
- Descriptive filenames:
```ini
# alembic.ini
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
```
→ `2026-04-14_add_post_content_idx.py`
## API documentation
### Hide docs outside selected envs
```python
from fastapi import FastAPI
from src.config import settings
SHOW_DOCS_IN = {"local", "staging"}
app_kwargs = {"title": "My API"}
if settings.ENVIRONMENT not in SHOW_DOCS_IN:
app_kwargs["openapi_url"] = None # disables /docs and /redoc
app = FastAPI(**app_kwargs)
```
### Document endpoints fully
```python
from fastapi import APIRouter, status
router = APIRouter()
@router.post(
"/items",
response_model=ItemResponse,
status_code=status.HTTP_201_CREATED,
summary="Create an item",
description="Creates an item owned by the authenticated user.",
tags=["items"],
responses={
status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse, "description": "Validation error"},
status.HTTP_409_CONFLICT: {"model": ErrorResponse, "description": "Slug already exists"},
},
)
async def create_item(payload: ItemCreate) -> ItemResponse: ...
```
## Linting ## Linting
Use ruff for formatting and linting:
```shell ```shell
ruff check --fix src ruff check --fix src
ruff format src ruff format src
``` ```
## Quick Reference Add to a pre-commit hook or run in CI. Ruff replaces black + isort + autoflake + most of flake8.
| Scenario | Solution | ---
|----------|----------|
| Non-blocking I/O | `async def` route with `await` | ## Anti-patterns — common AI-agent mistakes
| Blocking I/O | `def` route (sync) |
| Sync library in async | `run_in_threadpool()` | If you're an agent reviewing a diff, check for these. Each is a real failure mode I've
| CPU-intensive | Celery/multiprocessing | seen agents introduce.
| Request validation | Dependencies with DB checks |
| Shared validation | Chain dependencies | | Anti-pattern | Why it's wrong | Fix |
| Config per domain | Separate `BaseSettings` classes | |---|---|---|
| Complex DB queries | SQL with JSON aggregation | | `requests.get(...)` inside `async def` | Blocks the event loop. `requests` is sync. | Use `httpx.AsyncClient` or `await run_in_threadpool(requests.get, ...)`. |
| `time.sleep` / `open()` / sync DB driver inside `async def` | Same — blocks the loop. | Use the async equivalent (`asyncio.sleep`, `aiofiles`, async driver). |
| `from jose import jwt` | `python-jose` is unmaintained. | `import jwt` (PyJWT). |
| `from async_asgi_testclient import TestClient` | Unmaintained. | `httpx.AsyncClient` + `ASGITransport`. |
| `model_config = ConfigDict(json_encoders={...})` | Deprecated in Pydantic v2. | `@field_serializer` or `Annotated[T, PlainSerializer(...)]`. |
| `Field(ge=18, default=None)` | Constraint contradicts the default. | Pick required or optional, not both. |
| `def get_user(id: int = Depends(...))` (default-arg form) | Legacy; gotchas with default values. | `user: Annotated[User, Depends(...)]`. |
| Catching `Exception` around a route's body | Hides bugs and turns 500s into silent 200s. | Catch the specific exception class; raise `HTTPException` with a meaningful status. |
| `BackgroundTasks` for anything you'd page on | No retry, dies with the worker. | Use Celery / Arq / RQ. |
| Calling a sync ORM session inside `async def` | Blocks the loop, may deadlock the pool. | Use `AsyncSession`. |
| Returning a Pydantic model and *also* setting `response_model=` to that same class | Model gets constructed twice (validate + serialize). | Either return a `dict`/ORM row and let `response_model` validate, or drop `response_model` and trust the return type. |
| Importing across domains via deep paths (`from src.auth.service.user import ...`) | Tight coupling, hard to refactor. | `from src.auth import service as auth_service`. |
| Reusing one `BaseSettings` for the whole app | Hard to reason about, every domain reads every var. | One `BaseSettings` per domain. |
| Mocking the database in integration tests | Mock/prod divergence eventually fires in prod. | Use a real DB (testcontainers, ephemeral schema) and `dependency_overrides` for auth/external services. |
## Quick reference
| Scenario | Solution |
|--------------------------------------|---------------------------------------------------|
| Non-blocking I/O | `async def` route with `await` |
| Blocking I/O (no async client) | `def` route (sync, runs in threadpool) |
| Sync library inside async route | `await run_in_threadpool(fn, *args)` |
| CPU-intensive work | Celery / Arq / RQ worker process |
| Request validation against DB | Dependency that loads + validates + returns |
| Reuse validation across routes | Chain dependencies |
| Inject dependency in modern style | `Annotated[T, Depends(...)]` |
| Per-request dep caching | Default behavior — same `Depends(x)` runs once |
| Per-domain config | One `BaseSettings` subclass per domain |
| Custom datetime serialization | `@field_serializer` |
| Fire-and-forget short task | `BackgroundTasks` |
| Reliable / scheduled / heavy task | Celery / Arq / RQ |
| JWT decode | `PyJWT` (`import jwt`) |
| Async DB | SQLAlchemy 2.0 async (`AsyncSession`) |
| HTTP test client | `httpx.AsyncClient` + `ASGITransport` |
| Swap dep in tests | `app.dependency_overrides[dep] = fake` |
| Lint + format | `ruff check --fix` + `ruff format` |

106
README.md
View File

@@ -5,6 +5,12 @@ After several years of building production systems,
we've made both good and bad decisions that significantly impacted our developer experience. we've made both good and bad decisions that significantly impacted our developer experience.
Here are some lessons worth sharing. Here are some lessons worth sharing.
> **Working with an AI agent?** See [AGENTS.md](./AGENTS.md) for the same rules in a
> terse, machine-readable format with a version matrix, Do/Don't blocks, and an
> anti-patterns checklist.
**Tested against:** Python 3.11+ · FastAPI 0.115+ · Pydantic 2.7+ · SQLAlchemy 2.0+ · Alembic 1.13+ · httpx 0.27+ · PyJWT 2.9+ · ruff 0.6+
*[简体中文](./README_ZH.md)* *[简体中文](./README_ZH.md)*
## Contents <!-- omit from toc --> ## Contents <!-- omit from toc -->
@@ -25,6 +31,7 @@ Here are some lessons worth sharing.
- [Follow the REST](#follow-the-rest) - [Follow the REST](#follow-the-rest)
- [FastAPI response serialization](#fastapi-response-serialization) - [FastAPI response serialization](#fastapi-response-serialization)
- [If you must use sync SDK, then run it in a thread pool.](#if-you-must-use-sync-sdk-then-run-it-in-a-thread-pool) - [If you must use sync SDK, then run it in a thread pool.](#if-you-must-use-sync-sdk-then-run-it-in-a-thread-pool)
- [BackgroundTasks vs a real task queue](#backgroundtasks-vs-a-real-task-queue)
- [ValueErrors might become Pydantic ValidationError](#valueerrors-might-become-pydantic-validationerror) - [ValueErrors might become Pydantic ValidationError](#valueerrors-might-become-pydantic-validationerror)
- [Docs](#docs) - [Docs](#docs)
- [Set DB keys naming conventions](#set-db-keys-naming-conventions) - [Set DB keys naming conventions](#set-db-keys-naming-conventions)
@@ -211,7 +218,7 @@ class UserBase(BaseModel):
first_name: str = Field(min_length=1, max_length=128) first_name: str = Field(min_length=1, max_length=128)
username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$") username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$")
email: EmailStr email: EmailStr
age: int = Field(ge=18, default=None) # must be greater or equal to 18 age: int = Field(ge=18) # required, must be greater or equal to 18
favorite_band: MusicBand | None = None # only "AEROSMITH", "QUEEN", "AC/DC" values are allowed to be inputted favorite_band: MusicBand | None = None # only "AEROSMITH", "QUEEN", "AC/DC" values are allowed to be inputted
website: AnyUrl | None = None website: AnyUrl | None = None
``` ```
@@ -219,24 +226,23 @@ class UserBase(BaseModel):
Having a controllable global base model allows us to customize all the models within the app. For instance, we can enforce a standard datetime format or introduce a common method for all subclasses of the base model. Having a controllable global base model allows us to customize all the models within the app. For instance, we can enforce a standard datetime format or introduce a common method for all subclasses of the base model.
```python ```python
from datetime import datetime from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, field_serializer
def datetime_to_gmt_str(dt: datetime) -> str:
if not dt.tzinfo:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.strftime("%Y-%m-%dT%H:%M:%S%z")
class CustomModel(BaseModel): class CustomModel(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(populate_by_name=True)
json_encoders={datetime: datetime_to_gmt_str},
populate_by_name=True, @field_serializer("*", when_used="json", check_fields=False)
) def _serialize_datetimes(self, value: Any) -> Any:
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=ZoneInfo("UTC"))
return value.strftime("%Y-%m-%dT%H:%M:%S%z")
return value
def serializable_dict(self, **kwargs): def serializable_dict(self, **kwargs):
"""Return a dict which contains only serializable fields.""" """Return a dict which contains only serializable fields."""
@@ -273,7 +279,7 @@ auth_settings = AuthConfig()
# src.config # src.config
from pydantic import PostgresDsn, RedisDsn, model_validator from pydantic import PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from src.constants import Environment from src.constants import Environment
@@ -345,7 +351,8 @@ Dependencies can use other dependencies and avoid code repetition for similar lo
```python ```python
# dependencies.py # dependencies.py
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt import jwt # PyJWT
from jwt.exceptions import InvalidTokenError
async def valid_post_id(post_id: UUID4) -> dict[str, Any]: async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
post = await service.get_by_id(post_id) post = await service.get_by_id(post_id)
@@ -360,7 +367,7 @@ async def parse_jwt_data(
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"]) payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
except JWTError: except InvalidTokenError:
raise InvalidCredentials() raise InvalidCredentials()
return {"user_id": payload["id"]} return {"user_id": payload["id"]}
@@ -397,7 +404,8 @@ but `parse_jwt_data` is called only once, in the very first call.
# dependencies.py # dependencies.py
from fastapi import BackgroundTasks from fastapi import BackgroundTasks
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt import jwt # PyJWT
from jwt.exceptions import InvalidTokenError
async def valid_post_id(post_id: UUID4) -> Mapping: async def valid_post_id(post_id: UUID4) -> Mapping:
post = await service.get_by_id(post_id) post = await service.get_by_id(post_id)
@@ -412,7 +420,7 @@ async def parse_jwt_data(
) -> dict: ) -> dict:
try: try:
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"]) payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
except JWTError: except InvalidTokenError:
raise InvalidCredentials() raise InvalidCredentials()
return {"user_id": payload["id"]} return {"user_id": payload["id"]}
@@ -558,6 +566,28 @@ async def call_my_sync_library():
await run_in_threadpool(client.make_request, data=my_data) await run_in_threadpool(client.make_request, data=my_data)
``` ```
### BackgroundTasks vs a real task queue
FastAPI's `BackgroundTasks` is convenient — and a footgun if you misuse it. Tasks run **after the response is sent, in the same worker process**. If the worker dies, the task is gone. There is no retry, no visibility, no scheduling.
| Use `BackgroundTasks` when… | Use Celery / Arq / RQ when… |
|--------------------------------------------------|-------------------------------------------------|
| Task is short (< 1 second) | Task takes seconds to minutes |
| Failure can be silently dropped | You need retries or dead-letter handling |
| It's in-process (send email, log a row) | It's CPU-heavy or needs a separate worker pool |
| You don't need scheduling or rate limiting | You need cron, ETA, or rate limiting |
```python
from fastapi import BackgroundTasks
@router.post("/signup")
async def signup(data: SignupIn, bg: BackgroundTasks):
user = await service.create_user(data)
bg.add_task(send_welcome_email, user.email) # fire-and-forget, in-process
return user
```
Rule of thumb: if you'd page someone when the task is lost, it doesn't belong in `BackgroundTasks`.
### ValueErrors might become Pydantic ValidationError ### ValueErrors might become Pydantic ValidationError
If you raise a `ValueError` in a Pydantic schema that's used directly in a request body, FastAPI will return a detailed validation error response to users. If you raise a `ValueError` in a Pydantic schema that's used directly in a request body, FastAPI will return a detailed validation error response to users.
```python ```python
@@ -686,6 +716,8 @@ Being consistent with names is important. Some rules we followed:
- Usually, database handles data processing much faster and cleaner than CPython will ever do. - Usually, database handles data processing much faster and cleaner than CPython will ever do.
- It's preferable to do all the complex joins and simple data manipulations with SQL. - It's preferable to do all the complex joins and simple data manipulations with SQL.
- It's preferable to aggregate JSONs in DB for responses with nested objects. - It's preferable to aggregate JSONs in DB for responses with nested objects.
For new projects, reach for SQLAlchemy 2.0's async API (`AsyncSession`, `async_sessionmaker`). The example below uses `encode/databases` for brevity — the SQL-first principle is what matters; the client is interchangeable.
```python ```python
# src.posts.service # src.posts.service
from typing import Any from typing import Any
@@ -769,28 +801,50 @@ async def get_creator_posts(creator: dict[str, Any] = Depends(valid_creator_id))
``` ```
### Set tests client async from day 0 ### Set tests client async from day 0
Writing integration tests with DB will likely lead to messed up event loop errors in the future. Writing integration tests with DB will likely lead to messed up event loop errors in the future.
Set the async test client immediately, e.g. [httpx](https://github.com/encode/starlette/issues/652) Set the async test client immediately, using [httpx](https://www.python-httpx.org/) with `ASGITransport`. Don't reach for `async_asgi_testclient` — it's unmaintained.
```python ```python
from typing import AsyncGenerator
import pytest import pytest
from async_asgi_testclient import TestClient from httpx import AsyncClient, ASGITransport
from src.main import app # inited FastAPI app from src.main import app # inited FastAPI app
@pytest.fixture @pytest.fixture
async def client() -> AsyncGenerator[TestClient, None]: async def client() -> AsyncGenerator[AsyncClient, None]:
host, port = "127.0.0.1", "9000" transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
async with AsyncClient(transport=ASGITransport(app=app, client=(host, port)), base_url="http://test") as client: yield ac
yield client
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_post(client: TestClient): async def test_create_post(client: AsyncClient):
resp = await client.post("/posts") resp = await client.post("/posts")
assert resp.status_code == 201 assert resp.status_code == 201
``` ```
#### Override dependencies in tests
Don't monkeypatch internals. FastAPI's `dependency_overrides` lets you swap any dependency for a test fake — auth, external clients, anything you don't want hitting the network.
```python
from src.auth.dependencies import parse_jwt_data
from src.main import app
def fake_user():
return {"user_id": "00000000-0000-0000-0000-000000000001"}
@pytest.fixture(autouse=True)
def _override_auth():
app.dependency_overrides[parse_jwt_data] = fake_user
yield
app.dependency_overrides.clear()
```
Unless you have synchronous database connections (excuse me?) or don't plan to write integration tests. Unless you have synchronous database connections (excuse me?) or don't plan to write integration tests.
### Use ruff ### Use ruff

View File

@@ -222,7 +222,7 @@ class UserBase(BaseModel):
first_name: str = Field(min_length=1, max_length=128) first_name: str = Field(min_length=1, max_length=128)
username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$") username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$")
email: EmailStr email: EmailStr
age: int = Field(ge=18, default=None) # 必须大于或等于18 age: int = Field(ge=18) # 必须大于或等于18
favorite_band: MusicBand | None = None # 只允许输入"AEROSMITH"、"QUEEN"、"AC/DC"值 favorite_band: MusicBand | None = None # 只允许输入"AEROSMITH"、"QUEEN"、"AC/DC"值
website: AnyUrl | None = None website: AnyUrl | None = None
``` ```
@@ -233,22 +233,22 @@ class UserBase(BaseModel):
```python ```python
from datetime import datetime from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, field_serializer
def datetime_to_gmt_str(dt: datetime) -> str:
if not dt.tzinfo:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.strftime("%Y-%m-%dT%H:%M:%S%z")
class CustomModel(BaseModel): class CustomModel(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(populate_by_name=True)
json_encoders={datetime: datetime_to_gmt_str},
populate_by_name=True, @field_serializer("*", when_used="json", check_fields=False)
) def _serialize_datetimes(self, value: Any) -> Any:
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=ZoneInfo("UTC"))
return value.strftime("%Y-%m-%dT%H:%M:%S%z")
return value
def serializable_dict(self, **kwargs): def serializable_dict(self, **kwargs):
"""返回仅包含可序列化字段的字典。""" """返回仅包含可序列化字段的字典。"""
@@ -285,7 +285,7 @@ class AuthConfig(BaseSettings):
auth_settings = AuthConfig() auth_settings = AuthConfig()
# src.config # src.config
from pydantic import PostgresDsn, RedisDsn, model_validator from pydantic import PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from src.constants import Environment from src.constants import Environment
@@ -356,7 +356,8 @@ async def get_post_reviews(post: dict[str, Any] = Depends(valid_post_id)):
```python ```python
# dependencies.py # dependencies.py
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt import jwt # PyJWT
from jwt.exceptions import InvalidTokenError
async def valid_post_id(post_id: UUID4) -> dict[str, Any]: async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
post = await service.get_by_id(post_id) post = await service.get_by_id(post_id)
@@ -370,7 +371,7 @@ async def parse_jwt_data(
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"]) payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
except JWTError: except InvalidTokenError:
raise InvalidCredentials() raise InvalidCredentials()
return {"user_id": payload["id"]} return {"user_id": payload["id"]}
@@ -408,7 +409,8 @@ async def get_user_post(post: dict[str, Any] = Depends(valid_owned_post)):
# dependencies.py # dependencies.py
from fastapi import BackgroundTasks from fastapi import BackgroundTasks
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt import jwt # PyJWT
from jwt.exceptions import InvalidTokenError
async def valid_post_id(post_id: UUID4) -> Mapping: async def valid_post_id(post_id: UUID4) -> Mapping:
post = await service.get_by_id(post_id) post = await service.get_by_id(post_id)
@@ -422,7 +424,7 @@ async def parse_jwt_data(
) -> dict: ) -> dict:
try: try:
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"]) payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
except JWTError: except InvalidTokenError:
raise InvalidCredentials() raise InvalidCredentials()
return {"user_id": payload["id"]} return {"user_id": payload["id"]}
@@ -804,20 +806,21 @@ async def get_creator_posts(creator: dict[str, Any] = Depends(valid_creator_id))
使用数据库编写集成测试很可能在将来导致混乱的事件循环错误。立即设置异步测试客户端,例如[httpx](https://github.com/encode/starlette/issues/652) 使用数据库编写集成测试很可能在将来导致混乱的事件循环错误。立即设置异步测试客户端,例如[httpx](https://github.com/encode/starlette/issues/652)
```python ```python
from typing import AsyncGenerator
import pytest import pytest
from async_asgi_testclient import TestClient from httpx import AsyncClient, ASGITransport
from src.main import app # inited FastAPI app from src.main import app # inited FastAPI app
@pytest.fixture @pytest.fixture
async def client() -> AsyncGenerator[TestClient, None]: async def client() -> AsyncGenerator[AsyncClient, None]:
host, port = "127.0.0.1", "9000" transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
async with AsyncClient(transport=ASGITransport(app=app, client=(host, port)), base_url="http://test") as client: yield ac
yield client
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_post(client: TestClient): async def test_create_post(client: AsyncClient):
resp = await client.post("/posts") resp = await client.post("/posts")
assert resp.status_code == 201 assert resp.status_code == 201