不修改nginx接收websocket

以前只记得要支持websocket 需要 nginx 改:

  location /ws-service-a {
      proxy_pass http://backend_a;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
  }

今天折腾忘记这茬了,一直在调 FastAPI ,@app.websocket() 死活不识别 wscat 的请求

WebSocket 协议的工作方式是:

1.客户端发 GET + Upgrade: websocket 头
2.服务端识别这个特殊 GET,返回 101 Switching Protocols
3.连接升级完成

opus-4.6 说:既然路由确实注册了,那问题几乎可以确定是:前面的反向代理/LB 没有透传 WebSocket

我就来劲了,让AI写一份 特殊 GET,返回 101 Switching Protocols 。

结果还真成功了。wscat 返回 error: Invalid Sec-WebSocket-Accept header

折腾了几下,发现此路不通

问题不是 @app.websocket() 没挂载,它挂载了,但永远匹配不到。原因是 uvicorn 的路由分发发生在更底层:

请求到达 uvicorn

uvicorn 检查 headers:有 Upgrade: websocket?
├── 是 → scope.type = "websocket" → 只匹配 @app.websocket() 路由
└── 否 → scope.type = "http" → 只匹配 @app.get/post() 路由
你的 nginx 把 Upgrade 头吃了,所以 uvicorn 永远把请求标记为 http scope。在 http scope 下,@app.websocket(...) 路由根本不参与匹配 — 就像它不存在一样。

这就是为什么:

@app.get('/ws/test') 能匹配 — 它是 HTTP 路由,匹配 HTTP scope
@app.websocket('/ws/real') 匹配不到 — 它是 WebSocket 路由,只在 websocket scope 下才可见
两个都注册成功了,但 scope 类型不对,uvicorn 在路由匹配之前就把它们分到了不同的赛道。

接下来几轮错误复制粘贴大法:

后来发现ASGI 这一层做不到。因为 标准 ASGI middleware 拿到请求时已经太晚了:

uvicorn TCP 收包
1. httptools 解析 headers
2. on_headers_complete 决定 scope type (http vs websocket) ← 需要在这里拦截
3. 创建 scope + receive/send(已经绑定为 HTTP 协议)
4. 调用 ASGI app (middleware 链) ← middleware 才在这里介入

middleware 只能看到已经定型的 scope['type'] = 'http',改不了底层的 receive/send 绑定。

然后尝试了一个 gunicorn.conf.py 的hack:

def post_worker_init(worker):
    """让 uvicorn 从 sec-websocket-key 识别 WebSocket,绕过 nginx 吞 Upgrade 头的问题"""
    import httptools
    from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
    _orig = HttpToolsProtocol.on_headers_complete
    def _patched(self):
        has_ws_key = any(n == b"sec-websocket-key" for n, _ in self.headers)
        if has_ws_key and self._should_upgrade_to_ws():
            self.headers.append((b"upgrade", b"websocket"))
            self.headers.append((b"connection", b"Upgrade"))
            self.scope["headers"] = self.headers
            self.scope["method"] = self.parser.get_method().decode("ascii")
            raise httptools.HttpParserUpgrade(b"")
        return _orig(self)
    HttpToolsProtocol.on_headers_complete = _patched

我也觉得,ws这协议是不是有病。如果有 sec-websocket-key 就认定为 ws 不就完了。搞那么复杂。

然后这个办法在 ASGI 里还是行不通。最终版:直接篡改 uvicorn 收到的 raw TCP 字节

def post_worker_init(worker):
    import re
    from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol

    _orig_data_received = HttpToolsProtocol.data_received

    def _patched_data_received(self, data):
        if not getattr(self, '_ws_patched', False) and b'\r\n\r\n' in data:
            self._ws_patched = True
            lower = data.lower()
            if b'sec-websocket-key:' in lower and b'\nupgrade:' not in lower:
                data = re.sub(rb'(?i)\r\nconnection:[^\r]*', b'\r\nConnection: Upgrade', data)
                data = data.replace(b'\r\n\r\n', b'\r\nUpgrade: websocket\r\n\r\n', 1)
        return _orig_data_received(self, data)

    HttpToolsProtocol.data_received = _patched_data_received

居然成功了!不修改nginx兼容websocket!

这路子太野了。还是老老实实去改nginx了。

不过也学到一些姿势,比如 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ,以及ws居然是二进制流。

Comments