Hide goose from user-facing connection errors

This commit is contained in:
john
2026-06-25 23:44:58 +08:00
parent e133f9a3a4
commit d51df2fb0a
2 changed files with 77 additions and 14 deletions
+27 -7
View File
@@ -60,7 +60,7 @@ export class ApiError extends Error {
code?: string, code?: string,
details?: InsufficientBalanceDetails | Record<string, unknown>, details?: InsufficientBalanceDetails | Record<string, unknown>,
) { ) {
super(message); super(sanitizeUserFacingErrorMessage(message));
this.name = 'ApiError'; this.name = 'ApiError';
this.status = status; this.status = status;
this.code = code; this.code = code;
@@ -68,6 +68,21 @@ export class ApiError extends Error {
} }
} }
function sanitizeUserFacingErrorMessage(message: string) {
const normalized = String(message ?? '').trim();
if (!normalized) return normalized;
if (!/goose|goosed/i.test(normalized)) return normalized;
if (/超时|timeout/i.test(normalized)) {
return '后端连接超时,请确认后端服务正常后重试';
}
if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) {
return '后端连接失败,请稍后重试';
}
return normalized
.replace(/\bgoosed\b/gi, '后端服务')
.replace(/\bgoose\b/gi, '后端');
}
async function parseErrorResponse(res: Response): Promise<{ async function parseErrorResponse(res: Response): Promise<{
message: string; message: string;
code?: string; code?: string;
@@ -95,7 +110,7 @@ async function parseErrorResponse(res: Response): Promise<{
const details = nested.details ?? body.details; const details = nested.details ?? body.details;
if (code === 'INSUFFICIENT_BALANCE') { if (code === 'INSUFFICIENT_BALANCE') {
return { return {
message, message: sanitizeUserFacingErrorMessage(message),
code, code,
details: { details: {
code: 'INSUFFICIENT_BALANCE' as const, code: 'INSUFFICIENT_BALANCE' as const,
@@ -112,14 +127,14 @@ async function parseErrorResponse(res: Response): Promise<{
}; };
} }
return { return {
message, message: sanitizeUserFacingErrorMessage(message),
code, code,
details: details && typeof details === 'object' details: details && typeof details === 'object'
? (details as InsufficientBalanceDetails | Record<string, unknown>) ? (details as InsufficientBalanceDetails | Record<string, unknown>)
: undefined, : undefined,
}; };
} catch { } catch {
return { message: text || res.statusText }; return { message: sanitizeUserFacingErrorMessage(text || res.statusText) };
} }
} }
@@ -200,12 +215,17 @@ async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
function formatNetworkError(err: unknown) { function formatNetworkError(err: unknown) {
const message = err instanceof Error ? err.message : '网络请求失败'; const message = err instanceof Error ? err.message : '网络请求失败';
if (err instanceof DOMException && err.name === 'AbortError') { if (err instanceof DOMException && err.name === 'AbortError') {
return '连接 goose 服务超时,请确认 goosed/H5 后端正在运行后重试'; return '后端连接超时,请确认后端服务正常后重试';
} }
if (message.includes('Failed to fetch') || message.includes('NetworkError')) { if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
return '无法连接后端服务,请先运行: pnpm dev 或 node server.mjs'; return '无法连接后端服务,请先运行: pnpm dev 或 node server.mjs';
} }
return message; return sanitizeUserFacingErrorMessage(message);
}
function sanitizeSessionEvent(event: SessionEvent): SessionEvent {
if (event.type !== 'Error') return event;
return { ...event, error: sanitizeUserFacingErrorMessage(event.error) };
} }
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -1921,7 +1941,7 @@ export function subscribeSessionEvents(
} }
continue; continue;
} }
onEvent(JSON.parse(data) as SessionEvent); onEvent(sanitizeSessionEvent(JSON.parse(data) as SessionEvent));
} catch { } catch {
// ignore malformed frames // ignore malformed frames
} }
+50 -7
View File
@@ -14,6 +14,21 @@ function isHttpsTarget(target) {
return target.startsWith('https://'); return target.startsWith('https://');
} }
function sanitizeUserFacingProxyMessage(message, fallback = '后端连接失败,请稍后重试') {
const normalized = String(message ?? '').trim();
if (!normalized) return fallback;
if (!/goose|goosed/i.test(normalized)) return normalized;
if (/超时|timeout/i.test(normalized)) {
return '后端连接超时,请确认后端服务正常后重试';
}
if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) {
return fallback;
}
return normalized
.replace(/\bgoosed\b/gi, '后端服务')
.replace(/\bgoose\b/gi, '后端');
}
async function apiFetch(target, apiSecret, pathname, init = {}) { async function apiFetch(target, apiSecret, pathname, init = {}) {
const url = new URL(pathname, target); const url = new URL(pathname, target);
const headers = { const headers = {
@@ -308,7 +323,12 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
res.status(upstream.status).json(payload); res.status(upstream.status).json(payload);
} catch (err) { } catch (err) {
res.status(500).json({ message: err instanceof Error ? err.message : '恢复会话失败' }); res.status(500).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '恢复会话失败',
'恢复会话失败',
),
});
} }
}, },
], ],
@@ -339,12 +359,15 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
if (owned.has(item.id)) sessionsById.set(item.id, item); if (owned.has(item.id)) sessionsById.set(item.id, item);
} }
} catch (err) { } catch (err) {
lastFailure = err instanceof Error ? err.message : '读取会话失败'; lastFailure = sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '读取会话失败',
'读取会话失败',
);
} }
} }
if (healthyTargets === 0) { if (healthyTargets === 0) {
res.status(502).json({ message: lastFailure ?? '所有 goose 服务不可用' }); res.status(502).json({ message: lastFailure ?? '后端连接失败,请稍后重试' });
return; return;
} }
if (healthyTargets < targets.length) { if (healthyTargets < targets.length) {
@@ -352,7 +375,12 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
} }
res.json({ sessions: [...sessionsById.values()] }); res.json({ sessions: [...sessionsById.values()] });
} catch (err) { } catch (err) {
res.status(500).json({ message: err instanceof Error ? err.message : '读取会话失败' }); res.status(500).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '读取会话失败',
'读取会话失败',
),
});
} }
}, },
], ],
@@ -377,7 +405,12 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
req.agentSessionId = sessionId; req.agentSessionId = sessionId;
await build(req, res, next); await build(req, res, next);
} catch (err) { } catch (err) {
res.status(500).json({ message: err instanceof Error ? err.message : '请求失败' }); res.status(500).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '请求失败',
'请求失败',
),
});
} }
}, },
]; ];
@@ -441,7 +474,12 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
source.on('error', () => res.end()); source.on('error', () => res.end());
source.pipe(billingTransform); source.pipe(billingTransform);
} catch (err) { } catch (err) {
res.status(502).json({ message: err instanceof Error ? err.message : 'SSE 代理失败' }); res.status(502).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : 'SSE 代理失败',
'SSE 代理失败',
),
});
} }
}; };
@@ -494,7 +532,12 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
sendProxyResponse(res, upstream); sendProxyResponse(res, upstream);
} catch (err) { } catch (err) {
res.status(502).json({ message: err instanceof Error ? err.message : '代理失败' }); res.status(502).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '代理失败',
'代理失败',
),
});
} }
}; };