
The WebSocket that never shook hands — and the 5 endpoints that went silent
The handshake that never happened
It was the kind of bug that doesn’t make noise. Dogwalk ran, the database responded, the deploy was green. But the notifications WebSocket — and all the other 4 — returned HTTP 500 on handshake.
Real-time notifications, chat with the walker, live walk tracking, status updates: all silent. Nobody shouted, because the error appeared as a 500 in the browser console and everyone moved on.
The context: WebSocket in Starlette has a handshake protocol
In Starlette (the heart of FastAPI), a WebSocket isn’t just “open and talk”. There’s a mandatory order:
# What the code did (implicitly)
async def await_ws_auth(websocket: WebSocket) -> Optional[dict]:
raw = await websocket.receive_text() # ← LISTENS BEFORE ACCEPTING
...
Starlette requires accept() before receive() or send(). It’s the handshake: first you accept the connection, then you talk. Calling receive_text() without accepting is like answering the phone and talking without saying “hello”.
# What Starlette requires
async def await_ws_auth(websocket: WebSocket) -> Optional[dict]:
await websocket.accept() # ← HELLO
raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
...
The struggle: 5 affected endpoints, one pattern
The worst part: the bug wasn’t in 1 place. It was spread across 5 routes sharing the same await_ws_auth() helper:
| Endpoint | Function |
|---|---|
/ws/notifications |
Real-time notifications |
/ws/walk/{id} |
Live walk updates |
/ws/chat/{id} |
Chat with the walker |
/walks/{id}/status |
Status changes |
/ws/walk-now/{id} |
Walk tracking |
All called await_ws_auth() at the start. One helper failure = 5 silent endpoints.
The handshake HTTP 500 wasn’t obvious in the health test — because /health is plain HTTP. The WebSocket only showed up when the browser tried to connect, and then… silence.
The resolution: one line that unlocked everything
# backend/app/routers/ws.py
async def await_ws_auth(websocket: WebSocket) -> Optional[dict]:
# FIX 03/08/2026: Starlette requires accept() BEFORE receive_text/send_json.
await websocket.accept()
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
...
One line: await websocket.accept() at the start. All 5 endpoints came back.
Real verification: 6/6 WebSockets via tunnel OK + all 5 endpoints OK + pytest 32/32.
Metrics
| Metric | Value |
|---|---|
| Affected endpoints | 5 |
| Fix lines | 1 (await websocket.accept()) |
| Tests after fix | 32/32 pytest |
| WebSockets via tunnel | 6/6 OK |
| Verified endpoints | 5/5 |
Lessons learned
-
WebSocket has a handshake protocol — Starlette requires
accept()beforereceive()/send(). It’s the phone’s “hello”. Without it, handshake becomes a silent HTTP 500. -
Shared helper bug = N broken endpoints —
await_ws_auth()was used by 5 routes. An error in a shared helper doesn’t break 1 feature, it breaks the whole domain. Audit helpers used by multiple endpoints when something “won’t connect”. -
HTTP health check doesn’t catch WebSocket —
/health(plain HTTP) passed. The WS only failed in the browser. Health checks need to include WS handshakes if the app depends on real-time. -
Silent bugs are the most dangerous — HTTP 500 in the browser console doesn’t trigger alerts. The system “works” (pages load) but critical features are mute.