🪴 GoDeep
Search
← Shipping Software

Dependencies, lifespan and settings

Read and judge FastAPI's `Depends` wiring, know what belongs in `lifespan` versus a per-request dependency, load configuration from environment variables with pydantic-settings, and override dependencies cleanly in tests.

12 cards · 6 quiz questions · 10 min read

An agent has built you a FastAPI service. Every endpoint signature is full of things like db: Annotated[Session, Depends(get_db)], there is a lifespan function at the top of main.py, and somewhere there is a Settings class — or, less happily, a hard-coded connection string. This article is about what that wiring actually does at request time, which parts are load-bearing, and the specific smells that tell you the agent got it wrong.

Depends is a function call FastAPI makes for you

Dependency injection sounds architectural. The mechanism is small: you declare a parameter whose annotation says “the value here comes from calling this function”, and FastAPI calls it before your endpoint runs and passes the result in.

from typing import Annotated
from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session

app = FastAPI()

def get_db():
    db = SessionLocal()          # borrow a connection from the pool
    try:
        yield db                 # the endpoint runs here
    finally:
        db.close()               # always returned, even if the endpoint raises

@app.get("/responses")
def list_responses(db: Annotated[Session, Depends(get_db)], limit: int = 50):
    return db.scalars(select(SurveyResponse).limit(limit)).all()

Three things to take from that. First, dependencies can themselves have dependencies, and FastAPI resolves the whole graph before your code runs. Second, each dependency’s result is cached for the duration of one request, so if four things need the settings object they share one. Third, a dependency that uses yield gives you setup and teardown: the lines after the yield run once the response has been produced.

That try/finally is not decoration. Here is the version agents commonly emit:

# Agent's version — leaks a connection on every 500
def get_db():
    db = SessionLocal()
    yield db
    db.close()          # skipped entirely if the endpoint raises

If an endpoint throws, the generator is closed by an exception thrown into it at the yield, and db.close() never runs. In dev you never notice. In production, each error leaks a pooled connection until the pool is exhausted and every request hangs waiting for one. The symptom is a service that works fine, degrades over hours, and recovers on restart — which is the most misleading failure shape there is, because restarting “fixes” it.

Prefer Annotated

You will see two styles:

# Old style: the injection lives in the default value
def list_responses(db: Session = Depends(get_db)): ...

# Current style: type position, no default, reusable
DbSession = Annotated[Session, Depends(get_db)]

def list_responses(db: DbSession): ...

The Annotated form leaves the parameter without a default, so the function is still callable directly in a test, type checkers understand it, and you can alias the annotation once and reuse it in fifty endpoints. If a change touches get_db, there is one line to edit.

Dependencies as the place you put shared rules

The pattern earns its keep when a dependency encodes a rule rather than an object. “Who is calling?” is the classic:

async def current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: DbSession,
) -> User:
    user = await lookup_user(db, token)
    if user is None:
        raise HTTPException(status_code=401, detail="Not authenticated")
    return user

CurrentUser = Annotated[User, Depends(current_user)]

@app.get("/exports")
def my_exports(user: CurrentUser, db: DbSession):
    ...

Compare that with an agent calling a check_auth() helper inside each endpoint body. Both work, but the dependency version is declared in the signature, shows up in the OpenAPI schema your React front end reads, and can be attached to an entire router at once:

admin = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])

Now the protection is a property of the router, not of each handler. The smell to grep for is an endpoint that touches user data with no user-ish parameter in its signature and no router-level dependency — that endpoint is probably open to the internet.

lifespan: things built once, not per request

A lifespan handler runs code once when the process starts and once when it shuts down. It is an async context manager with a single yield:

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http = httpx.AsyncClient(timeout=30.0)   # startup
    app.state.engine = create_engine(get_settings().database_url, pool_pre_ping=True)
    yield                                              # app serves requests here
    await app.state.http.aclose()                      # shutdown
    app.state.engine.dispose()

app = FastAPI(lifespan=lifespan)

The dividing line is lifetime and safety of sharing:

