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:
Khoa (Revenovich) Tran Gia
2026-03-03 10:56:41 +07:00
parent f6907d2c39
commit 1c852f26f8

View File

@@ -81,6 +81,24 @@ class _SPAStaticFiles(StaticFiles):
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):
try:
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(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)
if _WEB_STATIC.exists() and any(_WEB_STATIC.iterdir()):
app.mount("/", _SPAStaticFiles(directory=str(_WEB_STATIC), html=True), name="static")