Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/services/open-platform-rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
nextAppVersion,
OpenPlatformApiError,
readStoredCookiesFromSessionFile,
safeErrorMessage,
type OpenPlatformApiClient,
type OpenPlatformClientResult,
type StoredCookie,
Expand Down Expand Up @@ -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) };
}

// ── 共用链路:改基础信息 + 镜像线上可见范围建版发布 ─────────────────────────
Expand Down
77 changes: 73 additions & 4 deletions src/setup/open-platform-automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down Expand Up @@ -1647,14 +1684,29 @@ class MutableCookieJar {
async fetchRaw(fetcher: typeof fetch, url: string, init: RequestInit = {}, maxHops = 10): Promise<Response> {
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);
if (cookieHeader) headers.set('cookie', cookieHeader);
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');
Expand Down Expand Up @@ -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 {
Expand Down
106 changes: 106 additions & 0 deletions test/setup-open-platform-automation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
buildSafeSettingPayload,
buildScopeUpdatePayload,
createFeishuOpenPlatformApp,
createOpenPlatformApiClient,
extractOpenPlatformCsrfToken,
extractOpenPlatformSessionIdentity,
extractOpenPlatformScopeEntries,
Expand All @@ -25,6 +26,7 @@ import {
parseSetupOpenPlatformAutoFlag,
prepareFeishuWebSession,
readStoredCookiesFromSessionFile,
safeErrorMessage,
type StoredCookie,
vcListenerEventGateError,
writeStoredCookiesToSessionFile,
Expand Down Expand Up @@ -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 ***');
});
});
Loading