grammY getUpdates returns "Request to getUpdates timed out after 500 seconds"
but RECOVERABLE_MESSAGE_SNIPPETS only had "timeout". Since
"timed out".includes("timeout") === false, the error was not classified as
recoverable, causing the polling loop to exit permanently.
Add "timed out" to RECOVERABLE_MESSAGE_SNIPPETS so the polling loop retries
instead of dying silently.
Fixes #7239
Fixes #7255
151 lines
3.9 KiB
TypeScript
151 lines
3.9 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",
|
|
"timeout", // catch timeout messages not covered by error codes/names
|
|
"timed out", // grammY getUpdates returns "timed out after X seconds" (not matched by "timeout")
|
|
];
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
// Grammy's HttpError wraps the underlying error in .error (not .cause)
|
|
// Only follow .error for HttpError to avoid widening the search graph
|
|
if (getErrorName(current) === "HttpError") {
|
|
const wrappedError = (current as { error?: unknown }).error;
|
|
if (wrappedError && !seen.has(wrappedError)) {
|
|
queue.push(wrappedError);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|