openclaw/src/telegram/network-errors.ts
ryan 558b64f5fa fix: handle Telegram network errors gracefully to prevent gateway crashes
- Expand recoverable error codes (ECONNABORTED, ERR_NETWORK)
- Add message patterns for 'typeerror: fetch failed' and 'undici' errors
- Add isNetworkRelatedError() helper for broad network failure detection
- Retry on all network-related errors instead of crashing gateway
- Remove unnecessary 'void' from fire-and-forget patterns
- Add tests for new error patterns

Fixes #3005
2026-01-27 19:56:24 -06:00

117 lines
3.2 KiB
TypeScript

import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
const RECOVERABLE_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"EPIPE",
"ETIMEDOUT",
"ESOCKETTIMEDOUT",
"ENETUNREACH",
"EHOSTUNREACH",
"ENOTFOUND",
"EAI_AGAIN",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_SOCKET",
"UND_ERR_ABORTED",
"ECONNABORTED",
"ERR_NETWORK",
]);
const RECOVERABLE_ERROR_NAMES = new Set([
"AbortError",
"TimeoutError",
"ConnectTimeoutError",
"HeadersTimeoutError",
"BodyTimeoutError",
]);
const RECOVERABLE_MESSAGE_SNIPPETS = [
"fetch failed",
"typeerror: fetch failed",
"undici",
"network error",
"network request",
"client network socket disconnected",
"socket hang up",
"getaddrinfo",
];
function normalizeCode(code?: string): string {
return code?.trim().toUpperCase() ?? "";
}
function getErrorName(err: unknown): string {
if (!err || typeof err !== "object") return "";
return "name" in err ? String(err.name) : "";
}
function getErrorCode(err: unknown): string | undefined {
const direct = extractErrorCode(err);
if (direct) return direct;
if (!err || typeof err !== "object") return undefined;
const errno = (err as { errno?: unknown }).errno;
if (typeof errno === "string") return errno;
if (typeof errno === "number") return String(errno);
return undefined;
}
function collectErrorCandidates(err: unknown): unknown[] {
const queue = [err];
const seen = new Set<unknown>();
const candidates: unknown[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current == null || seen.has(current)) continue;
seen.add(current);
candidates.push(current);
if (typeof current === "object") {
const cause = (current as { cause?: unknown }).cause;
if (cause && !seen.has(cause)) queue.push(cause);
const reason = (current as { reason?: unknown }).reason;
if (reason && !seen.has(reason)) queue.push(reason);
const errors = (current as { errors?: unknown }).errors;
if (Array.isArray(errors)) {
for (const nested of errors) {
if (nested && !seen.has(nested)) queue.push(nested);
}
}
}
}
return candidates;
}
export type TelegramNetworkErrorContext = "polling" | "send" | "webhook" | "unknown";
export function isRecoverableTelegramNetworkError(
err: unknown,
options: { context?: TelegramNetworkErrorContext; allowMessageMatch?: boolean } = {},
): boolean {
if (!err) return false;
const allowMessageMatch =
typeof options.allowMessageMatch === "boolean"
? options.allowMessageMatch
: options.context !== "send";
for (const candidate of collectErrorCandidates(err)) {
const code = normalizeCode(getErrorCode(candidate));
if (code && RECOVERABLE_ERROR_CODES.has(code)) return true;
const name = getErrorName(candidate);
if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true;
if (allowMessageMatch) {
const message = formatErrorMessage(candidate).toLowerCase();
if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) {
return true;
}
}
}
return false;
}