Skip to content

Commit fe61bb3

Browse files
author
codes-factory-of-bg
committed
fix(bridge): 网络抖动时最终回复不落卡 — 重试+keep-alive+降级补发
根因:SDK 内置 axios 为裸实例(无 keep-alive/超时/重试),每次流式卡更新 都新开 TLS 连接,flaky 网络下反复 ECONNRESET。最终 setContent 失败被 SDK 静默吞掉(streamingFailed 标志),用户卡片永远停在进度标记,结论丢失。 processAndReply 的 cardCreated===true 分支也吞错,无降级。 修复: - 新增 buildLarkHttpInstance():keep-alive agent 复用连接 + 15s 超时 + 响应拦截器对 ECONNRESET/ETIMEDOUT/5xx 等瞬态错误重试 3 次指数退避, 通过 createLarkChannel({httpInstance}) 注入,惠及全部出站调用 - processAndReply: stash controller 引用,stream 完成后检测 streamingFailed→sendReplyToFeishu 降级补发结论 - 外层 catch 的 cardCreated===true 分支不再静默:warn 日志 + 若已算出 finalDisplayText 则补发,确保结论不丢 - 关键路径可观测埋点([net] retry/[stream] fallback) - axios 声明为直接依赖 也顺带缓解进程内存膨胀(原 timeout=0 让挂起 socket 累积到 4.8G)。
1 parent add3c89 commit fe61bb3

2 files changed

Lines changed: 117 additions & 4 deletions

File tree

bridge/bridge.mjs

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323

2424
import * as Lark from '@larksuiteoapi/node-sdk';
25+
import axios from 'axios';
2526
import fs from 'node:fs';
2627
import os from 'node:os';
2728
import path from 'node:path';
@@ -42,6 +43,78 @@ import { spawn, execSync } from 'node:child_process';
4243
}
4344
}
4445

46+
// ─── Hardened HTTP instance for the Lark SDK ───────────────────
47+
// The SDK ships a bare `axios.create()` with no keep-alive, no timeout, and no
48+
// retry. Every streaming-card update therefore opens a fresh TLS connection,
49+
// which on a flaky network fails repeatedly with
50+
// `Client network socket disconnected before secure TLS connection was
51+
// established` (ECONNRESET). When that final-card update fails, the user's
52+
// conclusion never lands on the card (issue: 最终回复不更新卡片).
53+
//
54+
// This shared instance hardens ALL outbound SDK calls (streaming-card updates,
55+
// message sends, media uploads) by:
56+
// 1. Reusing TLS connections via a keep-alive agent (fewer handshakes).
57+
// 2. Enforcing a request timeout (the SDK default is 0 = forever, which let
58+
// hung sockets accumulate and bloated the process to 4.8G).
59+
// 3. Retrying transient network errors with exponential backoff.
60+
const LARK_HTTP_TIMEOUT_MS = Number(process.env.FEISHU_BRIDGE_HTTP_TIMEOUT_MS) || 15000;
61+
const LARK_HTTP_MAX_RETRY = Number(process.env.FEISHU_BRIDGE_HTTP_MAX_RETRY) || 3;
62+
63+
const _larkHttpsAgent = new https.Agent({
64+
keepAlive: true,
65+
keepAliveMsecs: 1000,
66+
maxSockets: 32,
67+
maxFreeSockets: 8,
68+
timeout: LARK_HTTP_TIMEOUT_MS,
69+
});
70+
71+
function _isRetryableNetworkError(err) {
72+
if (!err) return false;
73+
const code = err.code;
74+
// Low-level socket / TLS failures before a response is received.
75+
if (['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH'].includes(code)) {
76+
return true;
77+
}
78+
// No response received at all (request never completed).
79+
if (err.response === undefined && err.request !== undefined) {
80+
return true;
81+
}
82+
// Transient server-side failures.
83+
const status = err.response?.status;
84+
if (status && (status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599))) {
85+
return true;
86+
}
87+
return false;
88+
}
89+
90+
function buildLarkHttpInstance() {
91+
const instance = axios.create({
92+
timeout: LARK_HTTP_TIMEOUT_MS,
93+
httpAgent: new http.Agent({ keepAlive: true }),
94+
httpsAgent: _larkHttpsAgent,
95+
});
96+
let retryHit = 0;
97+
instance.interceptors.response.use(undefined, async (error) => {
98+
const config = error?.config || {};
99+
if (!config || !_isRetryableNetworkError(error)) {
100+
return Promise.reject(error);
101+
}
102+
config.__retryCount = config.__retryCount || 0;
103+
if (config.__retryCount >= LARK_HTTP_MAX_RETRY) {
104+
return Promise.reject(error);
105+
}
106+
config.__retryCount += 1;
107+
retryHit += 1;
108+
const delayMs = Math.min(2000, 300 * 2 ** (config.__retryCount - 1)) + Math.floor(Math.random() * 150);
109+
console.warn(`[net] retry #${config.__retryCount}/${LARK_HTTP_MAX_RETRY} in ${delayMs}ms (code=${error.code || error.response?.status}, url=${config.url?.split('?')[0]})`);
110+
await new Promise((r) => setTimeout(r, delayMs));
111+
return instance.request(config);
112+
});
113+
return instance;
114+
}
115+
116+
const larkHttpInstance = buildLarkHttpInstance();
117+
45118
// Load .env automatically (so users don't need to export env vars manually).
46119
// - Does NOT override existing process.env values.
47120
// - Keeps this bridge dependency-free (no dotenv package).
@@ -2281,11 +2354,19 @@ async function processAndReply(claude, text, channel, chatId, replyCtx) {
22812354
const streamOpts = incomingMessageId ? { replyTo: incomingMessageId } : {};
22822355
let cardCreated = false;
22832356
let finalResult = null;
2357+
// controller ref stashed so we can inspect `streamingFailed` after the
2358+
// stream completes (the SDK swallows update errors internally and only flips
2359+
// this flag — see MarkdownStreamControllerImpl.pushContent).
2360+
let streamController = null;
2361+
// Lifted into the outer scope so the catch block can fall back to a plain
2362+
// message if the streaming card never received the final content.
2363+
let finalDisplayText = null;
22842364

22852365
try {
22862366
await channel.stream(chatId, {
22872367
markdown: async (controller) => {
22882368
cardCreated = true;
2369+
streamController = controller;
22892370

22902371
// Show a "thinking" placeholder immediately so the user sees
22912372
// the card is being worked on (no blank-slate silence).
@@ -2345,7 +2426,6 @@ async function processAndReply(claude, text, channel, chatId, replyCtx) {
23452426
}
23462427

23472428
// Final update with clean result text
2348-
let finalDisplayText;
23492429
if (finalResult?.interrupted) {
23502430
finalDisplayText = '⚡ 当前处理已被打断';
23512431
} else {
@@ -2360,9 +2440,31 @@ async function processAndReply(claude, text, channel, chatId, replyCtx) {
23602440
finalDisplayText = `✅ 已执行(无输出)${costNote}`;
23612441
}
23622442
}
2363-
controller.setContent(finalDisplayText);
2443+
// Best-effort final push. The SDK schedules this through its throttle
2444+
// queue; if the underlying PUT fails (e.g. ECONNRESET) the SDK swallows
2445+
// the error and flips `streamingFailed`. We detect that below and
2446+
// re-deliver via a plain message so the conclusion is never lost.
2447+
try {
2448+
await controller.setContent(finalDisplayText);
2449+
} catch (e) {
2450+
console.warn('[stream] final setContent threw:', e?.message || String(e));
2451+
}
23642452
},
23652453
}, streamOpts);
2454+
2455+
// Stream completed (SDK ran completeTerminal). If the final card update
2456+
// failed transitively, streamingFailed is now true — re-deliver the
2457+
// conclusion as a plain message so the user is not left staring at a
2458+
// stuck "✍️ 正在生成回复…" progress marker.
2459+
if (streamController?.streamingFailed) {
2460+
console.warn('[stream] final card update failed (streamingFailed), falling back to plain message');
2461+
try {
2462+
await sendReplyToFeishu(channel, chatId, finalDisplayText ?? '', { incomingMessageId });
2463+
} catch (e) {
2464+
console.error('[stream] fallback plain message also failed:', e?.message || String(e));
2465+
}
2466+
}
2467+
return finalResult;
23662468
} catch (e) {
23672469
if (!cardCreated) {
23682470
// Stream failed to start — fall back to non-streaming send.
@@ -2412,8 +2514,17 @@ async function processAndReply(claude, text, channel, chatId, replyCtx) {
24122514
return null;
24132515
}
24142516
}
2415-
// Stream started but producer failed — SDK already showed error in card
2517+
// Stream started but producer failed — SDK already showed error in card.
2518+
// Don't swallow silently: log it, and if we already computed the final
2519+
// text, re-deliver it as a plain message so the conclusion survives.
24162520
console.error('[WARN] stream failed after card creation:', e?.message || String(e));
2521+
if (finalDisplayText) {
2522+
try {
2523+
await sendReplyToFeishu(channel, chatId, finalDisplayText, { incomingMessageId });
2524+
} catch (e2) {
2525+
console.error('[stream] post-failure fallback also failed:', e2?.message || String(e2));
2526+
}
2527+
}
24172528
}
24182529

24192530
// Send media files from the final result (after stream completes)
@@ -2994,6 +3105,7 @@ for (const [alias, proj] of Object.entries(bridgeConfig.projects)) {
29943105
appType: Lark.AppType.SelfBuild,
29953106
source: 'feishu-codes-bridge',
29963107
loggerLevel: Lark.LoggerLevel.info,
3108+
httpInstance: larkHttpInstance,
29973109
policy: {
29983110
dmMode: 'open',
29993111
requireMention: false,

bridge/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"setup": "node setup-service.mjs"
99
},
1010
"dependencies": {
11-
"@larksuiteoapi/node-sdk": "1.66.0"
11+
"@larksuiteoapi/node-sdk": "1.66.0",
12+
"axios": "^1.13.6"
1213
},
1314
"engines": {
1415
"node": ">=18"

0 commit comments

Comments
 (0)