【小记】第一次全栈踩坑 WebSocket 404

背景:人生第一次独立跑通前后端全链路 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 的桥接 IP 172.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 路由默默降级/跳过了 (不报错、不告警,只有当你真正去 inspect app.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,这笔"学费"交得值。

相关推荐
大龄秃头程序员5 小时前
Dart/Flutter StatefulWidget 生命周期 + ChangeNotifier Listener 泄漏的经典坑。
flutter
大龄秃头程序员8 小时前
Flutter踩坑记-WebSocket 通知挤占事件循环
flutter
恋猫de小郭10 小时前
Gradle 9.7.0 将提速 Android 构建,Sync 提升接近一倍
android·前端·flutter
iFlyCai11 小时前
Flutter三棵树核心详解之Widget树完全解析(二)
flutter·statelesswidget
GitLqr1 天前
Flutter 实战:使用 local_auth 实现生物识别(指纹/Face ID)
安全·flutter·全栈
大龄秃头程序员1 天前
【Flutter 性能踩坑小记】相册选个图卡了
flutter
ljt27249606611 天前
Flutter笔记--get_it&injectable
flutter
恋猫de小郭1 天前
Flutter iOS 的深度优化 PR,搞笑的是贡献者被 Gemini 评审折磨
android·前端·flutter
GitLqr2 天前
Flutter + Unity 混合开发:用 unity_kit 实现高效的双向通信
flutter·unity3d·全栈