OpenClaw 飞书插件API告急:用缓存把 /bot/v3/info 降到可控频率
今天OpenClaw 的飞书通道api 50000次都用完了,尴尬。在长连接不断线的情况下,由于飞书插件高频调用飞书 OpenAPI(GET /open-apis/bot/v3/info),导致月度基础额度很快耗尽,每分钟一次,最终出现 99991403 This month's API call quota has been exceeded。
这篇文章把今天的AI调试过程、关键误区、以及最终的“缓存+降频”方案完整记录下来,方便后续调试复用。
背景与现象
现象很清晰:飞书开放平台的调用明细里,/open-apis/bot/v3/info 出现固定周期请求(早期接近每分钟一次),并且夹杂着 POST /open-apis/auth/v3/tenant_access_token/internal 的取 token 请求。
一开始我们怀疑是“长连接频繁握手/重连”,但你确认飞书插件并没有断连,这就把排查方向从网络层拉回到:插件内部的健康检查 / 探针(probe)逻辑是否在定时调用 API。[github]
排查思路
排查按三个问题推进:
-
Q1:究竟是谁触发了
/bot/v3/info?结论:它不是业务消息触发,而是“探针”触发,优先在插件里搜索"/open-apis/bot/v3/info"或probeFeishu来定位。 -
Q2:探针为什么会这么频繁?结论:OpenClaw Gateway 会定期做 Health Checks(健康检查),并触发每个 channel 的
probeAccount,如果 probe 内部每次都真实请求 OpenAPI,就会被“健康检查周期”放大成稳定的周期调用。[github] -
Q3:为什么看到了
index.ts导出的是./src/probe.js,但目录里却没有probe.js?结论:这属于 TypeScript 在 Node ESM/NodeNext 场景的常见写法:源码里用.js扩展名来满足 ESM 解析,但实际源文件可能仍然是.ts,由运行时加载器/构建流程处理映射,所以“没有 probe.js”并不代表没加载到 probe。[github]
定位到根因
从 channel.ts 里,关键链路已经非常明确:
-
status.probeAccount: async ({ account }) => await probeFeishu(account) -
probeFeishu内部调用GET /open-apis/bot/v3/info
也就是说:只要 Gateway 的 health snapshot 在刷新,probeFeishu() 就会被调用;而原版 probeFeishu() 每次都会 createFeishuClient(creds) 并立即请求 /bot/v3/info,没有缓存也没有并发去重,于是每个周期必然打一次 API。[github]
另外,你还遇到一个“调试时非常容易踩坑”的情况:当月度额度已经超限后(99991403),probe 会一直失败;如果失败不做缓存,就会变成“失败风暴”,持续刷失败请求,把日志也刷爆。[github]
解决方案(缓存与降频)
目标很简单:让 probe 在 TTL 内返回缓存结果,而不是每次都真实请求飞书;同时在额度超限时做更长的失败缓存,避免不断重试。
这里给出一份适合公众号读者直接复制的“核心改法”(逻辑要点,而非依赖特定构建方式):
1)给 /bot/v3/info 做 TTL 缓存
-
成功结果:缓存 6 小时(或更长,bot 信息通常不频繁变)。
-
普通失败:缓存 10 分钟(防止瞬时抖动导致反复请求)。
-
配额超限 99991403:缓存 24 小时(关键止血点)。
并加上:
-
并发去重(in-flight):同一时刻多个地方触发 probe,只发 1 个请求。
2)解释“降频是否成功”的判断标准
最终观察到:调用频率从“每分钟一次”变成“每 10 分钟一次”,这基本等价于“失败缓存 TTL=10 分钟生效了”。如果你把 99991403 的失败缓存 TTL 提升到 24 小时,那么在当月额度恢复前,/bot/v3/info 理论上会变成“几乎不再出现”(最多每 24 小时尝试一次)。[github]
3)tenant_access_token 的降频(后续优化)
飞书自建应用的 tenant_access_token 有效期最大 2 小时,并且在前 1.5 小时内重复获取会返回同一个 token(剩余 <30 分钟才会返回新 token)。[histatic.zhipin]所以如果你仍然看到 tenant_access_token/internal 几分钟一次,通常是“反复 new SDK client 导致 token 缓存失效”。下一步应把 client 做成按 domain + appId + appSecret 的单例复用(放到 createFeishuClient() 那一层),让 SDK 内部的 token 管理真正起作用。
实操提醒:修改插件代码后必须重启 gateway 才会加载新模块;日志验证可以去看
/tmp/openclaw/openclaw-*.log(用于确认改的那份代码确实在跑)。
验证与经验
最后贴出的飞书调用明细已经很有说服力:/bot/v3/info 由“高频”明显下降到“低频”,并且仍然报 99991403,说明降频逻辑生效但额度尚未恢复(这是预期结果)。

