TL;DR

lark-oapi1.5.5 升到 1.6.2 后,部署在内网(必须走 HTTP 代理出公网)的飞书机器人 WebSocket 长连接不通了。原因不在 websockets 换底层、也不在 HTTP_PROXY 环境变量,而是 SDK 源码里显式写了 proxy=None

# lark_oapi/ws/client.py (1.6.2)
def _ws_connect_kwargs():
    params = inspect.signature(websockets.connect).parameters
    if "proxy" in params:
        return {"proxy": None}   # ← 主动关掉 websockets 15 的自动代理探测
    return {}

修复方式两选一:

  • 稳妥:钉 lark-oapi==1.5.5
  • 临时升级:monkey-patch 把 _ws_connect_kwargs 换成返回 {},让 websockets 15 的默认行为接管(会自动读 https_proxy

下面是完整过程,给后面踩同样坑的人作参考。

背景

项目 vibedp 是一个 YAML 驱动的 AI Agent 平台,部署在携程内网。机器人走飞书 WebSocket 长连接(lark-oapi SDK),通过公司 HTTP 代理 proxy.corp.com:8080 出公网。

某天升级后上线失败,现象是 Lark 模式起不来,连不通 open.feishu.cn。降级回上个版本又能用。

排查过程

第 1 步:怀疑最近的重构

最近做过一次 refactor/restructure-architecture,拆了模块、挪了文件。直觉上像是重构把 Lark 链路搞坏了。

对比了 f1e668...HEAD 这段时间 vibedp/integrations/lark_integration.py 的 diff——几乎没动,只是 make format 把两行拼接的字符串合并成了一行。entry.py 里 Lark 启动序列也和重构前一致。

重构这条线排除。

第 2 步:检查依赖

uv.lock 里写的是 lark-oapi 1.5.3,但用户报告实际装的是 1.6.2

发现部署链路是:

uv pip install vip

pyproject.tomllark-oapi 没有版本约束

dependencies = [
    "lark-oapi",   # ← 任何版本都行
    ...
]

第一个结论:依赖漂移。Pin 住版本:

"lark-oapi==1.5.5",

生效,问题消失。但不知道 1.6.2 到底改了什么导致代理失效。

第 3 步:对比 1.5.5 → 1.6.2 的 commit

GitHub compare API 拉下来,5 个 commit。前 4 个都是新加的 channel/ 高层能力层(FeishuChannel 那套),跟底层 WS 无关。

最后一个引人注目:

ae7e84e  fix: support websockets 15

这就是嫌疑人。但还不知道具体怎么坏的。

第 4 步:第一个错误假设

先自己推理:websockets 15 换了 IO 栈,不再通过 aiohttp,所以 trust_env=True 读环境变量代理的能力没了。

websockets 官方文档打脸:14.2 开始原生支持环境变量代理,查找顺序:

  1. no_proxy → 命中直连
  2. wss_proxy / ws_proxy(WebSocket 专用,优先级最高)
  3. 系统 SOCKS 代理
  4. https_proxywss://ws:// 都走)
  5. http_proxy(只给 ws://

底层用 urllib.request.getproxies() 读。

第 5 步:验证环境

让用户在容器里跑:

$ python -c "import websockets; print(websockets.__version__)"
15.0.1

$ python -c "from urllib.request import getproxies; print(getproxies())"
{}

这里一度以为找到答案了——getproxies() 返回空,说明进程环境里根本没代理变量。但用户接着查了 supervisor 启动的 vibedp 进程的 /proc/<pid>/environ

https_proxy=http://proxy.corp.com:8080
http_proxy=http://proxy.corp.com:8080

(之前的 python -cdocker exec 进去的 shell,跟 supervisor 启的进程不是同一个环境。)

也就是说 supervisor 启的 vibedp 进程里代理变量是对的,但 websockets 15 就是没走代理。

第 6 步:直接看源码

走到这一步应该一开始就做。拉 1.6.2 的 lark_oapi/ws/client.py

def _ws_connect_kwargs():
    params = inspect.signature(websockets.connect).parameters
    if "proxy" in params:
        # websockets 15 enables environment proxy discovery by default. The SDK
        # historically connected directly, so preserve that behavior when the
        # parameter exists.
        return {"proxy": None}
    return {}

然后在 _connect 里:

conn = await websockets.connect(conn_url, **_ws_connect_kwargs())

水落石出。

根因

lark-oapi 1.6.2 升级到兼容 websockets 15 的时候,维护者发现 websockets 15 默认会读系统代理配置。他们担心这改变 SDK 历史行为(之前的 websockets 13/14 不支持 proxy= 参数,相当于总是直连),于是主动传 proxy=None 关掉了自动探测

注释原话:preserve that behavior when the parameter exists

这个决策对”在公网直连飞书”的用户是正确的(没变化),但对必须走代理的内网部署用户是破坏性的——之前版本能通,是因为 websockets 13/14 的默认行为恰好能读到代理环境变量;升到 15 之后这条路被 proxy=None 堵死了。

用户环境里 https_proxy 配了也没用,因为 websockets.connect(url, proxy=None) 优先级高于环境变量。

为什么 1.5.5 能连

对照 1.5.5 的同一处代码:

# lark_oapi/ws/client.py (1.5.5)
conn = await websockets.connect(conn_url)

没有 proxy= 参数。当时装的 websockets 是 13 或 14 早期版本,connect() 签名里没有 proxy 参数,走默认行为。具体怎么读到代理的不重要——重点是没有显式禁用

修复方案

方案 A:钉版本(推荐)

# pyproject.toml
dependencies = [
    "lark-oapi==1.5.5",
    ...
]

方案 B:Monkey-patch(想用 1.6.2)

entry.py 顶部(或任何应用代码的入口):

import lark_oapi.ws.client as _ws_client

def _enable_proxy_discovery():
    """让 websockets 15 的环境变量代理探测重新生效。"""
    return {}

_ws_client._ws_connect_kwargs = _enable_proxy_discovery

这样 websockets.connect(url) 不再收到 proxy=None,会按默认规则读 https_proxy 环境变量。

方案 C:给上游提 issue/PR

lark_oapi.ws.Client.__init__ 应该暴露 proxy 参数,或者至少在 https_proxy 存在时不强制 proxy=None。内网部署是很常见的企业场景,值得上游修。

教训

几个可以做得更好的地方:

  1. Pin 依赖版本"lark-oapi" 这种无约束的写法在协作项目里是定时炸弹。PyPI 随便一个小版本上游发布就可能改变你的行为。
  2. 先看源码,再推理行为。这次排查我至少绕了两圈:先推理 websockets 换了 IO 栈(错),再推理 getproxies() 读不到代理(部分对但不是主因)。如果第一步就去看 lark_oapi.ws.client.py 的 diff,十分钟就能看出 proxy=None 那行。
  3. 注释里”保持历史行为”的决策值得警惕。升级传递依赖时,”不破坏现有用户”听起来是好话,但对不同使用场景可能意义相反。这次 proxy=None 对公网用户是零变更,对内网代理用户是完全不能连。

附:完整时间线

步骤 动作 结论
1 对比重构前后 diff Lark 链路代码几乎没变,不是重构问题
2 查实际装的版本 pyproject.toml 无约束 + CI 不走 lock → 装了 1.6.2
3 对比 1.5.5→1.6.2 的 commit 嫌疑在 fix: support websockets 15
4 推理 websockets 15 不读代理 错,实际支持
5 验证环境变量 supervisor 注入的变量是对的
6 看 SDK 源码 发现 proxy=None 硬编码

如果你也在用 lark-oapi + 内网代理,这个坑还在最新版本里(写这篇文章时)。升级前留意一下。