| name | server-python |
| description | Patterns and anti-patterns for the Python/FastAPI + granian server defined in spec 0006. Use when implementing or reviewing any file in servers/python/. Trigger phrases: "python server", "fastapi", "granian", "/server-python".
|
| applyTo | ["servers/python/**"] |
Server: Python 3.13 + FastAPI + granian (spec 0006)
Run command
.venv/bin/granian --interface asgi --workers 1 --host 0.0.0.0 --port 8080 main:app
--workers 1 is mandatory (spec RF6: single-process). Without it, granian forks
multiple OS processes, violating the fair-comparison constraint.
Dependencies
fastapi>=0.136.1
granian>=2.0.0
No uvicorn — granian is the server (Rust-implemented ASGI, 2-3× faster).
The async/sync decision for SQLite
Python's built-in sqlite3 module is synchronous. In an async def endpoint,
calling sqlite3 functions blocks the event loop and prevents other requests from
being served concurrently.
Correct pattern: synchronous endpoint
FastAPI natively supports synchronous def handlers by running them in a thread pool:
from fastapi import FastAPI
import sqlite3, os
app = FastAPI()
def get_db():
db_path = os.environ.get("DB_PATH", "../../data/benchmark.db")
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
@app.get("/items")
def list_items(db: sqlite3.Connection = Depends(get_db)):
rows = db.execute("SELECT id, name, created_at FROM items ORDER BY id").fetchall()
return [dict(row) for row in rows]
Alternative: async with run_in_executor
import asyncio
from functools import partial
@app.get("/items")
async def list_items():
loop = asyncio.get_event_loop()
rows = await loop.run_in_executor(None, partial(_fetch_items))
return rows
The def approach is simpler and idiomatic for CPU-bound or blocking I/O in FastAPI.
Status codes
FastAPI defaults to 200. Override with status_code:
@app.post("/items", status_code=201)
def create_item(body: CreateItemRequest, db = Depends(get_db)):
...
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="not found")
raise HTTPException(status_code=400, detail="name is required")
FastAPI wraps HTTPException into {"detail": "..."} by default.
The spec requires {"error": "..."}. Override the exception handler:
from fastapi.responses import JSONResponse
from fastapi.exceptions import HTTPException
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail},
)
created_at — UTC with Z suffix
from datetime import datetime, timezone
created_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
datetime.now(timezone.utc).isoformat()
datetime.utcnow().isoformat()
Pydantic models
from pydantic import BaseModel
class Item(BaseModel):
id: int
name: str
created_at: str
class CreateItemRequest(BaseModel):
name: str
@field_validator("name")
@classmethod
def name_must_not_be_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("name is required")
return v
When a Pydantic validator raises ValueError, FastAPI returns 422 by default.
Override at the handler level to return 400:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
return JSONResponse(status_code=400, content={"error": "name is required"})
204 DELETE — no body
from fastapi import Response
@app.delete("/items/{item_id}", status_code=204)
def delete_item(item_id: int, db = Depends(get_db)):
deleted = db_delete_item(db, item_id)
if not deleted:
raise HTTPException(status_code=404, detail="not found")
return Response(status_code=204)
Returning None from a 204 endpoint may cause FastAPI to serialize null.
Return an explicit Response(status_code=204) to guarantee no body.
Environment variables
import os
DB_PATH = os.environ.get("DB_PATH", "../../data/benchmark.db")
PORT = int(os.environ.get("PORT", "8080"))
Anti-patterns
| Anti-pattern | Problem | Fix |
|---|
async def endpoint + sqlite3 calls | Blocks event loop | Use def endpoint instead |
@app.post("/items") without status_code=201 | Returns 200 instead of 201 | Add status_code=201 |
datetime.now(timezone.utc).isoformat() | Produces +00:00 | Use strftime("%Y-%m-%dT%H:%M:%SZ") |
Default HTTPException handler | Returns {"detail":"..."} | Override with {"error":"..."} handler |
--workers > 1 in granian | Forks multiple processes | Always --workers 1 |
uvicorn instead of granian | Allowed but slower; violates framework choice in spec | Use granian |