mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-21 18:39:29 +00:00
3655247072
## 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>
75 lines
2.3 KiB
Python
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"
|