乐于分享
好东西不私藏

OpenClaw 飞书插件API告急:用缓存把 /bot/v3/info 降到可控频率

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 = { dataFeishuProbeResultexpiresAtnumber };const cache = new Map<stringCacheEntry>();const inFlight = new Map<stringPromise<FeishuProbeResult>>();// 可选:复用 client,减少 tenant_access_token/internal 被频繁触发的概率const clientCache = new Map<stringunknown>();function keyOf(credsFeishuClientCredentials) {  const domain = (creds as any).domain ?? "";  return `${domain}::${creds.appId}::${creds.appSecret}`;}function getClient(credsFeishuClientCredentials) {  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 { okfalseerror"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-any      const 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 failFeishuProbeResult = {          okfalse,          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 okFeishuProbeResult = {        oktrue,        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 failFeishuProbeResult = {        okfalse,        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;}
注意,可以直接复制替换,修改前先备份,改完需要gateway restart。
本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » OpenClaw 飞书插件API告急:用缓存把 /bot/v3/info 降到可控频率

评论 抢沙发

1 + 7 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