背景:人生第一次独立跑通前后端全链路 Demo。Flutter 做客户端,FastAPI + uvicorn 做后端。SSE 一切顺利,自信满满切 WebSocket 搞双向通信 ------ 然后在「WS 握手 404」这个坑里踩了整整一下午。
0. 故事的开始:一切都在 TestClient 里完美运行
写完 WS 接口的当天晚上,我兴奋地跑了自己写的 _debug_ws.py 自测脚本:
ini
[3/3] 尝试真正的 WebSocket 握手 + ping-pong + chat:
✅ 握手成功:101 Switching Protocols
✅ ping -> pong
✅ 收到 start 帧
✅ 收到 done 帧
✅ 收到 2 个chunk,总字符数: 166
最终状态 start=True done=True → 🎉 WS链路完全正常!
漂亮。_debug_ws.py 的路由表打印里也明明白白写着:
ini
[APIRoute ] /api/v1/sse/chat methods={'POST'}
[APIWebSocketRoute ] /api/v1/ws/chat/{session_id} methods={'WS'}
路由注册了,自测通过了,发布到 uvicorn,肯定没问题吧?
1. 第一锅:甩给 iOS 模拟器网络隔离
第二天把 Flutter 客户端跑起来,控制台无情打脸:
less
[Infra][WS] ❌ 连接失败: WebSocketException:
Connection to 'http://127.0.0.1:8000/.../demo_session_001#' was not upgraded to websocket,
HTTP status code: 404
第一反应:嗨,这我熟啊!iOS 模拟器的 127.0.0.1 指的是它自己,不是 Mac 宿主机嘛。
于是熟练地:
- 把后端启动参数加了
--host 0.0.0.0 ifconfig bridge100拿到 Mac 的桥接 IP172.20.10.3- 把 Flutter 里的 host 换成真实 IP
再跑一遍,还是 404。
2. 第二锅:甩给 FastAPI 路由注册顺序 + include_router 漏加
这时候我开始怀疑后端了。但毕竟 _debug_ws.py 是通过的,怎么真跑起来就不行?
排查手段升级,Mac 本机 curl 发 WS Upgrade 请求:
bash
curl -sv \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
http://127.0.0.1:8000/api/v1/ws/chat/demo_session_001
结果:本机 curl 127.0.0.1 也 404。
这就把"模拟器网络隔离"的锅彻底甩干净了。接下来的 2 小时我陷入了经典后端排查:
- ❓ 是不是
create_app()工厂函数里漏了include_router(ws_router)? ------ 翻源码确认加了 - ❓ 是不是同路径的 HTTP GET 路由写在了 WS 路由前面,把 Upgrade 请求当 HTTP GET 吞了? ------ 全局搜
@router.get("/chat")没有 - ❓ 是不是多 worker 模式下子进程路由没初始化? ------ 改单 worker 重启,问题依旧
- ❓ 是不是 URL prefix 拼了两次? ------ 没有,
/api/v1 + /ws/chat/{session_id}对的
直到我把 /openapi.json 拉下来一看:
json
"paths": {
"/api/v1/sse/chat": { "post": {...} },
"/health": { "get": {...} }
// ❌ /api/v1/ws/chat/{session_id} 不见了!
}
SSE 在,health 在,唯独 WS 路由从 openapi 里消失了。这时候我还坚定地认为:肯定是 create_app 的 include 顺序问题,或者 APIRouter 实例化对象和 TestClient 用的不是同一个。
3. 真相揭晓:我没装 WebSocket 协议库
为了彻底排除"不是同一个 app 对象"的怀疑,我在 create_app 里加了一行日志:
python
print(f"[启动时] app.routes 列表长度: {len(app.routes)}")
for r in app.routes:
print(f" - {type(r).__name__}: {getattr(r, 'path', 'N/A')}")
uvicorn 重启后,打印结果让我后背一凉:APIWebSocketRoute 真的不在 app.routes 里。
但同样的 create_app(),_debug_ws.py 里 import 之后打 routes 列表,APIWebSocketRoute 明明在啊?!
同一个函数,两种结果?这时候脑子里闪过一个尘封的记忆 ------ FastAPI 注册 WebSocket 路由时,会检查依赖库里有没有 WebSocket 协议实现。没有的话,@app.websocket 装饰器... 就静默跳过了?
我立刻 pip list | grep -i ws:
perl
(venv) % pip list | grep -E 'websockets|wsproto'
# ❌ 空!两个一个都没装!
pip install websockets,重启 uvicorn,再看启动日志:
bash
[启动时] app.routes 列表长度: 8
- APIWebSocketRoute: /api/v1/ws/chat/{session_id}
有了。再 curl 握手:
yaml
< HTTP/1.1 101 Switching Protocols
< upgrade: websocket
< connection: Upgrade
就这么简单。一条 pip install websockets,花了我 3 小时排查。
4. 为什么 TestClient 能通?为什么是静默失败?
事后复盘。Starlette/FastAPI 的 TestClient 内部用的是 httpx 的 ASGI 传输层,它自带了一个内置的 WS 协议实现 ,不依赖系统装没装 websockets/wsproto。所以:
- ✅ 用
TestClient(app).websocket_connect(url)测 → httpx 自己内部搞定协议,路由当然存在 - ❌ 用
uvicorn app:app真跑 → uvicorn 进程检测环境里找不到任何 WebSocket 协议实现,在装饰器阶段把@websocket路由默默降级/跳过了 (不报错、不告警,只有当你真正去 inspectapp.routes才发现没了)
而 SSE 是纯 HTTP/1.1,不存在这个问题。所以 SSE 接口一切正常,造成了"我后端肯定没问题"的假象。
5. 最终避坑 Checklist(写给下一次全栈的自己)
遇到 FastAPI/Starlette 的 WebSocket 404 Not Found,按这个顺序查,10 分钟解决战斗:
| 步骤 | 命令/检查点 | 不通过的处理 |
|---|---|---|
| 1 | `pip list | grep -E 'websockets |
| 2 | 在 create_app 末尾 print([(type(r).__name__, getattr(r,'path','')) for r in app.routes]),有没有 APIWebSocketRoute? |
没有 → 回到步骤 1,或检查 import 的 router 对象是否正确 |
| 3 | Mac 本机 curl -sv -H Upgrade:websocket ... HTTP 码是 101 吗? |
404 → openapi.json 里有没有路由?有 → HTTP 同名路由冲突;没有 → 同步骤 2 |
| 4 | 最后再怀疑客户端:iOS 模拟器换真实 IP、Android 用 10.0.2.2、真机同网段 |
--- |
6. 写在最后
第一次全栈嘛,总会有一两个"我代码写得完美,怎么可能有问题"的执念时刻,最后回头看往往都是最蠢的那类低级错误。
这次最深的体会有两个:
- 自测脚本(TestClient)通过 ≠ 真链路通过。它俩根本就不在一个执行环境里,一个在 ASGI 内存层,一个走真实 TCP + 协议栈。以后写完自测必须加一步「真实 TCP smoke test」。
- 工具链是全栈最容易翻车的地方 。前端的
pub get、后端的pip install、容器的apk add,任何一个看起来"肯定装了"的依赖,没到最后一条命令验证101 Switching Protocols之前,都不能拍胸脯说没问题。
就这样。3 小时换一条 pip install websockets,这笔"学费"交得值。