fix: prevent WebSocket AssertionError when StaticFiles catch-all intercepts WS
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>
This commit is contained in:
28
web/app.py
28
web/app.py
@@ -81,6 +81,24 @@ class _SPAStaticFiles(StaticFiles):
|
|||||||
matching file, so client-side routes like /generate work on refresh.
|
matching file, so client-side routes like /generate work on refresh.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send) -> None:
|
||||||
|
# StaticFiles only handles HTTP. In Starlette 0.52+, Mount('/') returns
|
||||||
|
# Match.FULL for ALL WebSocket scopes, so a WebSocket connection can
|
||||||
|
# reach here if the dedicated /ws route somehow doesn't match first.
|
||||||
|
# Close gracefully instead of asserting.
|
||||||
|
if scope.get("type") != "http":
|
||||||
|
from starlette.websockets import WebSocketClose
|
||||||
|
logger.warning(
|
||||||
|
"WebSocket or non-HTTP scope reached StaticFiles "
|
||||||
|
"(path=%r, type=%r) — closing gracefully. "
|
||||||
|
"This indicates a routing issue; /ws route did not match.",
|
||||||
|
scope.get("path"),
|
||||||
|
scope.get("type"),
|
||||||
|
)
|
||||||
|
await WebSocketClose()(scope, receive, send)
|
||||||
|
return
|
||||||
|
await super().__call__(scope, receive, send)
|
||||||
|
|
||||||
async def get_response(self, path: str, scope):
|
async def get_response(self, path: str, scope):
|
||||||
try:
|
try:
|
||||||
return await super().get_response(path, scope)
|
return await super().get_response(path, scope)
|
||||||
@@ -152,6 +170,16 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(workflow_router, prefix="/api/workflow", tags=["workflow"])
|
app.include_router(workflow_router, prefix="/api/workflow", tags=["workflow"])
|
||||||
app.include_router(ws_router, tags=["ws"])
|
app.include_router(ws_router, tags=["ws"])
|
||||||
|
|
||||||
|
# Belt-and-suspenders: register /ws directly on the app so it sits at the
|
||||||
|
# top of the route list and is guaranteed to be checked before the
|
||||||
|
# StaticFiles catch-all mount below. In Starlette 0.52+, Mount('/')
|
||||||
|
# returns Match.FULL for every WebSocket scope, which means if the route
|
||||||
|
# from include_router is somehow not matched first, the mount wins and
|
||||||
|
# StaticFiles crashes with AssertionError. Having the route registered
|
||||||
|
# twice is harmless — the first Match.FULL in the route list wins.
|
||||||
|
from web.routers.ws_router import websocket_endpoint as _ws_endpoint
|
||||||
|
app.add_websocket_route("/ws", _ws_endpoint)
|
||||||
|
|
||||||
# Serve frontend static files (if built)
|
# Serve frontend static files (if built)
|
||||||
if _WEB_STATIC.exists() and any(_WEB_STATIC.iterdir()):
|
if _WEB_STATIC.exists() and any(_WEB_STATIC.iterdir()):
|
||||||
app.mount("/", _SPAStaticFiles(directory=str(_WEB_STATIC), html=True), name="static")
|
app.mount("/", _SPAStaticFiles(directory=str(_WEB_STATIC), html=True), name="static")
|
||||||
|
|||||||
Reference in New Issue
Block a user