HTTP Load & Background Tasks¶
Limit simultaneous requests¶
Use admission control to give excess HTTP traffic a short queue deadline before it enters a route or starts a database transaction:
app.add_middleware(
SQLAlchemyMiddleware,
db_url=DB_URL,
engine_args={"pool_size": 20, "max_overflow": 50, "pool_timeout": 5},
max_concurrent_requests=50,
request_queue_timeout=0.1,
exclude_paths={"/health", "/health/live"},
pool_warn_threshold=0.8,
)
The values above are illustrative. Choose the request limit using the number of connections used per request and capacity reserved for other work. This limit belongs to one middleware instance in one process; each server worker has its own limiter and engine. It is not a cluster-wide connection limit.
When no permit becomes available within request_queue_timeout, the middleware
returns HTTP 503 with Retry-After: 1, without calling the route. It does
not retry requests or transactions. Set max_concurrent_requests=None (the
default) to disable admission control. Queue time must be finite and positive.
Waiting requests can still accumulate during that deadline; this is not a hard
limit on queue length or memory usage.
All non-excluded HTTP paths count towards the limit, even if they do not query
the database. exclude_paths uses exact ASGI path matching, bypasses admission
control and creates no request session. Exclusion does not provide database
health checking: use a separate readiness engine, and keep liveness independent
of the database. See Health Checks.
The permit is released when the final body is sent, or when request handling fails or is cancelled. Streaming responses hold a permit until their body ends. Background tasks are outside the HTTP limit and need their own concurrency controls if they perform substantial database work.
db(multi_sessions=True, max_concurrent=N) remains a separate limit on work
within one explicit context. It does not limit other requests. If a request
opens several database sessions, a request limit alone cannot guarantee that
the pool will never fill. Checkout deadlines and service-level batching are
still useful.
Request sessions end before background tasks¶
For a normal response, the middleware finalizes the request transaction when the complete response body is ready, before sending the buffered response and before Starlette runs background tasks. This returns the connection to the pool even when a background task is slow. A commit failure still prevents a successful response from being sent.
Background tasks must create their own session and transaction:
async def record_audit(item_id: int):
async with db(commit_on_exit=True):
db.session.add(AuditRecord(item_id=item_id))
@app.post("/items")
async def create_item(background_tasks: BackgroundTasks):
item = Item()
db.session.add(item)
await db.session.flush()
background_tasks.add_task(record_audit, item.id)
return {"id": item.id}
This example assumes middleware commit_on_exit=True. Pass scalar identifiers
to background tasks rather than request sessions or ORM objects attached to them.
Accessing the finalized request session through db.session raises an error
explaining that an explicit async with db() is required.
@app.middleware("http") below this middleware breaks the ordering
BaseHTTPMiddleware runs the application in a child task and hands
response messages to a queue, so that task reaches self.background() as
soon as the message is queued — while the outer middleware is still
awaiting the commit. Background tasks then run concurrently with
finalization, and a task that deletes the file a FileResponse just served
can win the race. This affects any outer middleware, not just this one; the
only way to rule it out is to write that middleware as pure ASGI instead of
@app.middleware("http"). A failing commit still prevents a successful
response either way, because the response start stays buffered.
Migration: background code that previously relied on the request session must open its own context. Errors after the response has been sent cannot roll back the committed request transaction or change its HTTP status. A failed background transaction rolls back only its own work. Do not move essential atomic request writes into a background task.
yield dependencies that own a transaction¶
FastAPI runs yield dependency teardown after the response is sent — the same
point at which the request session is finalized. A dependency that owns its
transaction explicitly is therefore exempt from the early finalization:
async def transaction():
async with db.session.begin():
yield
@app.post("/entries", dependencies=[Depends(transaction)])
async def create_entry():
await db.session.execute(insert(Entry).values(value=1))
return {"ok": True}
Here the dependency, not the middleware, decides when the transaction commits,
so the session stays open until the teardown finishes and its connection is
returned then. The same applies to a begin_nested() savepoint held across the
response.
The exemption is deliberately narrow — it covers only transactions your code
began with db.session.begin() or db.session.begin_nested(). A transaction
SQLAlchemy autobegan on the first db.session.execute(...) has no such owner
and is still finalized early, which is what keeps a slow background task from
pinning the request connection.
A transaction-owning dependency holds its connection longer
For the duration of the teardown the connection is still checked out, so a
dependency of this shape and a slow background task on the same route give
up the early release. Under pool pressure, prefer
commit_on_exit=True plus autobegin, and keep explicit transactions for
routes that genuinely need to control the commit boundary.
Migration: no change is needed for yield dependencies that wrap
db.session.begin() — they behave as they did before the early finalization was
introduced. A dependency whose teardown touches db.session without owning a
transaction will see the "closed after response finalization" error and must
open its own async with db() context.