aio-libs/aiohttp · error · RuntimeError
WebSocket connection is closed.
Error message
WebSocket connection is closed.
What it means
Raised inside receive() once self._conn_lost reaches THRESHOLD_CONNLOST_ACCESS (=5). After the connection is marked closed, each receive() call increments _conn_lost and returns WS_CLOSED_MESSAGE up to 4 times; on the 5th call aiohttp raises to stop a caller from busy-looping receive() on a dead socket.
Solutions
- Break your receive loop when msg.type is WSMsgType.CLOSED, CLOSING, CLOSE, or ERROR.
- Check `ws.closed` before re-entering receive().
- After close, create a new WebSocketResponse for a new connection rather than reusing the closed one.
- Handle WS_CLOSED_MESSAGE explicitly instead of treating it as data.
Example fix
// before
while True:
msg = await ws.receive() # raises after 5th call post-close
process(msg)
// after
while True:
msg = await ws.receive()
if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED, WSMsgType.ERROR):
break
process(msg) Defensive patterns
Strategy: validation
Validate before calling
while not ws.closed:
msg = await ws.receive()
if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED, WSMsgType.ERROR):
break
handle(msg) Type guard
def ws_is_dead(ws: web.WebSocketResponse) -> bool:
return ws.closed # property: self._closed Try / catch
try:
msg = await ws.receive()
except RuntimeError as e:
if 'connection is closed' in str(e):
break # stop looping on the dead socket
raise Prevention
- Break receive loops on CLOSE/CLOSING/CLOSED/ERROR message types.
- Check `ws.closed` before re-entering receive().
- Create a fresh WebSocketResponse for a new connection; never reuse a closed one.
When it happens
Trigger: Application keeps calling `await ws.receive()` (or iterating) after the peer has disconnected and the socket is closed; a loop that ignores WS_CLOSED_MESSAGE / WS_CLOSING_MESSAGE and re-enters receive().
Common situations: A `while True: msg = await ws.receive()` loop with no break on msg.type in (CLOSE, CLOSING, CLOSED, ERROR); a retry loop that swallows the closed sentinel; reconnect logic that reuses the dead WebSocketResponse.
Related errors
- Concurrent call to receive() is not allowed
- Response has not been started
- 1002
- Already started
- Call .prepare() first
AI-assisted analysis of aio-libs/aiohttp@d041d4d0fd (2026-08-11).
Data as JSON: /api/errors/be9174c9b270c96f.
Report an issue: GitHub.
Appendix: source
Thrown at aiohttp/web_ws.py:610
async def receive(
self: "WebSocketResponse[_DecodeText]", timeout: float | None = None
) -> WSMessageDecodeText | WSMessageNoDecodeText: ...
async def receive(
self, timeout: float | None = None
) -> WSMessageDecodeText | WSMessageNoDecodeText:
if self._reader is None:
raise RuntimeError("Call .prepare() first")
receive_timeout = timeout or self._receive_timeout
while True:
if self._waiting:
raise RuntimeError("Concurrent call to receive() is not allowed")
if self._closed:
self._conn_lost += 1
if self._conn_lost >= THRESHOLD_CONNLOST_ACCESS:
raise RuntimeError("WebSocket connection is closed.")
return WS_CLOSED_MESSAGE
elif self._closing:
return WS_CLOSING_MESSAGE
try:
self._waiting = True
try:
if receive_timeout:
# Entering the context manager and creating
# Timeout() object can take almost 50% of the
# run time in this loop so we avoid it if
# there is no read timeout.
async with async_timeout.timeout(receive_timeout):
msg = await self._reader.read()
else:
msg = await self._reader.read()
finally:
self._waiting = FalseView on GitHub (pinned to d041d4d0fd)