Skip to content

[BUG][4.2.0rc1] backends._fastapi: unhandled WebSocketDisconnect cascades into 3-frame ASGI traceback on idle-WS reap #3790

@pip-install-python

Description

@pip-install-python

When a Dash 4.2 WebSocket-served callback handler tries to send a response on a socket the client has already closed (very common for long-idle browser tabs,
mobile background tabs, or proxy/edge reaps), dash.backends._fastapi doesn't catch starlette.websockets.WebSocketDisconnect around websocket.send_json(...).
The exception escapes the task, the next iteration of the receive loop then calls websocket.receive_json() on the already-closed socket, and a second handler
re-tries send_json after Starlette has emitted its close frame — producing three back-to-back tracebacks per disconnected client and an ERROR: Exception in
ASGI application from uvicorn. The client experience is unaffected (it's already gone), but each stale-WS reap costs ~50 log lines and surfaces as an ASGI
error in observability tooling.

Environment

  • dash==4.2.0rc1 (backend="fastapi", websocket_callbacks=True)
  • Python 3.12
  • starlette (current FastAPI dep), uvicorn (websockets impl), websockets package
  • Render.com web service behind their edge proxy (i.e. proxy may reap long-idle sockets)
  • Optional but present in the stack: dash-clerk-auth ASGI middleware (not the source of the bug — exception originates inside dash.backends._fastapi; included
    only because it appears in the traceback)

Observed behavior

After a browser tab sat idle on a Dash page for ~7 hours, the WebSocket dropped (proxy reap) and the server logged:

future: <Task finished name='Task-2146'
coro=<FastAPIDashServer.serve_websocket_callback..websocket_handler..execute_callback_task() done,
defined at /usr/local/lib/python3.12/site-packages/dash/backends/_fastapi.py:727>
exception=WebSocketDisconnect()>
Traceback (most recent call last):
File ".../uvicorn/protocols/websockets/websockets_sansio_impl.py", line 433, in send
self.conn.send_text(text_data.encode())
File ".../websockets/protocol.py", line 337, in send_text
raise InvalidState(f"connection is {self.state.name.lower()}")
websockets.exceptions.InvalidState: connection is closing

During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File ".../starlette/websockets.py", line 86, in send
await self._send(message)
...
File ".../uvicorn/protocols/websockets/websockets_sansio_impl.py", line 452, in send
raise ClientDisconnected()
uvicorn.protocols.utils.ClientDisconnected

During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File ".../dash/backends/_fastapi.py", line 735, in execute_callback_task
await websocket.send_json(
File ".../starlette/websockets.py", line 176, in send_json
await self.send({"type": "websocket.send", "text": text})
File ".../starlette/websockets.py", line 89, in send
raise WebSocketDisconnect(code=1006)
starlette.websockets.WebSocketDisconnect

Immediately after, a sibling task hits the same dead socket on the retry path:

2026-05-20 03:17:05,883 ERROR Task exception was never retrieved
future: <Task finished name='Task-2147' ... exception=RuntimeError('Cannot call "send" once a close message has been sent.')>
Traceback (most recent call last):
File ".../dash/backends/_fastapi.py", line 735, in execute_callback_task
await websocket.send_json(
File ".../starlette/websockets.py", line 98, in send
raise RuntimeError('Cannot call "send" once a close message has been sent.')
RuntimeError: Cannot call "send" once a close message has been sent.

And finally the receive loop in the handler itself tries to read from the now-fully-closed socket:

ERROR: Exception in ASGI application
Traceback (most recent call last):
...
File ".../dash/backends/_fastapi.py", line 748, in websocket_handler
message = await websocket.receive_json()
File ".../starlette/websockets.py", line 134, in receive_json
raise RuntimeError('WebSocket is not connected. Need to call "accept" first.')
RuntimeError: WebSocket is not connected. Need to call "accept" first.

The full ASGI middleware chain (FastAPI → custom middleware → auth._asgi → SessionMiddleware → dash.backends._fastapi.call → starlette router →
dash.backends._fastapi.websocket_handler) propagates the final RuntimeError all the way to uvicorn, which logs it as ERROR: Exception in ASGI application.

Triggering scenario (deterministic)

  1. Client opens a Dash 4.2 page with backend="fastapi" and websocket_callbacks=True.
  2. The WebSocket completes the initial dependency exchange and goes idle.
  3. The client disappears in a way that the server learns about between the dispatch of a callback and its response — most reliably reproduced by a proxy/edge
    reap on a long-idle socket, but also by simply closing the tab while a callback is in flight, or by mobile background-tab kill.
  4. The server tries to deliver the callback response → cascade begins.

Root cause analysis

In dash/backends/_fastapi.py (rc1 line numbers — please confirm against current HEAD):

  • Line 735 (execute_callback_task): await websocket.send_json(...) is not wrapped in a handler for starlette.websockets.WebSocketDisconnect or
    uvicorn.protocols.utils.ClientDisconnected. When the underlying socket is in closing/closed, starlette.websockets.WebSocket.send raises
    WebSocketDisconnect(code=1006); the task exits with exception=WebSocketDisconnect() and is never retrieved, so Python emits the "Task exception was never
    retrieved" warning and the framework keeps trying.
  • Line 735 (a second task): a second concurrent execute_callback_task invocation hits send_json after starlette has already emitted its close frame for the
    same socket, so it raises RuntimeError('Cannot call "send" once a close message has been sent.').
  • Line 748 (websocket_handler): the outer receive loop calls await websocket.receive_json() without first checking websocket.client_state /
    websocket.application_state; on the now-closed socket starlette raises RuntimeError('WebSocket is not connected. Need to call "accept" first.'), which escapes
    to uvicorn and surfaces as ERROR: Exception in ASGI application.

Net: a single client disconnect produces (1) an unhandled task exception, (2) a "Cannot send after close" follow-up, (3) an unhandled receive on a closed
socket — each with a full multi-frame traceback.

Suggested fix

Conceptually, every send_json/receive_json on the WS in _fastapi.py should treat WebSocketDisconnect and RuntimeError-on-closed-socket as a clean terminal
condition:

dash/backends/_fastapi.py — around the existing send/receive sites

from starlette.websockets import WebSocketDisconnect, WebSocketState

_DISCONNECT_EXC = (WebSocketDisconnect,)

line ~735 in execute_callback_task

try:
if websocket.application_state == WebSocketState.CONNECTED:
await websocket.send_json(payload)
else:
logger.debug("WS callback response dropped: socket already closed")
except _DISCONNECT_EXC:
logger.debug("WS callback response: client disconnected mid-send")
except RuntimeError as exc:
# 'Cannot call "send" once a close message has been sent.' /
# 'WebSocket is not connected. ...' — both reduce to "client gone".
if "close" in str(exc).lower() or "not connected" in str(exc).lower():
logger.debug("WS callback response: %s", exc)
else:
raise

line ~748 in websocket_handler

while True:
if websocket.application_state != WebSocketState.CONNECTED:
break
try:
message = await websocket.receive_json()
except _DISCONNECT_EXC:
logger.debug("WS callback receive: client disconnected")
break
except RuntimeError as exc:
if "not connected" in str(exc).lower():
break
raise
...

The key invariants:

  1. Never call send_json without checking application_state == CONNECTED first, or alternatively, always wrap it in try/except WebSocketDisconnect. Currently
    neither is true at line 735.
  2. Catch WebSocketDisconnect in the receive loop, and exit the loop cleanly. The current receive_json at line 748 is bare.
  3. Treat RuntimeError messages mentioning "close" or "not connected" as the same terminal condition — starlette uses RuntimeError (not WebSocketDisconnect) for
    the "send after close-frame-sent" and "receive before accept" cases, which is a starlette API quirk worth absorbing here rather than asking every framework
    user to.

Impact

  • No user-visible regression (the client is already gone in every case observed).
  • Significant log/observability noise: each stale-WS reap produces ~50 lines of traceback across three tasks, surfaces as ERROR: Exception in ASGI application
    from uvicorn, and triggers Python's "Task exception was never retrieved" warning. At scale this will dominate error dashboards for any deployment behind a
    reaping proxy (Render, Heroku, Cloud Run, Cloudflare, etc.) or with mobile clients.
  • Mitigation in user code is awkward: a wrapping ASGI middleware can swallow the exceptions, but only by also masking real RuntimeErrors in handlers — I thought the
    right fix belongs inside dash.backends._fastapi.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions