From 65c6525909330ddae6270b17b6fc1f125918a92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 18 Aug 2026 01:01:58 -0700 Subject: [PATCH] =?UTF-8?q?fix(setup):=20=E5=BC=80=E6=94=BE=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E9=A1=B5=E9=9D=A2=E8=AF=BB=E5=8F=96=E5=81=B6=E5=8F=91?= =?UTF-8?q?=20fetch=20failed=20=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E5=B9=B6=E9=80=8F=E5=87=BA=E7=9C=9F=E5=AE=9E=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主机到 open.feishu.cn 的偶发网络抖动会让 undici 把请求抛成一句 TypeError('fetch failed'),一次失败就中断整条 console 自动化链路—— dashboard 改头像/改名、VC 事件订阅检查都实测偶发中招,且真实原因 (ECONNRESET / EAI_AGAIN 等)藏在 err.cause 里被丢弃,无法排障。 - MutableCookieJar.fetchRaw:幂等 GET/HEAD 每跳对瞬态网络错误小步退避 重试(300ms/900ms,共 3 次);POST 全是 console 写操作,传输错误时 结果未知,保持单次绝不重试 - safeErrorMessage:展开 err.cause 链(含 happy-eyeballs AggregateError), 报错带上真实网络错误码,长 token 脱敏保持不变 - open-platform-rename failureFromError 复用 safeErrorMessage,改名/改 头像的 POST 阶段网络错误同样透出 cause Co-authored-by: Cursor --- src/services/open-platform-rename.ts | 4 +- src/setup/open-platform-automation.ts | 77 +++++++++++++- test/setup-open-platform-automation.test.ts | 106 ++++++++++++++++++++ 3 files changed, 182 insertions(+), 5 deletions(-) diff --git a/src/services/open-platform-rename.ts b/src/services/open-platform-rename.ts index 1b3fef614..0a6497899 100644 --- a/src/services/open-platform-rename.ts +++ b/src/services/open-platform-rename.ts @@ -29,6 +29,7 @@ import { nextAppVersion, OpenPlatformApiError, readStoredCookiesFromSessionFile, + safeErrorMessage, type OpenPlatformApiClient, type OpenPlatformClientResult, type StoredCookie, @@ -78,7 +79,8 @@ function failureFromError(err: unknown, fallbackReason: OpenPlatformRenameFailur } return { reason: fallbackReason, message: err.message }; } - return { reason: fallbackReason, message: err instanceof Error ? err.message : String(err) }; + // 网络类错误(undici "fetch failed")的真实原因在 cause 链里,safeErrorMessage 会带上。 + return { reason: fallbackReason, message: safeErrorMessage(err) }; } // ── 共用链路:改基础信息 + 镜像线上可见范围建版发布 ───────────────────────── diff --git a/src/setup/open-platform-automation.ts b/src/setup/open-platform-automation.ts index fdafb5e77..4d52ed0de 100644 --- a/src/setup/open-platform-automation.ts +++ b/src/setup/open-platform-automation.ts @@ -1619,6 +1619,43 @@ function readDefaultScopeManifest(): ScopeManifest { throw new Error('找不到 botmux lark-scopes.json'); } +// 宿主机到飞书的偶发网络抖动(DNS EAI_AGAIN、连接被重置、路由瞬断等)会让 +// undici 把整个请求直接抛成 TypeError('fetch failed'),一次失败就中断 console +// 自动化链路(dashboard 改名/改头像、VC 事件订阅检查都实测偶发中招)。这类 +// 错误按错误码识别,只对幂等请求小步退避重试。 +const TRANSIENT_NETWORK_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ENOTFOUND', + 'EPIPE', + 'ENETUNREACH', + 'EHOSTUNREACH', + 'ENETDOWN', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_SOCKET', +]); + +const TRANSIENT_FETCH_RETRY_DELAYS_MS = [300, 900]; + +function isLikelyTransientNetworkError(err: unknown, depth = 0): boolean { + if (depth > 4 || !(err instanceof Error)) return false; + // 调用方主动 abort / 超时不算网络抖动,重试会违背调用方意图。 + if (err.name === 'AbortError' || err.name === 'TimeoutError') return false; + const code = (err as { code?: unknown }).code; + if (typeof code === 'string' && TRANSIENT_NETWORK_ERROR_CODES.has(code)) return true; + if (err instanceof AggregateError && err.errors.some(item => isLikelyTransientNetworkError(item, depth + 1))) { + return true; + } + // undici 网络层失败统一表现为 TypeError('fetch failed', { cause });cause 缺失 + // (老版本/被吞)时按瞬态处理——多试两次的代价远小于误报一次给用户。 + if (err instanceof TypeError && err.message === 'fetch failed') { + return err.cause === undefined || isLikelyTransientNetworkError(err.cause, depth + 1); + } + return isLikelyTransientNetworkError((err as { cause?: unknown }).cause, depth + 1); +} + class MutableCookieJar { private cookies: StoredCookie[]; @@ -1647,6 +1684,10 @@ class MutableCookieJar { async fetchRaw(fetcher: typeof fetch, url: string, init: RequestInit = {}, maxHops = 10): Promise { let current = url; let referer: string | undefined; + // 只有幂等的 GET/HEAD 允许瞬态网络错误重试:POST 全是 console 写操作或登录 + // 流程,传输错误时服务端可能已 commit(结果未知),重试等于重复提交。 + const method = (init.method ?? 'GET').toUpperCase(); + const retryable = method === 'GET' || method === 'HEAD'; for (let hop = 0; hop <= maxHops; hop += 1) { const headers = new Headers(init.headers); const cookieHeader = getCookieHeader(this.cookies, current); @@ -1654,7 +1695,18 @@ class MutableCookieJar { headers.set('user-agent', headers.get('user-agent') ?? DEFAULT_BROWSER_USER_AGENT); if (referer && !headers.has('referer')) headers.set('referer', referer); - const response = await fetcher(current, { ...init, headers, redirect: 'manual' }); + let response: Response; + for (let attempt = 0; ; attempt += 1) { + try { + response = await fetcher(current, { ...init, headers, redirect: 'manual' }); + break; + } catch (err) { + if (!retryable || attempt >= TRANSIENT_FETCH_RETRY_DELAYS_MS.length || !isLikelyTransientNetworkError(err)) { + throw err; + } + await sleep(TRANSIENT_FETCH_RETRY_DELAYS_MS[attempt]); + } + } this.loadFromResponse(current, response.headers); if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location'); @@ -1951,9 +2003,26 @@ function summarizeOpenPlatformPayload(payload: unknown): string { return JSON.stringify(summary).slice(0, 500); } -function safeErrorMessage(err: unknown): string { - const message = err instanceof Error ? err.message : String(err); - return message.replace(/[A-Za-z0-9_=-]{24,}/g, '***'); +export function safeErrorMessage(err: unknown): string { + // undici 把网络失败包成 TypeError('fetch failed', { cause }),真实原因 + // (ECONNRESET / EAI_AGAIN / 具体地址等)全在 cause 链里——不带上它,用户和 + // 排障方永远只能看到一句 "fetch failed"。 + const parts: string[] = []; + let current: unknown = err; + for (let depth = 0; depth < 4 && current !== undefined && current !== null; depth += 1) { + if (current instanceof AggregateError && !current.message && current.errors.length > 0) { + current = current.errors[0]; + } + const message = current instanceof Error ? current.message : String(current); + const code = (current as { code?: unknown }).code; + const part = typeof code === 'string' && code && !message.includes(code) + ? (message ? `${message} (${code})` : code) + : message; + if (part && parts[parts.length - 1] !== part) parts.push(part); + current = current instanceof Error ? current.cause : undefined; + } + const combined = parts.join(': ') || (err instanceof Error ? err.message : String(err)); + return combined.replace(/[A-Za-z0-9_=-]{24,}/g, '***'); } function markFinalResponseUrl(response: Response, finalUrl: string): void { diff --git a/test/setup-open-platform-automation.test.ts b/test/setup-open-platform-automation.test.ts index aaf4aeb5e..bb60bdbde 100644 --- a/test/setup-open-platform-automation.test.ts +++ b/test/setup-open-platform-automation.test.ts @@ -16,6 +16,7 @@ import { buildSafeSettingPayload, buildScopeUpdatePayload, createFeishuOpenPlatformApp, + createOpenPlatformApiClient, extractOpenPlatformCsrfToken, extractOpenPlatformSessionIdentity, extractOpenPlatformScopeEntries, @@ -25,6 +26,7 @@ import { parseSetupOpenPlatformAutoFlag, prepareFeishuWebSession, readStoredCookiesFromSessionFile, + safeErrorMessage, type StoredCookie, vcListenerEventGateError, writeStoredCookiesToSessionFile, @@ -1361,3 +1363,107 @@ describe('automateOpenPlatformSetup 版本可见范围', () => { expect(calls.some(path => path.includes('/app_version/create/'))).toBe(false); }); }); + +// 宿主机到飞书的偶发网络抖动会让 undici 抛 TypeError('fetch failed'),一次失败 +// 就中断整条 console 链路(dashboard 改名/改头像实测偶发中招)。页面读取 GET +// 幂等可重试;console POST 写操作传输错误时结果未知,绝不能重试。 +describe('console 页面读取的瞬态网络错误重试', () => { + const transientFetchError = () => + new TypeError('fetch failed', { + cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }), + }); + + it('GET 页面读取遇瞬态网络错误自动重试,抖一次不再让整条链路失败', async () => { + let pageAttempts = 0; + const fetchImpl = (async (url: string | URL | Request) => { + const href = String(url); + if (href === 'https://open.feishu.cn/app') { + pageAttempts += 1; + if (pageAttempts === 1) throw transientFetchError(); + return new Response(openPlatformPage(), { status: 200 }); + } + throw new Error(`unexpected url: ${href}`); + }) as typeof fetch; + + const result = await createOpenPlatformApiClient([cookie()], { fetchImpl }); + expect(result.ok).toBe(true); + expect(pageAttempts).toBe(2); + }); + + it('重试耗尽后返回 network 失败,message 带上 cause 里的真实网络错误', async () => { + let pageAttempts = 0; + const fetchImpl = (async () => { + pageAttempts += 1; + throw transientFetchError(); + }) as typeof fetch; + + const result = await createOpenPlatformApiClient([cookie()], { fetchImpl }); + expect(result).toMatchObject({ ok: false, reason: 'network' }); + if (!result.ok) { + expect(result.message).toContain('fetch failed'); + expect(result.message).toContain('ECONNRESET'); + } + expect(pageAttempts).toBe(3); // 首次 + 2 次重试 + }); + + it('console POST 写操作不重试:传输错误立刻抛出,避免重复提交', async () => { + let postAttempts = 0; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + const href = String(url); + if ((init?.method ?? 'GET').toUpperCase() === 'POST') { + postAttempts += 1; + throw transientFetchError(); + } + if (href === 'https://open.feishu.cn/app') return new Response(openPlatformPage(), { status: 200 }); + throw new Error(`unexpected url: ${href}`); + }) as typeof fetch; + + const clientResult = await createOpenPlatformApiClient([cookie()], { fetchImpl }); + expect(clientResult.ok).toBe(true); + if (!clientResult.ok) return; + await expect(clientResult.client.postJson('/developers/v1/app/cli_x', {})).rejects.toThrow('fetch failed'); + expect(postAttempts).toBe(1); + }); + + it('非网络错误不重试(mock/逻辑错误一次就失败,不白等退避)', async () => { + let attempts = 0; + const fetchImpl = (async () => { + attempts += 1; + throw new Error('boom'); + }) as typeof fetch; + + const result = await createOpenPlatformApiClient([cookie()], { fetchImpl }); + expect(result).toMatchObject({ ok: false, reason: 'network' }); + expect(attempts).toBe(1); + }); +}); + +describe('safeErrorMessage', () => { + it('展开 undici fetch failed 的 cause 链,露出真实网络错误', () => { + const err = new TypeError('fetch failed', { + cause: Object.assign(new Error('connect ETIMEDOUT 1.2.3.4:443'), { code: 'ETIMEDOUT' }), + }); + expect(safeErrorMessage(err)).toBe('fetch failed: connect ETIMEDOUT 1.2.3.4:443'); + }); + + it('cause 是 happy-eyeballs 的 AggregateError 时取首个真实错误', () => { + const err = new TypeError('fetch failed', { + cause: new AggregateError([ + Object.assign(new Error('connect ECONNREFUSED 1.2.3.4:443'), { code: 'ECONNREFUSED' }), + ]), + }); + expect(safeErrorMessage(err)).toBe('fetch failed: connect ECONNREFUSED 1.2.3.4:443'); + }); + + it('message 里没有错误码时把 code 补进去', () => { + const err = new TypeError('fetch failed', { + cause: Object.assign(new Error('getaddrinfo failure'), { code: 'EAI_AGAIN' }), + }); + expect(safeErrorMessage(err)).toBe('fetch failed: getaddrinfo failure (EAI_AGAIN)'); + }); + + it('仍然脱敏长 token', () => { + const err = new Error(`bad token ${'a'.repeat(32)}`); + expect(safeErrorMessage(err)).toBe('bad token ***'); + }); +});