Skip to content

AP2 — Dependency injection lifecycle misuse ​

What goes wrong ​

Depends() runs its function on each request. Nothing stops you from putting create_async_engine(...) or httpx.AsyncClient(...) in that function. Each request then builds a new connection pool, opens new connections, and throws the pool away at the end.

For Postgres, the expensive part is the TCP connection and authentication handshake. The benchmark README attributes almost all of the measured overhead to that handshake. Under real concurrency the same churn also strains the database's connection limit.

The bad code ​

The bad endpoint takes two dependencies. Each one builds its own resource on every request. This excerpt is from orders-api-demo/app/routers/demo_ap2.py.

python
async def get_bad_db_session() -> AsyncGenerator[AsyncSession, None]:
    """Anti-pattern: a new engine (and new pooled connection) per request."""
    engine = build_engine(get_settings())
    sessionmaker = build_sessionmaker(engine)
    async with sessionmaker() as session:
        yield session
    await engine.dispose()


async def get_bad_http_client() -> AsyncGenerator[httpx.AsyncClient, None]:
    """Anti-pattern: a new httpx client (new connection pool) per request."""
    async with httpx.AsyncClient(timeout=10.0) as client:
        yield client

The good code ​

The good endpoint reuses the objects created in the lifespan (app/main.py). The sessionmaker and the HTTP client both live on app.state.

python
@router.get("/good")
async def good_dependency_lifecycle(
    session: Annotated[AsyncSession, Depends(get_session)],
    client: Annotated[httpx.AsyncClient, Depends(get_http_client)],
) -> dict[str, str | float]:
python
async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]:
    """The correct, lifespan-scoped way to hand out a DB session (Task 3/AP2 'good')."""
    async with request.app.state.sessionmaker() as session:
        yield session
python
async def get_http_client(request: Request) -> httpx.AsyncClient:
    """The correct way: hand out the lifespan-scoped singleton (AP2 'good')."""
    return request.app.state.http_client

The fix is the pattern from the talk: create once in the lifespan, store on app.state, and hand out through Annotated[T, Depends(...)].

Try it ​

bash
curl localhost:8000/demo/ap2/bad     # {"mode":"bad-new-engine-and-client-per-request","elapsed_seconds":...}
curl localhost:8000/demo/ap2/good
bash
BASE_URL=http://localhost:8000 uv run python scripts/bench_ap2_di.py

Expected, Postgres capture (benchmarks/ap2-di/latency-report.txt): bad mean about 17.48 ms, good mean about 1.73 ms.

Expected, SQLite local (DEMO_GUIDE, not a committed capture): bad about 5 to 6 ms, good about 1.3 ms, roughly 4×. SQLite understates the gap because a new SQLite engine has no network handshake. Say this out loud when you present with SQLite.

Measured evidence ​

17.48 ms
bad, mean
new engine and client per request
1.73 ms
good, mean
lifespan singletons
~10×
ratio
17.48 ÷ 1.73 = 10.1
Postgres capture
environment
n = 50 sequential requests each
Bad
17.48 ms
Good
1.73 ms
10.1× bad ÷ good · mean latency, 50 sequential requests each

Captured output (benchmarks/ap2-di/latency-report.txt):

text
bad  (new engine+client per request): mean=17.48ms p50=16.82ms p95=19.52ms (n=50)
good (shared singletons)             : mean=1.73ms p50=1.59ms p95=2.56ms (n=50)

mean overhead: 15.74ms per request

The automated test tests/test_ap2_di.py asserts that bad stays more than 1.5× slower than good on every run.

How to detect it in your own service ​

CheckHowWhat to look for
Code reviewSearch for create_async_engine, httpx.AsyncClient(, or build_* callsA constructor inside a function used with Depends
Latency benchmarkCompare an endpoint against a lifespan-singleton version, as in scripts/bench_ap2_di.pyA large mean gap for a trivial query
Connection churnWatch connection counts on the database under loadConnections opened and closed per request

Checklist ​

Talking points ​

  • "17.5 milliseconds average, versus 1.7. Ten times, just from rebuilding a connection pool you already had."
  • The overhead is constant per request, and it also destroys connection reuse. Under load you churn sockets and can exhaust the database's max_connections.
  • Optional live run: scripts/bench_ap2_di.py takes about 2 seconds. If time is short, show the captured table.

Released under the MIT License. Speaker: Satyam Soni, PyCon Hong Kong 2026.