Fixing CancelledError When FastAPI SSE Clients Disconnect
Encountered this while building an AI customer-service automation system for a client β recording the root cause and fix.
TL;DRβ
FastAPI's StreamingResponse cancels the generator task when the client disconnects, raising asyncio.CancelledError. The correct fix is to catch the exception inside the generator and re-raise β otherwise you get error-log noise and resource leaks.
The symptomβ
When streaming a conversation over SSE (Server-Sent Events), the server log fills with exceptions after the client disconnects:
ERROR: Exception in ASGI application
...
asyncio.CancelledError
The original code:
async def event_stream():
async for event in engine.execute(body.message):
yield event
return StreamingResponse(event_stream(), media_type="text/event-stream")
Root causeβ
FastAPI/Starlette's StreamingResponse cancels the running generator task when the client disconnects. A cancelled async for loop raises asyncio.CancelledError.
Unhandled, the exception propagates up and the ASGI server logs it as an error. Worse, resources held inside the generator (database connections, HTTP clients) may never be released properly.
The fixβ
Catch CancelledError inside the generator, log it, and always re-raise:
import asyncio
import logging
logger = logging.getLogger(__name__)
async def event_stream():
try:
async for event in engine.execute(body.message):
yield event
except asyncio.CancelledError:
# Client disconnected β expected behavior
logger.info("Client disconnected")
raise # must re-raise to terminate the generator correctly
return StreamingResponse(event_stream(), media_type="text/event-stream")
Why re-raise is mandatoryβ
CancelledError is Python's standard mechanism for cancelling coroutines. Catching it without re-raising:
- leaves the generator unterminated,
- makes
StreamingResponsebelieve the response completed normally, and - can leak resources.
FAQβ
Why does FastAPI SSE raise CancelledError after a client disconnects?β
That is asyncio working as designed. On disconnect, Starlette cancels the generator task, triggering CancelledError. The correct handling is to catch it and re-raise.
What happens if I catch CancelledError without re-raising?β
The generator cannot terminate properly, leaking resources such as database connections and HTTP clients. StreamingResponse also mistakes the response for a normal completion.
How do I distinguish a normal disconnect from an abnormal one?β
CancelledError itself is the normal-disconnect signal. If you need cleanup on disconnect (e.g. updating state), handle it in the except block, then re-raise.