Skip to main content

Fixing CancelledError When FastAPI SSE Clients Disconnect

Β· 2 min read

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:

  1. leaves the generator unterminated,
  2. makes StreamingResponse believe the response completed normally, and
  3. 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.