Files
responder/tests/test_ws_idle_timeout.py
kennethreitz 3655247072 Add WebSocket idle timeout (API ws_idle_timeout) (#614)
## Summary

Last item from the audit backlog. WebSocket handlers had no way to bound
how long they wait for the next client message, so a stuck or vanished
peer (one that never sends a proper close frame) could hold a connection
open forever. HTTP routes already have `request_timeout`; this brings
parity to WebSockets.

## What it does

`API(ws_idle_timeout=<seconds>)` — plumbed through `Router` into the
ASGI scope, mirroring `request_timeout`. `WebSocketRoute` shadows
`ws.receive` so each awaited receive must resolve within the window:

- The deadline **resets on every inbound message**, so it bounds *idle*
time between messages, not the total connection lifetime — a
continuously active client is never killed.
- On expiry, `asyncio.wait_for` raises `TimeoutError`, caught in
`__call__`, and the connection is closed with **1001 (going away)**.
- Defaults to `None` (unlimited) — existing behavior is unchanged, and
`receive` isn't wrapped at all when unset.

## Tests

New `tests/test_ws_idle_timeout.py`:
- idle connection is closed with code 1001
- an active client (three 0.2s-spaced messages, total 0.6s > the 0.5s
timeout, no single gap over it) stays connected — proving the
per-message reset
- no wrapping / normal behavior when `ws_idle_timeout` is unset

Full suite: 749 passed, 1 skipped (php not installed); ruff clean; mypy
clean under the tightened config from #613.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:03:19 -04:00

75 lines
2.3 KiB
Python

"""WebSocket idle timeout: close a connection that goes quiet too long.
The deadline resets on every inbound message, so it bounds idle time between
messages rather than the total connection lifetime.
"""
import time
import pytest
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import responder
def _client(api):
return TestClient(api, base_url="http://;")
def test_idle_websocket_is_closed_with_1001():
api = responder.API(
allowed_hosts=[";"], session_https_only=False, ws_idle_timeout=0.2
)
@api.route("/ws", websocket=True)
async def ws_handler(ws):
await ws.accept()
# Block waiting for a client message that never arrives.
await ws.receive_text()
with _client(api).websocket_connect("ws://;/ws") as ws:
with pytest.raises(WebSocketDisconnect) as excinfo:
ws.receive_text()
assert excinfo.value.code == 1001
def test_active_websocket_survives_beyond_the_idle_window():
# The deadline is per-message: a client that keeps talking, with no single
# gap exceeding the timeout, stays connected even though the total lifetime
# (0.6s of gaps) exceeds the 0.5s idle timeout.
api = responder.API(
allowed_hosts=[";"], session_https_only=False, ws_idle_timeout=0.5
)
@api.route("/ws", websocket=True)
async def echo(ws):
await ws.accept()
try:
while True:
msg = await ws.receive_text()
await ws.send_text(msg)
except WebSocketDisconnect:
pass
with _client(api).websocket_connect("ws://;/ws") as ws:
for i in range(3):
time.sleep(0.2) # < timeout, so the connection must stay open
ws.send_text(f"ping-{i}")
assert ws.receive_text() == f"ping-{i}"
def test_no_timeout_by_default():
# Without ws_idle_timeout, receive is not wrapped and behaves normally.
api = responder.API(allowed_hosts=[";"], session_https_only=False)
@api.route("/ws", websocket=True)
async def echo(ws):
await ws.accept()
msg = await ws.receive_text()
await ws.send_text(msg)
with _client(api).websocket_connect("ws://;/ws") as ws:
ws.send_text("hi")
assert ws.receive_text() == "hi"