_WSInterceptMiddleware intercepts /ws WebSocket scopes at the outermost
ASGI layer before Starlette routing is consulted, so Mount('/') can never
hand a WS connection to _SPAStaticFiles regardless of route ordering.
Also downgrade the _SPAStaticFiles non-HTTP fallback log from WARNING to
DEBUG — the graceful close still fires as a safety net, but no longer
spams the log since the middleware handles the normal case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In Starlette 0.52+, Mount('/') returns Match.FULL for every WebSocket
scope. If APIWebSocketRoute('/ws') is somehow not matched first, the
StaticFiles mount catches the connection and crashes with:
assert scope["type"] == "http" # AssertionError
Two-layer fix:
- _SPAStaticFiles.__call__: gracefully close non-HTTP connections with
WebSocketClose() and log a warning with the actual path/type so the
routing issue can be diagnosed.
- app.add_websocket_route('/ws', websocket_endpoint): belt-and-suspenders
registration using Starlette's base WebSocketRoute (simpler than
FastAPI's APIWebSocketRoute) right before the StaticFiles mount. If
include_router's APIWebSocketRoute doesn't match, this fallback will.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>