Build once in lifespanBuild per request in a dependency
SQLAlchemy engine and its connection poolA session (it holds one transaction)
httpx.AsyncClient with keep-aliveThe current user
A loaded model or tokeniserA request-scoped logger with a trace id
A cache or queue clientAnything derived from this caller’s input

Getting this backwards is the most common structural mistake in agent-written FastAPI. Creating the engine inside get_db means a new connection pool per request, so pooling never happens: every request pays a full TCP handshake and authentication, and your Azure Database for PostgreSQL hits its connection ceiling under modest load. The mirror-image mistake — one module-level Session shared by everything — gives you concurrent requests writing into the same transaction and reading each other’s uncommitted rows.

Two smaller notes. @app.on_event("startup") is the deprecated predecessor of lifespan; if an agent emits it, it still runs, but new code should use lifespan. And the shutdown half only runs on a graceful stop, so do not put anything load-bearing there that must happen even if the container is killed.

Config belongs in the environment

The twelve-factor rule is blunt: config is whatever differs between deployments, code is whatever does not, and the test is whether you could open-source the repository right now without leaking a credential.

# Agent's version — a live secret, in git, forever
DATABASE_URL = "postgresql://admin:Pa55w0rd@iff-db.postgres.database.azure.com/surveys"
# Better: one typed class, values from the environment
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")
    database_url: str                    # required: no default, so it fails at startup
    azure_openai_key: str
    max_batch_size: int = 25             # optional, with a sensible default

@lru_cache                               # built once per process, not per request
def get_settings() -> Settings:
    return Settings()

SettingsDep = Annotated[Settings, Depends(get_settings)]

pydantic-settings reads each field from the matching environment variable, coerces the type (so MAX_BATCH_SIZE=25 becomes an int, and MAX_BATCH_SIZE=lots is a startup error, not a mystery halfway through a nightly job), and refuses to construct if a required field is missing. That failure is the point: you want a container that will not start over one that starts and fails on the first survey import at 3am.

Note what the hard-coded version costs beyond the leak. The same string is used in every environment, so staging writes to production. Rotating the password needs a code change, a review and a deploy. And deleting the line does not fix it — the credential is in git history, so it must be rotated.

@lru_cache matters because Settings() reads the environment and validates on every construction. Cached, it is a per-process singleton. The catch is in tests: if you change os.environ and expect new settings, call get_settings.cache_clear(), or better, override the dependency.

Overriding dependencies in tests

Every Depends is a seam. app.dependency_overrides maps the original function object to a replacement, at any depth in the graph:

import pytest
from fastapi.testclient import TestClient

@pytest.fixture
def client(test_session):
    app.dependency_overrides[get_db] = lambda: test_session
    app.dependency_overrides[current_user] = lambda: User(id=1, email="t@example.com")
    yield TestClient(app)
    app.dependency_overrides.clear()      # without this, the override leaks

That dictionary lives on the app object for the whole process. Setting it inside a test body and forgetting to clear it produces the worst class of test bug: passes alone, fails in the suite, or — much worse — a later test quietly talks to the real database because an override it assumed was there got removed. Always set overrides in a fixture with teardown.

What to check in your own code

  • grep -rn "yield" across your dependencies: is every yield wrapped in try/finally?
  • grep -rn "create_engine\|AsyncClient(" : does any of it sit inside a function called per request instead of in lifespan?
  • grep -rn "os.environ\|getenv" : scattered reads mean config is not centralised, and the missing-value failure will be a late None.
  • grep -rniE "password|secret|key|://.*:.*@" over the repo. Anything live must be rotated, not just deleted.
  • Search for on_event( — deprecated startup hooks worth migrating to lifespan.
  • Open the generated /docs page and look for endpoints returning user or client data with no auth dependency in their signature.
  • In your test suite, grep -rn "dependency_overrides": every assignment should have a matching clear() in the same fixture.
  • Try starting the app with one required environment variable removed. It should refuse to start with a clear message naming the field.

Sources