今天这次调试总结三条经验:
-
看到“固定周期”的 OpenAPI 调用,先别急着怪网络/握手,优先怀疑“健康检查/探针”。[github]
-
在“配额超限”场景下,失败缓存比成功缓存更重要:不缓存失败就会形成持续重试。
-
TypeScript ESM 项目里出现
import "./xxx.js"但文件是.ts很常见,别被“文件不存在”误导,最终以“运行时确实执行哪份代码”的日志验证为准。[github]
附件:
probe.ts代码
import type { FeishuProbeResult } from "./types.js";import { createFeishuClient, type FeishuClientCredentials } from "./client.js";const OK_TTL_MS = 6 * 60 * 60 * 1000; // 6小时const FAIL_TTL_MS = 10 * 60 * 1000; // 10分钟const QUOTA_FAIL_TTL_MS = 24 * 60 * 60 * 1000; // 24小时(本月额度用尽)type CacheEntry = { data: FeishuProbeResult; expiresAt: number };const cache = new Map<string, CacheEntry>();const inFlight = new Map<string, Promise<FeishuProbeResult>>();// 可选:复用 client,减少 tenant_access_token/internal 被频繁触发的概率const clientCache = new Map<string, unknown>();function keyOf(creds: FeishuClientCredentials) {const domain = (creds as any).domain ?? "";return `${domain}::${creds.appId}::${creds.appSecret}`;}function getClient(creds: FeishuClientCredentials) {const k = keyOf(creds);const hit = clientCache.get(k);if (hit) return hit;const c = createFeishuClient(creds);clientCache.set(k, c);return c;}export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {if (!creds?.appId || !creds?.appSecret) {return { ok: false, error: "missing credentials (appId, appSecret)" };}const k = keyOf(creds);const now = Date.now();const cached = cache.get(k);if (cached && cached.expiresAt > now) return cached.data;const running = inFlight.get(k);if (running) return await running;const p = (async () => {try {const client = getClient(creds);// eslint-disable-next-line @typescript-eslint/no-explicit-anyconst response = await (client as any).request({method: "GET",url: "/open-apis/bot/v3/info",data: {},});if (response.code !== 0) {const ttl = response.code === 99991403 ? QUOTA_FAIL_TTL_MS : FAIL_TTL_MS;const fail: FeishuProbeResult = {ok: false,appId: creds.appId,error: `API error: ${response.msg || `code ${response.code}`}`,};cache.set(k, { data: fail, expiresAt: now + ttl });return fail;}const bot = response.bot || response.data?.bot;const ok: FeishuProbeResult = {ok: true,appId: creds.appId,botName: bot?.bot_name,botOpenId: bot?.open_id,};cache.set(k, { data: ok, expiresAt: now + OK_TTL_MS });return ok;} catch (err) {const fail: FeishuProbeResult = {ok: false,appId: creds.appId,error: err instanceof Error ? err.message : String(err),};cache.set(k, { data: fail, expiresAt: now + FAIL_TTL_MS });return fail;} finally {inFlight.delete(k);}})();inFlight.set(k, p);return await p;}
夜雨聆风
