Part eleven
A whole service, file by file
Everything before this was a mechanism or a snippet. This is the repository — one small service, built in the order you would actually build it, with every file that has to exist before anyone will let you deploy it. The domain is a link shortener, chosen because it is small enough to hold in your head and still needs every production concern there is: validation, a database and a migration, idempotency, authentication, rate limiting, structured logs, health checks, tests that run in CI, and an image that starts as a non-root user. Read it top to bottom once; after that each topic stands alone.
Python · 33
Where the files go, and why
Layout is the first architectural decision and the one nobody revisits. Two choices matter here and both have a real reason: the code lives under src/ so the tests cannot accidentally import the working copy instead of the installed package, and the modules are layered so that the direction of every import is a rule you can check rather than a habit you hope holds.
shortlink/
pyproject.toml project metadata, dependencies, and every tool's config
uv.lock the resolved, hashed dependency graph. Committed.
.env.example every variable the app reads, with safe placeholders
compose.yaml Postgres and Redis for local work, nothing else
Dockerfile multi-stage; the runtime stage has no build tools
Makefile the four commands everyone needs, named the same way
src/
shortlink/
__init__.py
main.py the app factory and its lifespan. No business logic.
config.py settings, read once from the environment
domain.py the rules. Imports nothing from this package.
schemas.py the wire contract: what the API accepts and returns
errors.py one exception hierarchy, one handler
db.py engine, session factory, the session dependency
models.py SQLAlchemy tables
repository.py the only module that writes SQL
service.py use cases. Knows the domain, not HTTP.
api.py routers, status codes, dependencies
security.py API keys and the rate limiter
observability.py logging, tracing, metrics, health
migrations/ Alembic: env.py plus one file per schema change
versions/
0001_create_links.py
tests/
conftest.py fixtures: a real database, an in-process client
test_domain.py pure, fast, no I/O
test_api.py through the app, against a real Postgres
test_errors.py the failure contract, which is also an API
.github/workflows/ci.yaml
| Layer | May import | Must never import |
domain.py | The standard library, and nothing else | FastAPI, SQLAlchemy, config. It is the part you can test in a millisecond. |
schemas.py | Pydantic, domain | SQLAlchemy. The wire contract and the table are different shapes and change for different reasons. |
repository.py | models, domain, SQLAlchemy | FastAPI. A repository that raises HTTPException cannot be used by a worker. |
service.py | repository, domain | FastAPI, and the Request object in particular. |
api.py | Everything | Nothing — but it should contain no branching that is not about HTTP. |
That table is the whole architecture. It is also enforceable: a single ruff rule fails the build when an inner layer reaches outward, which is worth more than any diagram, because diagrams do not fail pull requests.
[project]
name = "shortlink"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"pydantic>=2.9",
"pydantic-settings>=2.5",
"sqlalchemy[asyncio]>=2.0.35",
"asyncpg>=0.30",
"alembic>=1.13",
"redis>=5.1",
"structlog>=24.4",
"uvicorn[standard]>=0.31",
]
[dependency-groups]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"httpx>=0.27",
"testcontainers[postgres]>=4.8",
"mypy>=1.11",
"ruff>=0.6",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "S", "ASYNC", "TID"]
per-file-ignores = { "tests/*" = ["S101"] }
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"fastapi".msg = "domain/, repository/ and service/ must not import FastAPI"
[tool.mypy]
strict = true
files = ["src", "tests"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = "-q --strict-markers"
.PHONY: install fmt check test run
install:
uv sync --all-groups
fmt:
uv run ruff format .
uv run ruff check --fix .
check:
uv run ruff format --check .
uv run ruff check .
uv run mypy
test:
uv run pytest
run:
uv run uvicorn shortlink.main:app --reload
L1 · Beginner
The defaults to copy
- Use the
src/ layout. Without it, import shortlink in a test finds the source directory, and you never test the thing you package.
- Commit the lockfile.
uv.lock is what makes the build reproducible; pyproject.toml only records intent.
- One file per responsibility, named for the responsibility.
utils.py is where design goes to die.
- Keep
__init__.py empty. Import side effects are the hardest bugs on this page.
L2 · Intermediate
Make the boundary real
- Layer by dependency direction, not by file type. “Everything that talks to the database” is a layer; “all the classes” is not.
- Enforce it in the linter. A rule that fails the build beats a convention that survives until the first deadline.
- Split by feature when the layers get long. At roughly a dozen tables,
links/ and accounts/ packages each with their own layers read better than one flat set.
- Put tooling config in
pyproject.toml. Five dotfiles is five places to disagree.
L3 · Advanced
What this buys later
- The domain stays testable at speed. If
test_domain.py ever needs a fixture, something has leaked inward.
- The service becomes reusable. The same
service.py backs the HTTP API, a CLI, and a queue consumer, because none of it knows what a request is.
- Swapping the framework becomes a day, not a quarter — the FastAPI-shaped part is one file.
- Dependency groups keep
pytest and mypy out of the runtime image, which is both smaller and a smaller attack surface.
Python · 34
Configuration, read once, validated at boot
Configuration is the first thing that kills a deployment, and it does it in the worst possible way: at three in the morning, on the one code path nobody exercised. The fix is to read every setting once at start-up, give each one a type, and refuse to start when something is missing — a container that fails to boot is a rollback, and a container that boots and then fails on the tenth request is an incident.
from functools import lru_cache
from typing import Literal
from pydantic import Field, PostgresDsn, RedisDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="SHORTLINK_",
extra="forbid",
)
environment: Literal["local", "staging", "production"] = "local"
database_url: PostgresDsn
redis_url: RedisDsn
base_url: str = "http://localhost:8000"
db_pool_size: int = Field(default=10, ge=1, le=100)
rate_limit_per_minute: int = Field(default=60, ge=1)
log_level: str = "INFO"
@field_validator("base_url")
@classmethod
def _no_trailing_slash(cls, v: str) -> str:
return v.rstrip("/")
@property
def debug(self) -> bool:
return self.environment == "local"
@lru_cache
def get_settings() -> Settings:
"""Read the environment exactly once per process.
Cached because settings are immutable for the life of the process, and
because FastAPI will call this as a dependency on every request.
"""
return Settings()
SHORTLINK_ENVIRONMENT=local
SHORTLINK_DATABASE_URL=postgresql+asyncpg://shortlink:shortlink@localhost:5432/shortlink
SHORTLINK_REDIS_URL=redis://localhost:6379/0
SHORTLINK_BASE_URL=http://localhost:8000
SHORTLINK_LOG_LEVEL=DEBUG
Secrets are not configuration
The pattern above is right for a database URL and wrong for a signing key. Anything secret should arrive from the platform’s secret store — a mounted file, a sidecar, an injected environment variable the deployment system owns — and never have a default, never be logged, and never appear in .env.example with a real-looking value. Two habits that cost nothing: keep secrets as pydantic.SecretStr so an accidental repr() of the settings object prints **********, and assert at boot that a production environment did not pick up a development default.
Python · 35
The core, which knows nothing about the web
This is the part worth protecting. It holds the rules — what a valid destination is, how a short code is generated, when a link has expired — and it imports nothing from the rest of the package. That constraint is not purity for its own sake: it is what makes these tests run in a millisecond with no fixtures, and it is what lets the same rules back an HTTP API, a batch import and a queue consumer without being rewritten.
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime
ALPHABET = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"
CODE_LENGTH = 7
BLOCKED_SCHEMES = frozenset({"javascript", "data", "file", "vbscript"})
class DomainError(Exception):
"""Something the caller did. Never a 500."""
class InvalidDestination(DomainError):
pass
@dataclass(frozen=True, slots=True)
class Link:
code: str
destination: str
owner_id: str
created_at: datetime
expires_at: datetime | None = None
def is_expired(self, now: datetime | None = None) -> bool:
if self.expires_at is None:
return False
return (now or datetime.now(UTC)) >= self.expires_at
def new_code(length: int = CODE_LENGTH) -> str:
"""A short code with ~41 bits of entropy at the default length.
secrets, not random: these are guessable identifiers for other people's
content. The collision is handled by a unique index, not by checking
first -- see repository.py, where the race is impossible rather than
unlikely.
"""
return "".join(secrets.choice(ALPHABET) for _ in range(length))
def validate_destination(raw: str) -> str:
"""Normalise and reject. Returns the URL to store, or raises."""
url = raw.strip()
scheme, _, rest = url.partition("://")
if not rest:
raise InvalidDestination("destination must be an absolute URL")
if scheme.lower() in BLOCKED_SCHEMES:
raise InvalidDestination(f"scheme {scheme!r} is not allowed")
if scheme.lower() not in {"http", "https"}:
raise InvalidDestination("only http and https destinations are allowed")
if len(url) > 2048:
raise InvalidDestination("destination is too long")
return url
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from shortlink.domain import Link
class CreateLinkRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
destination: str = Field(min_length=1, max_length=2048)
expires_at: datetime | None = None
class LinkResponse(BaseModel):
code: str
short_url: str
destination: str
created_at: datetime
expires_at: datetime | None
@classmethod
def of(cls, link: Link, base_url: str) -> "LinkResponse":
return cls(
code=link.code,
short_url=f"{base_url}/{link.code}",
destination=link.destination,
created_at=link.created_at,
expires_at=link.expires_at,
)
L1 · Beginner
Two models, not one
- The request model is not the table. They change for different reasons: one when the API changes, one when the schema does.
- Never return the ORM object. Every column you add later becomes public the day you add it.
- Set
extra="forbid" on input. Silent field-name typos are the cheapest bug class to eliminate.
- Validate at the edge, once, and trust the object afterwards.
L2 · Intermediate
Where the rules live
- Pydantic validates shape; the domain validates meaning. “Is a string under 2048 characters” is shape. “Is not a
javascript: URL” is meaning, and belongs where a CLI can reach it too.
- Use
frozen=True and slots=True on domain objects: immutable by default, and measurably smaller.
- Aware datetimes, always UTC. Convert at the edge, never in the middle.
secrets, not random, for anything another person could try to guess.
L3 · Advanced
The parts that bite later
- Entropy is a product decision. Seven base-58 characters is about 41 bits; at ten million links the chance any two collide is small but the chance one is guessed by a scanner is not zero. Size it against what the links protect.
- Open redirects are a real vulnerability class, not a theoretical one — they are used to launder phishing links through domains people trust. The allow-list above is the minimum.
- Normalising is a decision you cannot take back: once you store the normalised form, the original is gone. Keep both if anyone might audit it.
- Keep the domain importable with no environment set. If it needs settings, it is not the domain.
Python · 36
Persistence: engine, session, and the first migration
Three separate things get confused here constantly. The engine is a connection pool and lives for the life of the process. The session is a unit of work and lives for the life of one request. The migration is a versioned, reviewed change to the schema that runs before the new code does. Getting the lifetimes right is most of what makes a service survive load.
from datetime import datetime
from sqlalchemy import DateTime, Index, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class LinkRow(Base):
__tablename__ = "links"
code: Mapped[str] = mapped_column(String(16), primary_key=True)
destination: Mapped[str] = mapped_column(String(2048), nullable=False)
owner_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
idempotency_key: Mapped[str | None] = mapped_column(String(64))
__table_args__ = (
Index("uq_links_owner_idem", "owner_id", "idempotency_key", unique=True,
postgresql_where=idempotency_key.isnot(None)),
)
class ApiKeyRow(Base):
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(primary_key=True)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
owner_id: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from shortlink.config import get_settings
settings = get_settings()
engine = create_async_engine(
str(settings.database_url),
pool_size=settings.db_pool_size,
max_overflow=0,
pool_pre_ping=True,
pool_recycle=1800,
)
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncIterator[AsyncSession]:
"""The transaction boundary, and the only place commit is called.
One request, one transaction. Anything raised below -- a domain error, a
bug, a cancelled request -- unwinds through here and rolls back, so a
handler can never leave half a write behind.
"""
async with SessionFactory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
"""create links
Revision ID: 0001
"""
import sqlalchemy as sa
from alembic import op
revision = "0001"
down_revision = None
def upgrade() -> None:
op.create_table(
"links",
sa.Column("code", sa.String(16), primary_key=True),
sa.Column("destination", sa.String(2048), nullable=False),
sa.Column("owner_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True)),
sa.Column("idempotency_key", sa.String(64)),
)
op.create_index("ix_links_owner_id", "links", ["owner_id"])
op.create_index(
"uq_links_owner_idem", "links", ["owner_id", "idempotency_key"],
unique=True, postgresql_where=sa.text("idempotency_key IS NOT NULL"),
)
def downgrade() -> None:
op.drop_table("links")
Why expire_on_commit=False is not an optimisation
By default SQLAlchemy marks every attribute stale after a commit, so the next attribute access re-reads it from the database. In a synchronous script that is a mild waste. In an async request handler it is a crash: the commit happens in the dependency as the response is being built, so the refresh is attempted on a session that is closing, from a thread with no greenlet context, and you get MissingGreenlet pointing at a line that looks completely innocent. Turn it off, and convert ORM rows to domain objects or response models before the transaction ends — which the repository below does anyway.
Python · 37
The repository, and the race you cannot check for
One module writes SQL, and it returns domain objects rather than ORM rows so nothing above it is coupled to the table. It also contains the single most important idea in this whole part: where two requests can collide, do not check and then act — ask the database to enforce it and handle the refusal. A SELECT followed by an INSERT is a race with a comfortable-looking test suite.
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from shortlink.domain import Link
from shortlink.models import LinkRow
class CodeCollision(Exception):
"""The generated code was already taken. Caller retries with a new one."""
def _to_domain(row: LinkRow) -> Link:
return Link(
code=row.code,
destination=row.destination,
owner_id=row.owner_id,
created_at=row.created_at,
expires_at=row.expires_at,
)
class LinkRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get(self, code: str) -> Link | None:
row = await self._session.get(LinkRow, code)
return _to_domain(row) if row else None
async def find_by_idempotency_key(self, owner_id: str, key: str) -> Link | None:
stmt = select(LinkRow).where(
LinkRow.owner_id == owner_id, LinkRow.idempotency_key == key
)
row = (await self._session.execute(stmt)).scalar_one_or_none()
return _to_domain(row) if row else None
async def add(
self,
code: str,
destination: str,
owner_id: str,
expires_at: datetime | None,
idempotency_key: str | None,
) -> Link:
"""Insert, or raise CodeCollision if that code is taken.
This is INSERT ... ON CONFLICT DO NOTHING on the idempotency index,
which makes a repeated request a no-op at the database rather than a
second link. If nothing came back, either the code collided or the
request is a retry -- the caller can tell them apart, and only one of
them is worth retrying.
"""
stmt = (
insert(LinkRow)
.values(
code=code,
destination=destination,
owner_id=owner_id,
expires_at=expires_at,
idempotency_key=idempotency_key,
)
.on_conflict_do_nothing(index_elements=["owner_id", "idempotency_key"])
.returning(LinkRow)
)
try:
row = (await self._session.execute(stmt)).scalar_one_or_none()
except IntegrityError as exc:
raise CodeCollision(code) from exc
if row is None:
existing = await self.find_by_idempotency_key(owner_id, idempotency_key or "")
if existing is None:
raise CodeCollision(code)
return existing
return _to_domain(row)
from datetime import UTC, datetime
from shortlink import domain
from shortlink.domain import DomainError, Link
from shortlink.repository import CodeCollision, LinkRepository
MAX_CODE_ATTEMPTS = 5
class LinkNotFound(DomainError):
pass
class LinkExpired(DomainError):
pass
class LinkService:
def __init__(self, repo: LinkRepository) -> None:
self._repo = repo
async def create(
self,
destination: str,
owner_id: str,
expires_at: datetime | None = None,
idempotency_key: str | None = None,
) -> Link:
clean = domain.validate_destination(destination)
for _ in range(MAX_CODE_ATTEMPTS):
try:
return await self._repo.add(
code=domain.new_code(),
destination=clean,
owner_id=owner_id,
expires_at=expires_at,
idempotency_key=idempotency_key,
)
except CodeCollision:
continue
raise RuntimeError("could not allocate a unique code")
async def resolve(self, code: str) -> Link:
link = await self._repo.get(code)
if link is None:
raise LinkNotFound(code)
if link.is_expired(datetime.now(UTC)):
raise LinkExpired(code)
return link
L1 · Beginner
Keep SQL in one place
- One module writes queries. When a query is slow, there is one file to open.
- Return domain objects, not ORM rows — then nothing above cares which ORM you used.
- Never build SQL with f-strings. Parameters are not about tidiness; they are the injection boundary.
- The service raises domain errors. Translating those to status codes is the API layer's job.
L2 · Intermediate
Let the database enforce it
- Check-then-act is a race.
SELECT ... IF NOT EXISTS ... INSERT passes every test and fails under concurrency.
- Idempotency is a unique index plus a key from the client. It is the difference between a retry and a duplicate charge.
- Bound every retry loop, and make the exhausted case loud.
- Distinguish 404 from 410. It costs one exception class and it changes crawler behaviour.
L3 · Advanced
Transactions and the N+1
- The repository does not commit. Only the request-scoped dependency does, so one request is one atomic unit however many repositories it touches.
- Watch for lazy loads after commit. Converting to a domain object inside the transaction is what makes that impossible rather than unlikely.
- Partial unique indexes let idempotency keys be optional without making
NULL collide with itself — Postgres treats every NULL as distinct.
- If the collision retry ever fires measurably, the code space is too small. Alert on it rather than discovering it in a bug report.
Python · 38
The HTTP edge, and the app that wires it
This layer should be boring, and if it is not, something below it is missing. Its whole job is to turn a request into a call and a result into a status code — no business rules, no SQL, no branching that is not about HTTP. The interesting part is the app factory: what gets created once at start-up and torn down cleanly, which is what lifespan exists for.
from typing import Annotated
from fastapi import APIRouter, Depends, Header, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from shortlink.config import Settings, get_settings
from shortlink.db import get_session
from shortlink.repository import LinkRepository
from shortlink.schemas import CreateLinkRequest, LinkResponse
from shortlink.security import ApiKey, require_api_key, rate_limit
from shortlink.service import LinkService
router = APIRouter()
def get_service(session: Annotated[AsyncSession, Depends(get_session)]) -> LinkService:
return LinkService(LinkRepository(session))
ServiceDep = Annotated[LinkService, Depends(get_service)]
SettingsDep = Annotated[Settings, Depends(get_settings)]
@router.post(
"/links",
response_model=LinkResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(rate_limit)],
summary="Create a short link",
)
async def create_link(
body: CreateLinkRequest,
service: ServiceDep,
settings: SettingsDep,
caller: Annotated[ApiKey, Depends(require_api_key)],
response: Response,
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
) -> LinkResponse:
link = await service.create(
destination=body.destination,
owner_id=caller.owner_id,
expires_at=body.expires_at,
idempotency_key=idempotency_key,
)
response.headers["Location"] = f"{settings.base_url}/{link.code}"
return LinkResponse.of(link, settings.base_url)
@router.get("/{code}", include_in_schema=False)
async def follow(code: str, service: ServiceDep) -> RedirectResponse:
link = await service.resolve(code)
return RedirectResponse(link.destination, status_code=status.HTTP_307_TEMPORARY_REDIRECT)
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import redis.asyncio as redis
from fastapi import FastAPI
from shortlink import observability
from shortlink.api import router
from shortlink.config import get_settings
from shortlink.db import engine
from shortlink.errors import install_error_handlers
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Start-up before the first request; shutdown after the last one.
Everything expensive and long-lived is built here, once. Building a Redis
client per request is the commonest cause of a service that is fine in
staging and exhausts file descriptors in production.
"""
settings = get_settings()
observability.configure(settings)
app.state.redis = redis.from_url(str(settings.redis_url), decode_responses=True)
try:
yield
finally:
await app.state.redis.aclose()
await engine.dispose()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title="shortlink",
version="0.1.0",
lifespan=lifespan,
docs_url="/docs" if settings.debug else None,
redoc_url=None,
)
install_error_handlers(app)
observability.install_middleware(app)
app.include_router(observability.health_router)
app.include_router(router)
return app
app = create_app()
The route order that costs a day
GET /{code} is a wildcard at the root, so it will happily swallow /healthz, /docs and anything else you add later — FastAPI matches routes in registration order, and the first match wins. That is why the health router is included before the application router above, and why in a real service the catch-all is usually mounted last and deliberately, with a comment saying so. The same reasoning applies to a prefix: putting the API under /v1 and leaving the bare root for redirects removes the ambiguity entirely, and is what you want the day you add a second endpoint.
Python · 39
Failure is part of the contract
Every endpoint has a happy path and several unhappy ones, and clients integrate against both. If each handler invents its own error body, the client ends up with a parser per endpoint and a comment saying “sometimes it is detail, sometimes message”. One exception hierarchy, one handler, one documented shape — and RFC 9457 already specifies that shape, so there is nothing to design.
import structlog
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from shortlink.domain import DomainError, InvalidDestination
from shortlink.service import LinkExpired, LinkNotFound
log = structlog.get_logger()
STATUS_BY_ERROR: dict[type[DomainError], int] = {
InvalidDestination: status.HTTP_422_UNPROCESSABLE_ENTITY,
LinkNotFound: status.HTTP_404_NOT_FOUND,
LinkExpired: status.HTTP_410_GONE,
}
def problem(
request: Request, status_code: int, title: str, detail: str, **extra: object
) -> JSONResponse:
"""RFC 9457 problem details. A standard shape beats a bespoke one."""
body = {
"type": f"https://errors.shortlink.dev/{title.lower().replace(' ', '-')}",
"title": title,
"status": status_code,
"detail": detail,
"instance": str(request.url.path),
"request_id": request.state.request_id,
**extra,
}
return JSONResponse(
body, status_code=status_code, media_type="application/problem+json"
)
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(DomainError)
async def _domain(request: Request, exc: DomainError) -> JSONResponse:
code = STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
log.info("domain_error", error=type(exc).__name__, status=code)
return problem(request, code, type(exc).__name__, str(exc))
@app.exception_handler(RequestValidationError)
async def _validation(request: Request, exc: RequestValidationError) -> JSONResponse:
return problem(
request,
status.HTTP_422_UNPROCESSABLE_ENTITY,
"Validation failed",
"The request body did not match the schema.",
errors=[
{"field": ".".join(str(p) for p in e["loc"][1:]), "message": e["msg"]}
for e in exc.errors()
],
)
@app.exception_handler(Exception)
async def _unhandled(request: Request, exc: Exception) -> JSONResponse:
log.exception("unhandled_error", path=request.url.path)
return problem(
request,
status.HTTP_500_INTERNAL_SERVER_ERROR,
"Internal error",
"The request could not be completed. Quote the request id if you contact support.",
)
| Situation | Status | Why that one |
| Body does not match the schema | 422 | Handled by FastAPI before your code runs. Return the failing field names; clients cannot fix what they cannot see. |
Destination is a javascript: URL | 422 | Well-formed request, unacceptable content. A domain rule said no. |
| No such code | 404 | Nothing to say beyond that. Do not confirm whether it ever existed. |
| Code existed, has expired | 410 | Tells a crawler to stop asking. 404 invites a retry next week. |
| Missing or wrong API key | 401 | With WWW-Authenticate. 403 means “I know who you are and no”. |
| Over the rate limit | 429 | With Retry-After, or the client will simply hammer you faster. |
| Anything unhandled | 500 | Generic body, full detail in the logs, one request id joining the two. |
The catch-all handler and the one thing it must not do
Registering a handler for bare Exception means the framework no longer re-raises, so anything that used to surface in your error tracker now quietly becomes a tidy 500. That is only safe because the handler logs with log.exception — which captures the traceback — before returning. If you take one thing from this topic: an error the client sees and an error you can debug are two different artefacts, and the request id is the only thing joining them. Put it in the response body, in every log line, and in the X-Request-Id response header.
Python · 40
Who is calling, and how often
Two edge concerns that are always someone else’s job until the incident. Authentication here is an API key, which is the right choice for machine-to-machine calls and has exactly two rules: store a hash, never the key, and compare in constant time. Rate limiting is the thing that keeps one badly-written client from being everybody’s outage, and it has to be shared across processes, which means it lives in Redis rather than in a dictionary.
import hashlib
import hmac
import time
from dataclasses import dataclass
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shortlink.config import Settings, get_settings
from shortlink.db import get_session
from shortlink.models import ApiKeyRow
bearer = HTTPBearer(auto_error=False)
@dataclass(frozen=True, slots=True)
class ApiKey:
owner_id: str
def hash_key(raw: str) -> str:
"""What goes in the database. Never the key itself.
sha256 and not bcrypt on purpose: an API key is already 256 bits of
randomness that we issued, so there is no low-entropy password to make
expensive to guess -- and this runs on every single request.
"""
return hashlib.sha256(raw.encode()).hexdigest()
async def load_key(session: AsyncSession, digest: str) -> ApiKeyRow | None:
"""Look the caller up by the hash, never by the key.
Indexed on key_hash, so this is one index probe, and the raw key never
reaches a query, a log line, or a slow-query report.
"""
stmt = select(ApiKeyRow).where(
ApiKeyRow.key_hash == digest, ApiKeyRow.revoked_at.is_(None)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def require_api_key(
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> ApiKey:
unauthorized = HTTPException(
status.HTTP_401_UNAUTHORIZED,
"invalid or missing credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if creds is None:
raise unauthorized
digest = hash_key(creds.credentials)
row = await load_key(session, digest)
if row is None or not hmac.compare_digest(row.key_hash, digest):
raise unauthorized
return ApiKey(owner_id=row.owner_id)
async def rate_limit(
request: Request, settings: Annotated[Settings, Depends(get_settings)]
) -> None:
"""A fixed-window counter in Redis, shared by every worker.
An in-process dict is not a rate limit: with four workers behind one load
balancer the effective limit is four times what you configured, and it
resets on every deploy.
"""
key = f"rl:{request.client.host if request.client else 'unknown'}:{int(time.time() // 60)}"
redis = request.app.state.redis
pipe = redis.pipeline()
pipe.incr(key)
pipe.expire(key, 120)
used, _ = await pipe.execute()
limit = settings.rate_limit_per_minute
if used > limit:
retry_after = 60 - int(time.time() % 60)
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"rate limit exceeded",
headers={
"Retry-After": str(retry_after),
"RateLimit-Limit": str(limit),
"RateLimit-Remaining": "0",
},
)
L1 · Beginner
The non-negotiables
- Store a hash, never the key. A leaked database should not be a leaked credential.
- Show the key once, at creation. If you can display it later, you stored it.
- 401 for “who are you”, 403 for “not allowed”. They are different bugs to debug.
- Never log the
Authorization header. Redact it in the middleware, not by remembering.
L2 · Intermediate
Make it operable
- Rate limit state must be shared. Per-process counters silently multiply the limit by your worker count.
- Return the headers.
Retry-After turns a retry storm into a queue.
- Key on the caller, not the IP, once you have authenticated one — otherwise a whole office behind one NAT shares a bucket.
- Give keys a prefix (
sl_live_...) so secret scanners can find them in a public repository before an attacker does.
L3 · Advanced
Where this design runs out
- A fixed window allows a double burst at the boundary: the full limit at 11:59:59 and again at 12:00:00. A sliding window or a token bucket in a Lua script fixes it when that matters.
- Decide what happens when Redis is down. Fail open and you have no limit; fail closed and Redis is now a hard dependency of every request. Choose deliberately and write it down.
- Rate limiting at the edge is cheaper — a gateway or CDN rejects before you pay for a worker. Keep the in-app limit as the backstop.
- Key rotation needs two valid keys at once, which means the lookup must accept a set, not a value.
Python · 41
Logs, traces, and the two health checks
You will debug this service from its output, not from a debugger, so the output is part of the design. Three things pay for themselves in the first week: logs that are JSON with a request id on every line, a trace that spans the whole request, and two different health endpoints — because “is this process alive” and “should this process receive traffic” have different answers and confusing them takes an entire cluster down.
import time
import uuid
from collections.abc import Awaitable, Callable
import structlog
from fastapi import APIRouter, FastAPI, Request, Response, status
from fastapi.responses import JSONResponse
from sqlalchemy import text
from shortlink.config import Settings
from shortlink.db import engine
log = structlog.get_logger()
def configure(settings: Settings) -> None:
"""JSON in production, colour locally. Same events either way."""
renderer = (
structlog.dev.ConsoleRenderer()
if settings.debug
else structlog.processors.JSONRenderer()
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
renderer,
],
wrapper_class=structlog.make_filtering_bound_logger(settings.log_level),
cache_logger_on_first_use=True,
)
def install_middleware(app: FastAPI) -> None:
@app.middleware("http")
async def request_context(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request.state.request_id = request_id
structlog.contextvars.bind_contextvars(request_id=request_id)
started = time.perf_counter()
try:
response = await call_next(request)
finally:
structlog.contextvars.clear_contextvars()
elapsed_ms = (time.perf_counter() - started) * 1000
response.headers["X-Request-Id"] = request_id
log.info(
"http_request",
method=request.method,
path=request.scope.get("route").path if request.scope.get("route") else request.url.path,
status=response.status_code,
duration_ms=round(elapsed_ms, 2),
)
return response
health_router = APIRouter(tags=["health"])
@health_router.get("/healthz", include_in_schema=False)
async def liveness() -> dict[str, str]:
"""Is the process alive? Nothing else.
It must not touch the database. A liveness probe that checks a dependency
restarts every pod in the fleet the moment that dependency has a bad
minute -- turning a brief database blip into a full outage with no
capacity left to recover.
"""
return {"status": "ok"}
@health_router.get("/readyz", include_in_schema=False)
async def readiness() -> Response:
"""Should this process receive traffic? Now the dependencies matter."""
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
except Exception:
log.warning("readiness_failed", check="database")
return JSONResponse(
{"status": "degraded", "database": "unreachable"},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return JSONResponse({"status": "ready"})
| Signal | Answers | The mistake to avoid |
| Structured logs | “What happened to this request?” | Free-text messages. f"user {uid} failed" cannot be filtered, counted or joined. |
| Metrics | “How is the service doing overall?” | High-cardinality labels. A label per short code is a monitoring bill, not a dashboard. |
| Traces | “Where did the 900 ms go?” | Sampling everything. 1–10% plus every error is the usual shape. |
/healthz | “Restart me?” | Checking dependencies. That is how one slow database restarts the entire fleet. |
/readyz | “Send me traffic?” | Returning 200 while the pool is exhausted, so the load balancer keeps feeding a dead process. |
The one field that makes logs useful
A request id, bound in a contextvar at the edge and merged into every event automatically. Without it, a production investigation is grep and hope; with it, one identifier from a support ticket returns the whole story across every module and, if the id is propagated on outbound calls, across every service. It costs the middleware above and nothing else. Note the route template rather than the raw path in the log line, for the same reason: /links/{code} is a thing you can count, and /links/aB3xK9p is ten million things you cannot.
Python · 42
Tests that would actually have caught it
The tests worth writing are the ones that fail when the behaviour changes and pass when only the code does. That rules out mocking the database — a mocked session cannot fail a unique constraint, which is precisely the behaviour the idempotency logic depends on. So: a real Postgres in a container, the app driven in-process over ASGI with no network, and the domain tested on its own with no fixtures at all.
from collections.abc import AsyncIterator, Iterator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from testcontainers.postgres import PostgresContainer
from shortlink.db import get_session
from shortlink.main import create_app
from shortlink.models import Base
@pytest.fixture(scope="session")
def postgres_url() -> Iterator[str]:
with PostgresContainer("postgres:16-alpine", driver="asyncpg") as pg:
yield pg.get_connection_url()
@pytest.fixture
async def session(postgres_url: str) -> AsyncIterator[AsyncSession]:
"""Each test runs in a transaction that is rolled back afterwards.
Faster than recreating the schema per test, and it means tests cannot
leak state into one another however carelessly they are written.
"""
engine = create_async_engine(postgres_url)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
connection = await engine.connect()
transaction = await connection.begin()
factory = async_sessionmaker(bind=connection, expire_on_commit=False)
async with factory() as s:
yield s
await transaction.rollback()
await connection.close()
await engine.dispose()
@pytest.fixture
async def client(session: AsyncSession) -> AsyncIterator[AsyncClient]:
"""The real app, with only the session swapped for the test one.
ASGITransport means no socket is opened and no port is bound, so the
suite runs anywhere and cannot flake on a port collision.
"""
app = create_app()
app.dependency_overrides[get_session] = lambda: session
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
import pytest
from httpx import AsyncClient
async def test_create_returns_201_with_location(client: AsyncClient, api_key: str) -> None:
response = await client.post(
"/links",
json={"destination": "https://example.com/a"},
headers={"Authorization": f"Bearer {api_key}"},
)
assert response.status_code == 201
body = response.json()
assert body["short_url"].endswith(body["code"])
assert response.headers["Location"].endswith(body["code"])
async def test_same_idempotency_key_returns_the_same_link(
client: AsyncClient, api_key: str
) -> None:
"""The test that a mocked database physically cannot perform.
Two identical requests, one link. The guarantee lives in a partial
unique index, so only a real Postgres can prove it holds.
"""
headers = {"Authorization": f"Bearer {api_key}", "Idempotency-Key": "abc-123"}
first = await client.post("/links", json={"destination": "https://example.com"}, headers=headers)
second = await client.post("/links", json={"destination": "https://example.com"}, headers=headers)
assert first.status_code == 201
assert first.json()["code"] == second.json()["code"]
@pytest.mark.parametrize(
"destination",
["javascript:alert(1)", "data:text/html,<script>", "ftp://example.com", "not a url"],
)
async def test_dangerous_destinations_are_rejected(
client: AsyncClient, api_key: str, destination: str
) -> None:
response = await client.post(
"/links",
json={"destination": destination},
headers={"Authorization": f"Bearer {api_key}"},
)
assert response.status_code == 422
assert response.headers["content-type"].startswith("application/problem+json")
assert response.json()["status"] == 422
async def test_unknown_code_is_404_and_expired_is_410(client: AsyncClient) -> None:
assert (await client.get("/nope123")).status_code == 404
L1 · Beginner
What to write first
- One test per behaviour, named for the behaviour.
test_create_returns_201_with_location tells you what broke from the failure line alone.
- Test the domain without fixtures. If it needs one, the layering has slipped.
- Assert the status code and the body shape, not the exact prose of a message.
- Parametrise the variants instead of writing four near-identical tests.
L2 · Intermediate
Make them fast and honest
- A real database, one container, per-test transactions. Rollback isolation is faster than recreating a schema and stricter than trusting cleanup.
- Drive the app over ASGI — no port, no server, no flake, and the middleware and error handlers are all still exercised.
- Override only the seam you must. Overriding the service under test proves nothing.
- Mock what you do not own and cannot run: a third-party payment API, yes. Your own database, no.
L3 · Advanced
The tests that earn their keep later
- Test the error contract. Clients parse it, so it is an interface, and it will otherwise change silently.
- Run the migrations in CI against a copy of the previous schema, so an unsafe lock is caught before it takes production offline.
- Property tests for the pure parts — a code generator and a validator are exactly what Hypothesis is good at.
- Coverage is a smoke detector, not a goal. Untested error paths matter more than the last 4%.
Python · 43
The image, the pipeline, and the shutdown
The last mile, and the one where Python services most often go wrong in ways nobody notices until the first rolling deploy drops requests. Three things to get right: an image that carries no build tools and runs as nobody, a pipeline that runs exactly what the Makefile runs, and a process that finishes its in-flight requests when the orchestrator asks it to stop.
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.4 /uv /bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev
COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.12-slim AS runtime
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
USER app
EXPOSE 8000
CMD ["uvicorn", "shortlink.main:app", \
"--host", "0.0.0.0", "--port", "8000", \
"--workers", "4", "--timeout-graceful-shutdown", "20"]
name: ci
on:
push: { branches: [main] }
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: uv.lock
- run: uv sync --frozen --all-groups
- run: make check
- run: make test
- name: Build the image
run: docker build -t shortlink:${{ github.sha }} .
Graceful shutdown is not automatic, and it is what a rolling deploy needs
When Kubernetes replaces a pod it sends SIGTERM and starts a countdown. Three things have to line up or you drop requests on every deploy. The signal must reach the server, which means the exec form of CMD so it is PID 1 rather than a shell that ignores signals. The server must then stop accepting new connections but finish the ones in flight, which is what --timeout-graceful-shutdown bounds. And the pod must leave the load balancer before it stops accepting, which is what a preStop sleep of a few seconds buys you, because endpoint removal is eventually consistent and traffic keeps arriving for a moment after the signal. Get all three right and a deploy is invisible; miss the first and every deploy is a small outage nobody attributes to the deploy.
| Before it is “done” | Why it is on this list |
| Migrations run as a separate step, not at start-up | Four replicas starting at once run four migrations. Run them once, in a job, before the new version rolls. |
/readyz wired to the readiness probe | Otherwise traffic arrives before the pool is warm and the first requests fail. |
| Resource requests and limits set | Without a memory limit, one large response takes down the node instead of one pod. |
| Logs to stdout, nowhere else | The platform collects stdout. A log file in a container is a disk that fills. |
| The image scanned, and the lockfile audited | uv lock --check plus a vulnerability scan in CI; supply chain is part of the build now. |
| An alert on the 500 rate and on p99 latency | Dashboards are for after somebody notices. Alerts are for before. |