fastapi-production
Skill3 starsChanged 48 days agoMITView on GitHub
Production-grade conventions for FastAPI + SQLAlchemy 2.0 async + Pydantic v2 projects — layering (router → service → model), transactions, errors, schemas, pagination, multi-tenancy, auth, security, logging, background tasks (ArdiQ), testing, migrations, and deployment. Use when creating, reviewing, or refactoring any FastAPI endpoint, service, model, schema, dependency, migration, test, or project setup.
- Reads credentials
2 other projects use a version of this.
Adopted by
Most recently taken up by
- salmandev/everything-geminitheir own version, first seen Oct 17, 2026
- MaheshAwasare/claude-skills-protheir own version, first seen May 4, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- salmandev/everything-gemini★ 2same name
- MaheshAwasare/claude-skills-pro★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
The file
Show its rules and text
We split this skill into the rules it gives an agent
27 rules. About 0 are common, 5 are distinctive, and 22 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
22
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 16
Every rule below is self-contained.
- Very specific: line 36
Use the Astral stack: uv (packages), ruff (lint + format), ty (types).
- Distinctive: line 82
Never put these in a router: DB access, business conditionals, private helpers, manual dict mappings, or several service calls orchestrated …
- Very specific: line 98
Use flush() when you need a generated ID before the commit.
- Very specific: line 115
Put prefix, tags, and shared dependencies on the APIRouter, not in include_router().
- Very specific: line 137
Never use HTTPException in services.
- Very specific: line 143
Every endpoint declares its return type.
- Very specific: line 175
Every string has max_length (Annotated[str, Field(min_length=1, max_length=200)]), and numbers have bounds.
- Very specific: line 180
Don't use RootModel. Use Annotated[list[X], Field(min_length=1)] instead.
- Very specific: line 184
Every list endpoint is paginated, with a hard le= limit on page size, and always uses ORDER BY on a unique combination.
- Very specific: line 198
Every query on tenant-owned data includes organization_id: reads, updates, deletes, counts, joins.
- Distinctive: line 213
Every endpoint that takes an ID has a cross-tenant test (Rule 25).
- Very specific: line 230
Every tenant table has an indexed organization_id FK.
- Very specific: line 262
Use with_for_update() for read-modify-write that can't be one statement.
- Distinctive: line 266
Always read autogenerated migrations: autogenerate turns renames into drop + add (data loss).
- Very specific: line 267
Never edit a migration that has already run in a shared environment.
- Very specific: line 279
Use lifespan (never @app.on_event) to create shared resources once (httpx.AsyncClient, Redis), store them on app.state, expose them via …
- Distinctive: line 310
Every non-public route has an auth dependency.
- Very specific: line 338
Read at most MAX + 1 bytes and return 413 if it's too large.
- Very specific: line 346
Use ArdiQ, never Celery: Celery workers are sync, and every async task needs its own asyncio.run().
- Very specific: line 377
Never fire-and-forget with asyncio.create_task in a request.
- Distinctive: line 382
Incoming webhooks: verify the signature over the raw body first, dedupe by event ID (unique constraint), enqueue, and return 200 in under 5 …
- Very specific: line 387
Use FastAPI's native streaming (≥ 0.135): the endpoint yields and FastAPI encodes.
- Very specific: line 407
Do the reads first, then commit() to release the pool connection during long streams.
- Very specific: line 415
Use httpx.AsyncClient(transport=ASGITransport(app=app)) + app.dependency_overrides.
- Very specific: line 425
Start the API with fastapi run --forwarded-allow-ips=<proxy>.
- Very specific: line 457
slowapi decorators don't work on
yieldendpoints: Use a dependency (Rule 18).
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
FastAPI Production Rules
Production conventions for FastAPI projects. Follow them strictly when generating or reviewing code. Verified against: FastAPI 0.141 · Pydantic 2.13 · SQLAlchemy 2.0/2.1 · Alembic 1.20 · ArdiQ 1.0 · Python 3.13 (September 2026).
Scope. This skill covers the production architecture around FastAPI: layering, data integrity, tenancy, security, operations. For FastAPI API idioms (parameter declaration, response models, streaming details, app.frontend()), the official fastapi skill that ships with the FastAPI package is authoritative. The two are complementary. When both are installed, follow the official one on API idioms and this one on everything else.
Deliberate choices that differ from the official skill:
- SQLAlchemy 2.0 instead of SQLModel. We keep DB models and API schemas separate (Rule 6), and we rely on SQLAlchemy features SQLModel doesn't expose cleanly:
lazy="raise",version_id_col,with_for_update, naming conventions, andtype_annotation_map. Don't "migrate" code in this style to SQLModel. asyncio.to_threadinstead of Asyncer. The stack is async end to end, so few blocking calls remain. The stdlib is enough.
Every rule below is self-contained. references/ holds complete implementations. Read the relevant file before writing that part of a project:
| Reference | Read when |
| toolchain.md | pyproject.toml, ruff, ty, CI |
| app-setup.md | Settings, app factory, lifespan, middleware, CORS, OpenAPI |
| errors-and-responses.md | Exceptions, handlers, response wrappers, schemas, pagination |
| database.md | Engine/session, models, relationships, transactions, locking, Alembic |
| auth-and-security.md | Passwords, JWT, refresh tokens, magic links, roles, rate limits, uploads |
| logging-and-observability.md | structlog, request context, Sentry, OpenTelemetry, health checks |
| background-tasks.md | ArdiQ app, tasks, enqueueing, idempotency, retries, cron, outbox |
| api-patterns.md | Idempotency keys, webhooks, SSE/JSON Lines streaming, outbound HTTP, SSRF, caching, audit log |
| testing.md | Fixtures, real Postgres, tenant-isolation, architecture and task tests |
| deployment.md | Dockerfile, fastapi run, migrations in the pipeline, probes, local dev |
| crud-example.md | A complete domain: model → schemas → service → router → tests |
Toolchain
Use the Astral stack: uv (packages), ruff (lint + format), ty (types). Never use pip, poetry, black, flake8, isort, or mypy. Always commit uv.lock. Target Python 3.13+.
uv add package-name # uv add --dev for dev deps
uv run ruff check . --fix && uv run ruff format . && uv run ty check && uv run pytest
fastapi dev # local server with reloadDeclare the entrypoint once in pyproject.toml ([tool.fastapi] entrypoint = "app.main:app") and use fastapi dev / fastapi run. Ruff rule sets: E W F I N UP B S T20 FAST ASYNC DTZ SIM C4 PT PTH PERF RUF TID. FAST enforces Annotated dependencies, DTZ catches naive datetimes, and TID bans requests, passlib, and jose. → toolchain.md
Project Structure
app/
├── main.py # create_app(): factory, middleware, handlers, routers
├── api.py # api_router = APIRouter(prefix="/api/v1") + domain routers
├── worker.py # imports every tasks module → `ardiq run app.worker:task_app`
├── core/ # config, constants, dependencies, lifespan, limiter, tasks (ArdiQ app),
│ # db/ (base, database, models), http/ (exceptions, handlers, middleware,
│ # pagination, responses, schemas), logging/, security/, storage/, mail/
├── health/ # /health/live, /health/ready
└── <domain>/ # tickets/, auth/, billing/ ...
├── router.py # HTTP only
├── service.py # business logic — no FastAPI imports
├── model.py # SQLAlchemy models
├── schemas.py # Pydantic I/O schemas
└── tasks.py # ArdiQ tasks (optional)
migrations/ # Alembic
tests/ # mirrors app/Rule 1 — Router → Service → Model
The router translates HTTP: it declares input, calls one service function, and wraps the result. The service holds all business logic and never imports fastapi, starlette, HTTPException, Request, or Depends, because routers, ArdiQ tasks, scripts, and tests all call it.
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_ticket(
data: TicketCreate, user: CurrentUser, db: DbSession
) -> DataResponse[TicketRead]:
ticket = await ticket_service.create_ticket(db, data, user)
return DataResponse(data=TicketRead.model_validate(ticket))Never put these in a router: DB access, business conditionals, private helpers, manual dict mappings, or several service calls orchestrated by hand. An architecture test enforces this (Rule 25).
Rule 2 — One Transaction per Use Case
The service function the router calls owns the transaction and calls commit() once, at the end. Helpers called by other services only add()/flush().
async def close_ticket(db: AsyncSession, ticket_id: UUID, user: User) -> Ticket:
ticket = await get_ticket(db, ticket_id, user.organization_id)
ticket.status = TicketStatus.CLOSED
await audit_service.record(db, user, "ticket.closed", ticket.id) # add() only
await db.commit()
await db.refresh(ticket)
return ticketUse flush() when you need a generated ID before the commit. Enqueue tasks and send emails after commit(). Never make external HTTP calls while holding row locks. → database.md
Rule 3 — Dependencies with Annotated
Define each dependency once as a type alias. Never write x: T = Depends(...) (ruff FAST002).
DbSession = Annotated[AsyncSession, Depends(get_db)]
CurrentUser = Annotated[User, Depends(get_current_user)]
CurrentAdmin = Annotated[User, Depends(require_role(Role.ADMIN, Role.OWNER))]
HttpClient = Annotated[httpx.AsyncClient, Depends(get_http_client)]
Pagination = Annotated[PageParams, Query()]- Params use
Annotatedtoo:limit: Annotated[int, Query(ge=1, le=100)] = 20. Never use...as a default. - Declare dependencies as
async defunless they block: sync dependencies run in the threadpool. - Guards that don't need a return value go on the router:
APIRouter(prefix="/admin", dependencies=[Depends(require_role(Role.ADMIN))]). - Put
prefix,tags, and shareddependencieson theAPIRouter, not ininclude_router().
Rule 4 — Domain Exceptions and One Error Format
Services raise AppError subclasses. Global handlers turn every error into one shape: domain errors, validation errors, unknown routes, rate limits, ardiq.BrokerError (→ 503), and unhandled crashes (→ generic 500, logged with traceback).
{ "error": { "code": "not_found", "message": "Ticket not found", "request_id": "3f2a9c..." } }class AppError(Exception):
status_code: int = 400
code: str = "bad_request"
default_message: str = "Bad request" # __init__(message=None, *, details=None)
# NotFoundError(resource) 404, InvalidInputError 422, UnauthorizedError 401, ForbiddenError 403,
# ConflictError 409, PayloadTooLargeError 413, UnsupportedMediaTypeError 415, RateLimitError 429,
# ServiceUnavailableError 503- Never use
HTTPExceptionin services. Chain causes:raise ConflictError("Email already registered") from e. - Messages never contain SQL, stack traces, paths, or internal IDs.
codenever changes once shipped. - Validation error details never echo the submitted
input(it may contain passwords). → errors-and-responses.md
Rule 5 — Typed Responses and Correct Status Codes
Every endpoint declares its return type. Pydantic validates, filters, documents, and serializes from it. Use response_model= only when the returned object differs from the public schema. Never use ORJSONResponse/UJSONResponse (deprecated). Never return ORM objects or raw dicts.
class DataResponse[T](BaseModel):
data: T
# also: PaginatedResponse[T] (data, total, skip, limit), CursorPage[T] (data, next_cursor),
# MessageResponse (message)| Operation | Status | Returns |
| GET one / PATCH / PUT | 200 | DataResponse[XRead] |
| GET list | 200 | PaginatedResponse[XRead] / CursorPage[XRead] |
| POST create | 201 | DataResponse[XRead] |
| DELETE | 204 | None |
| Queued for async processing | 202 | DataResponse[JobRead] / MessageResponse |
Rule 6 — Pydantic Schemas
Each resource gets three schemas: XCreate, XUpdate, XRead. Inputs forbid unknown fields; outputs read ORM attributes.
class InputSchema(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class OutputSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)- Every string has
max_length(Annotated[str, Field(min_length=1, max_length=200)]), and numbers have bounds. UseEmailStr,HttpUrl,UUID, andStrEnumrather than barestr. - Input schemas never contain server-owned fields (
id,organization_id,created_by,role,is_active). This is what prevents mass assignment. - Read schemas never contain
password_hash,token_hash, or secrets. - PATCH uses
model_dump(exclude_unset=True). NOT NULL fields reject an explicitnullwith a validator. - Money is
Decimalor integer cents, neverfloat. Datetimes are timezone-aware. - Don't use
RootModel. UseAnnotated[list[X], Field(min_length=1)]instead. → errors-and-responses.md
Rule 7 — Pagination with a Stable Order
Every list endpoint is paginated, with a hard le= limit on page size, and always uses ORDER BY on a unique combination. Without it, Postgres returns rows in arbitrary order and pages overlap or skip rows.
query = select(Ticket).where(Ticket.organization_id == org_id)
total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0
rows = await db.scalars(
query.order_by(Ticket.created_at.desc(), Ticket.id.desc()).offset(page.skip).limit(page.limit)
)Large or fast-growing tables use keyset/cursor pagination. Sorting and filtering use allowlisted enums, never raw field names. → errors-and-responses.md
Rule 8 — Multi-tenancy: Every Query Scoped by Organization
Every query on tenant-owned data includes organization_id: reads, updates, deletes, counts, joins. A missing scope is an IDOR vulnerability.
# CORRECT
ticket = await db.scalar(
select(Ticket).where(Ticket.id == ticket_id, Ticket.organization_id == org_id)
)
# WRONG — any user can read any ticket
ticket = await db.get(Ticket, ticket_id)organization_idalways comes from the authenticated user, never from the body, path, or query.- Another org's resource returns 404, never 403 (403 confirms it exists).
- Child resources verify that the parent belongs to the org. Cache keys, storage paths, and unique constraints include
organization_idtoo. - A resource used by many endpoints can be loaded once through a dependency that calls the scoped service getter:
ValidTicket = Annotated[Ticket, Depends(valid_ticket)]. FastAPI caches it per request. - Every endpoint that takes an ID has a cross-tenant test (Rule 25).
Rule 9 — SQLAlchemy Models
Base sets a constraint naming_convention (so Alembic can alter constraints later) and maps every Mapped[datetime] to TIMESTAMPTZ via type_annotation_map.
class Ticket(TimestampMixin, Base): # created_at / updated_at set by the DB
__tablename__ = "tickets"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
organization_id: Mapped[UUID] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), index=True
)
title: Mapped[str] = mapped_column(String(200))
status: Mapped[TicketStatus] = mapped_column(str_enum(TicketStatus), default=TicketStatus.OPEN)- Every tenant table has an indexed
organization_idFK. Every FK declaresondelete. Mapped[X | None]is nullable andMapped[X]is NOT NULL. String lengths match the schema'smax_length.- Enums are
StrEnumstored as VARCHAR values. Tenant-scoped uniqueness isUniqueConstraint("organization_id", "email"). - Datetimes are always aware:
datetime.now(UTC), neverutcnow()(ruffDTZ). → database.md
Rule 10 — Async Session and Explicit Loading
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, autoflush=False)expire_on_commit=Falseis mandatory. Otherwise reading an attribute after a commit triggers a lazy load, which raisesMissingGreenlet.- Relationships are declared
lazy="raise"and loaded explicitly:selectinloadfor collections,joinedloadfor many-to-one. This makes N+1 queries impossible to ship silently. - Engine uses
pool_pre_ping=Trueand astatement_timeout. Raw SQL always usestext()+ bind params, never f-strings.
Rule 11 — Let the Database Guarantee Integrity
db.add(user)
try:
await db.commit()
except IntegrityError as e: # unique constraint, not check-then-insert (race condition)
await db.rollback()
raise ConflictError("Email already registered") from e
await db.execute( # atomic guarded update, not read-modify-write
update(Organization)
.where(Organization.id == org_id, Organization.credits >= cost)
.values(credits=Organization.credits - cost)
)Use with_for_update() for read-modify-write that can't be one statement. Use version_id_col for optimistic locking (→ 409) on resources that users edit concurrently. → database.md
Rule 12 — Migrations with Alembic
- Always read autogenerated migrations: autogenerate turns renames into drop + add (data loss).
- Never edit a migration that has already run in a shared environment. Use
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)sfor readable file names. - Migrations run as a separate deploy step, never on app startup.
- Expand → migrate → contract: each migration must be compatible with the code already running. New columns are nullable or have a server default. Large-table indexes use
CONCURRENTLY. Setlock_timeout. - CI runs
alembic upgrade head+alembic checkon an empty DB. → database.md
Rule 13 — Configuration and Constants
Secrets and per-environment values live in Settings(BaseSettings). Secrets are SecretStr with no default, so the app refuses to start without them. Fields are typed (PostgresDsn, RedisDsn, Literal). Access settings through @lru_cache get_settings(). Values identical in every environment go in constants.py. Commit .env.example, never .env. → app-setup.md
Rule 14 — App Factory, Lifespan, Routers
create_app()builds the app (logging, Sentry, handlers, middleware, routers). Tests call it too.- Use
lifespan(never@app.on_event) to create shared resources once (httpx.AsyncClient, Redis), store them onapp.state, expose them via dependencies, and close them on shutdown along withengine.dispose(). - One
APIRouterper domain, all under/api/v1. Breaking changes go to/api/v2. One HTTP method per function. - Middleware is pure ASGI, never
BaseHTTPMiddleware(it breaks contextvars and streaming). The lastadd_middlewarecall is the outermost. - In production set
openapi_url=Noneunless the API is public. Usegenerate_unique_id_functionfor clean client names. → app-setup.md
Rule 15 — Structured Logging with structlog
logger.info("ticket.created", ticket_id=str(ticket.id)) # domain.action + key-values
logger.exception("billing.charge_failed", org_id=str(org_id)) # inside except: keeps traceback
# WRONG: logger.info(f"Ticket {id} created") · logger.error(..., error=str(e)) · print()- JSON in production and console locally. stdlib, uvicorn, and ArdiQ logs go through the same pipeline.
- Pure-ASGI middleware binds
request_id(validatedX-Request-ID, echoed back) and logs onehttp.requestline.get_current_userbindsuser_id/org_id. - Never log passwords, tokens,
Authorization/cookies, emails/PII, request bodies, file contents, LLM prompts or responses, or URLs with query strings. - Log 4xx as
warningat most and 5xx aserror/exception. → logging-and-observability.md
Rule 16 — Authentication
- Passwords: argon2 via
pwdlib, run inasyncio.to_thread. On login, return the same error with the same timing for an unknown email and a wrong password (verify against a dummy hash). - Access tokens: JWT with PyJWT. Expire after 15 minutes, pin
algorithms=["HS256"]on decode, and requireexp/iat/sub. Send them only inAuthorization: Bearer, never in query strings. - The token identifies, the DB authorizes: load the user on every request, check
is_active, and take roles from the DB. - Refresh tokens: opaque
secrets.token_urlsafe(32), stored as a SHA-256 hash and rotated on every use. They belong to afamily_id, and reuse of a revoked token revokes the whole family. On the web, use anhttpOnly+secure+samesite="strict"cookie scoped to/api/v1/auth. - One-time tokens (magic links, resets, invites): hashed, with a TTL, consumed by one atomic
UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING. "Request link" endpoints respond the same whether or not the email exists. → auth-and-security.md
Rule 17 — Authorization
- Role requirements are visible in the signature (
admin: CurrentAdmin) or on the router (dependencies=[...]), never asif user.role == ...inside services. - Resource-level rules ("only the author can edit") live in the service, or in a
valid_owned_xdependency, after loading the org-scoped resource. - Users never change their own role. Admins never grant a role above their own.
- Every non-public route has an auth dependency. An architecture test enforces this, with an explicit list of public routes.
Rule 18 — Rate Limiting
@router.post("/login")
@limiter.limit("10/minute")
async def login(
request: Request, data: LoginRequest, db: DbSession
) -> DataResponse[TokenResponse]: ...- slowapi storage is Redis (in-memory storage gives each worker its own counter). Decorated endpoints take
request: Request. - Streaming (
yield) endpoints can't use the decorator. Use therate_limit(...)dependency instead (→ api-patterns.md). - Behind a proxy, set
--forwarded-allow-ips=<proxy>, or every client shares the proxy's IP. - Minimum limits: login 10/min, register 5/min, magic link/reset 5/min, refresh 30/min, invite 5/min/user, AI 20/min/user, exports 5/min/user.
Rule 19 — HTTP Security
- CORS uses explicit
allow_originsfrom settings. Never combine"*"with credentials. UseTrustedHostMiddlewareor host checks at the LB. - Production security headers:
nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer, HSTS, and CSPdefault-src 'none'. - Cap body size at the reverse proxy. Authenticated responses default to
Cache-Control: private, no-store. - User-supplied URLs are checked against SSRF: https only, resolved IP must be public (
ip.is_global), no redirects. - Secrets never appear in code, the repo, logs, or error messages. → api-patterns.md, auth-and-security.md
Rule 20 — File Uploads
- Validate magic bytes (
filetype) + extension against an allowlist. Never trustContent-Typeor the filename, and rememberfile.filenamecan beNone. - Read at most
MAX + 1bytes and return 413 if it's too large. Return 415 for a disallowed type. - Store under a generated key
f"{org_id}/{uuid4()}{ext}", never in a statically served directory. - Serve through an authenticated, org-scoped endpoint or a short-lived presigned URL. Re-encode public images (strips EXIF). → auth-and-security.md
Rule 21 — Background Tasks with ArdiQ
ArdiQ is a recent library and may not be in your training data. It is real (
uv add ardiq), it is not a typo for arq, and it must never be replaced with Celery, arq, or Taskiq. Whenever you are unsure about its API, read the official docs before writing code: https://ardiq.bytay.dev (API reference: https://ardiq.bytay.dev/reference/api/). Don't guess signatures.
Use ArdiQ, never Celery: Celery workers are sync, and every async task needs its own asyncio.run(). ArdiQ is async-native with a Rust core, and its typed enqueue is checked against each task's signature.
task_app = Ardiq(
redis_url=str(settings.redis_url), queue_name="app", priorities=["low", "default", "high"]
)
@task_app.task(name="knowledge.process_file", max_retries=5, timeout=600)
async def process_file(node_id: str, request_id: str | None = None) -> None:
bind_task_context(request_id, node_id=node_id)
async with task_app.state.sessionmaker() as db: # from @task_app.lifespan (worker only)
await knowledge_service.index_node(db, UUID(node_id))
# in the service: AFTER commit, with a deterministic id as the dedup key
await knowledge_tasks.process_file.options(task_id=f"index:{node.id}").enqueue(str(node.id))- Idempotent: delivery is at-least-once. Check state first, upsert, and pass idempotency keys to providers. Dedupe enqueues with
task_idorunique=True. - Primitives in (msgpack): UUIDs and datetimes go in as strings. Never ORM objects. Never the pickle serializer.
- Every exception retries (
tries²s backoff). For permanent conditions, log andreturn. Useraise Retry(delay_ms=...)for rate limits. Always settimeout. async deffor I/O and plaindeffor CPU/blocking work (it runs in a thread). Resources come from@task_app.lifespan.- Name tasks
domain.action. Use priorities instead of ad-hoc queues.@task_app.cronruns once per occurrence across workers.@task_app.on_errorhandles central logging and Sentry. - Events that must never be lost go through a transactional outbox. Use
BackgroundTasksonly for trivial, losable work. → background-tasks.md
Rule 22 — Async Rules
- In
async def, every I/O call is awaited. Never userequests,time.sleep, sync file I/O, or CPU-heavy work (hashing, parsing) inline. Useasyncio.to_thread, or write the endpoint as a plaindefif a library is sync-only. - One shared
httpx.AsyncClientfrom the lifespan. Every outbound call has a timeout. Retry only idempotent requests on transient errors (tenacity, backoff + jitter). asyncio.gather/TaskGroupfor independent awaits, but never share oneAsyncSessionacross concurrent tasks.- Never fire-and-forget with
asyncio.create_taskin a request. Use an ArdiQ task.
Rule 23 — Idempotency and Webhooks
- POSTs that move money or create irreversible things accept an
Idempotency-Key. Store key + request hash + response in the same transaction. The same key with the same body returns the stored response; with a different body it returns 409. - Incoming webhooks: verify the signature over the raw body first, dedupe by event ID (unique constraint), enqueue, and return 200 in under 5 s. Re-fetch state from the provider inside the task.
- Outgoing webhooks: sent from tasks, HMAC-signed with a timestamp, with a unique event ID, retried with backoff. → api-patterns.md
Rule 24 — Streaming: Native SSE and JSON Lines
Use FastAPI's native streaming (≥ 0.135): the endpoint yields and FastAPI encodes. Never build data: ...\n\n by hand or return a StreamingResponse for SSE.
from fastapi.sse import EventSourceResponse, ServerSentEvent
@router.post(
"",
response_class=EventSourceResponse,
dependencies=[Depends(rate_limit("chat", limit=20, window_s=60))],
)
async def chat(
data: ChatRequest, user: CurrentUser, db: DbSession, llm: LlmClient
) -> AsyncIterable[ServerSentEvent]:
# the service yields domain objects; the router wraps them in SSE events
async for chunk in chat_service.stream_answer(db, llm, data, user):
yield ServerSentEvent(data=chunk, event=chunk.type)- FastAPI sets
Cache-Control: no-cacheandX-Accel-Buffering: no, sends keep-alive pings, and keeps dependencies open until the stream ends. - Do the reads first, then
commit()to release the pool connection during long streams. - Errors after the first byte are sent as events (the status is already 200).
- Client disconnects cancel the generator. Cleanup writes need
anyio.CancelScope(shield=True). - JSON Lines: same
yieldpattern with return typeAsyncIterable[Model]and noresponse_class. → api-patterns.md
Rule 25 — Testing
- Real Postgres (testcontainers), never SQLite. Each test runs in a rolled-back transaction (
join_transaction_mode="create_savepoint"). Redis also comes from testcontainers. - Use
httpx.AsyncClient(transport=ASGITransport(app=app))+app.dependency_overrides. Never call real third-party APIs. - For every endpoint: happy path, 401, 403 for the wrong role, 404 for another org's resource, 422 for invalid input and unknown fields, 409 for conflicts, and side effects.
- Architecture tests: services don't import FastAPI, and every
/api/route has auth unless it's listed as public. - Tasks: assert the enqueue via
task_app.status(task_id), test the body by calling the task inline, and run each task twice to prove idempotency. → testing.md
Rule 26 — Health, Observability, Deployment
- Two health endpoints outside
/api/v1:/health/live(no dependencies) and/health/ready(DB + Redis, 503 on failure). - Sentry with
environment,release(git SHA),send_default_pii=False, and a sample rate of 0.1 or less. OpenTelemetry once you have a tracing backend. - Multi-stage Docker with uv, running as non-root. The same image serves API, worker, and migrations.
- Start the API with
fastapi run --forwarded-allow-ips=<proxy>. Run workers withardiq run app.worker:task_app. Migrations run as a one-off job before rollout. → deployment.md
Rule 27 — Naming Conventions
| Element | Convention | Example |
| Domain folders / tables | snake_case plural | tickets/, refresh_tokens |
| Domain files | fixed names | router.py, service.py, model.py, schemas.py, tasks.py |
| Service import in routers | <domain>_service alias | from app.tickets import service as ticket_service |
| Schemas | <Model><Create|Update|Read> | TicketRead |
| Functions | snake_case, verb first | create_ticket, list_tickets |
| Enums | StrEnum, lowercase values | TicketStatus.OPEN = "open" |
| URL paths | plural nouns, kebab-case | /api/v1/tickets/{ticket_id}, /api/v1/magic-links |
| Non-CRUD actions | sub-resource verb | POST /api/v1/invites/{invite_id}/resend |
| Log events / error codes / task names | domain.action / snake_case / domain.action | ticket.created / not_found / knowledge.process_file |
| Datetime columns | _at suffix | created_at, used_at |
Rule 28 — No Over-Engineering
Don't use: a repository pattern on top of the ORM, abstract service base classes, generic CRUD base classes, event buses, DI containers, microservices before you need them, or a cache before you've measured. Use: plain async functions, direct SQLAlchemy calls, and Annotated dependencies. Duplicate twice; extract on the third time.
FastAPI Gotchas
- Route order matters: declare
/tickets/exportbefore/tickets/{ticket_id}, or"export"is parsed as an ID (→ 422). - Types in endpoint/dependency signatures must exist at runtime. Never import them under
if TYPE_CHECKING:, because FastAPI resolves annotations when it builds the route. - List query params need
Query():tags: Annotated[list[str], Query()]. Without it, the list is read from the body. - Dependencies are cached per request. The same dependency used in several places runs once. Use
Depends(fn, use_cache=False)when you need a fresh value. - Returning a
Responsewith its ownbackground=silently replaces the tasks added through theBackgroundTasksparameter. - 204 endpoints return
None. A body on a 204 is a protocol error. - Trailing slashes:
/tickets/and/ticketsare different routes, and FastAPI answers the mismatch with a 307 redirect. Declare routes without a trailing slash (""inside a prefixed router) and have clients call exactly that. - slowapi decorators don't work on
yieldendpoints. Use a dependency (Rule 18).
Security Checklist — Before Every Endpoint
- Auth dependency present (or route consciously listed as public); role in the signature/router if privileged
- Every query scoped by
organization_id; other org's resources → 404 - Input schema forbids unknown fields and has no server-owned fields; strings have
max_length - Response schema excludes sensitive fields; list endpoints have page limits
- Rate limit if public, auth-related, expensive, or sends email
- Errors are
AppErrorsubclasses with no internals; no secrets or PII in logs - Uploads validated by magic bytes + extension + size; user-supplied URLs checked for SSRF
- Tasks enqueued after commit, idempotent, with a
timeout - Tests: 401, 403, 404 cross-tenant, 422
Review Checklist — Anti-patterns to Flag
| Found in code | Rule |
DB access in a router; HTTPException in a service | 1, 4 |
Two commit() calls in one use case; enqueue before commit | 2, 21 |
= Depends(...) / = Query(...) defaults; ... as a default; include_router(prefix=...) | 3 |
No return type; returning ORM objects or dict; ORJSONResponse/UJSONResponse | 5 |
model_config = {...} dict; missing extra="forbid"; organization_id in an input schema; RootModel | 6 |
List query without order_by or without a limit bound | 7 |
db.get(Model, id) or a query without organization_id | 8 |
utcnow(), naive datetimes, DateTime without timezone=True | 9 |
Default expire_on_commit, relationship without lazy="raise" | 10 |
| Check-then-insert for uniqueness; read-modify-write counters | 11 |
One-step column rename; NOT NULL column without default; migrations on startup | 12 |
| Hardcoded secret, or a secret with a default value | 13 |
@app.on_event, BaseHTTPMiddleware, httpx.AsyncClient() per request | 14, 22 |
f-string logs, print(), error=str(e), email/token in logs | 15 |
passlib, python-jose, JWT decode without pinned algorithms, token in query string | 16 |
if user.role == ... for route-level permissions inside services | 17 |
In-memory rate-limit storage; @limiter.limit on a yield endpoint | 18 |
allow_origins=["*"] with credentials | 19 |
Trusting Content-Type or the filename for uploads | 20 |
Celery, ORM objects passed to tasks, tasks without timeout, pickle serializer | 21 |
requests, time.sleep, sync I/O or CPU work in async def | 22 |
Hand-built data: ...\n\n strings or a returned StreamingResponse for SSE | 24 |
SQLite in tests, no cross-tenant tests, AsyncClient(app=app) | 25 |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

