Skip to content

Commit 1e8ca26

Browse files
committed
fix(telegram): cool down unhealthy transports
1 parent 40aa57b commit 1e8ca26

3 files changed

Lines changed: 175 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,7 @@ Docs: https://docs.openclaw.ai
711711
- Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys.
712712
- Docker/compose: pin container-side `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR` on both gateway and CLI services so the host paths written into `.env` by `scripts/docker/setup.sh` (used as Compose bind-mount sources) cannot leak into runtime code via the `env_file` import. Fixes regressions on macOS Docker setups where the first agent reply died with `EACCES: permission denied, mkdir '/Users'` because the host-style workspace path got persisted into `agents.defaults.workspace`. Fixes #77436. Thanks @lonexreb.
713713
- Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev.
714+
- Telegram: cool down repeatedly failing Bot API transport fallbacks so long polling stops hammering a blackholed Telegram route. Fixes #77900. Thanks @bryce-d-greybeard.
714715
- Slack: report `unknown error` instead of `undefined` in socket-mode startup retry logs and label the retry reason explicitly.
715716
- Telegram: let explicit forum-topic `requireMention` settings override persisted `/activate` and `/deactivate` state, so per-topic mention gates work consistently. Fixes #49864. Thanks @Panniantong.
716717
- Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval.

extensions/telegram/src/fetch.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ function buildFetchFallbackError(code: string) {
179179
});
180180
}
181181

182+
function buildCodeLessFetchFallbackError() {
183+
return new TypeError("fetch failed");
184+
}
185+
182186
const STICKY_IPV4_FALLBACK_NETWORK = {
183187
network: {
184188
autoSelectFamily: true,
@@ -955,6 +959,63 @@ describe("resolveTelegramFetch", () => {
955959
expect(getDispatcherFromUndiciCall(4)).toBe(getDispatcherFromUndiciCall(3));
956960
});
957961

962+
it("falls back on code-less fetch failed envelopes", async () => {
963+
undiciFetch
964+
.mockRejectedValueOnce(buildCodeLessFetchFallbackError())
965+
.mockResolvedValueOnce({ ok: true } as Response);
966+
967+
const resolved = resolveTelegramFetchOrThrow(undefined, {
968+
network: {
969+
autoSelectFamily: true,
970+
dnsResultOrder: "ipv4first",
971+
},
972+
});
973+
974+
await resolved("https://api.telegram.org/botx/deleteWebhook");
975+
976+
expect(undiciFetch).toHaveBeenCalledTimes(2);
977+
expect(getDispatcherFromUndiciCall(1)).not.toBe(getDispatcherFromUndiciCall(2));
978+
});
979+
980+
it("cools down a repeatedly failing sticky fallback and probes earlier attempts", async () => {
981+
for (let i = 0; i < 7; i += 1) {
982+
undiciFetch.mockRejectedValueOnce(buildFetchFallbackError("ENETUNREACH"));
983+
}
984+
undiciFetch
985+
.mockRejectedValueOnce(buildFetchFallbackError("ENETUNREACH"))
986+
.mockRejectedValueOnce(buildFetchFallbackError("ENETUNREACH"));
987+
988+
const resolved = resolveTelegramFetchOrThrow(undefined, {
989+
network: {
990+
autoSelectFamily: true,
991+
dnsResultOrder: "ipv4first",
992+
},
993+
});
994+
995+
await expect(resolved("https://api.telegram.org/botx/deleteWebhook")).rejects.toThrow(
996+
"fetch failed",
997+
);
998+
for (let i = 0; i < 4; i += 1) {
999+
await expect(resolved("https://api.telegram.org/botx/getUpdates")).rejects.toThrow(
1000+
"fetch failed",
1001+
);
1002+
}
1003+
await expect(resolved("https://api.telegram.org/botx/getUpdates")).rejects.toThrow(
1004+
"temporarily unhealthy",
1005+
);
1006+
1007+
expect(undiciFetch).toHaveBeenCalledTimes(9);
1008+
expect(getDispatcherFromUndiciCall(7)).toBe(getDispatcherFromUndiciCall(3));
1009+
expect(getDispatcherFromUndiciCall(8)).toBe(getDispatcherFromUndiciCall(1));
1010+
expect(getDispatcherFromUndiciCall(9)).toBe(getDispatcherFromUndiciCall(2));
1011+
expect(loggerWarn).toHaveBeenCalledWith(
1012+
expect.stringContaining("telegram transport attempt marked temporarily unhealthy"),
1013+
);
1014+
expect(loggerDebug).toHaveBeenCalledWith(
1015+
expect.stringContaining("fetch fallback: re-probing primary dispatcher"),
1016+
);
1017+
});
1018+
9581019
it("preserves caller-provided dispatcher across fallback retry", async () => {
9591020
const fetchError = buildFetchFallbackError("EHOSTUNREACH");
9601021
undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response);

extensions/telegram/src/fetch.ts

Lines changed: 113 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const TELEGRAM_DISPATCHER_KEEP_ALIVE_MAX_TIMEOUT_MS = 600_000;
4242
const TELEGRAM_DISPATCHER_CONNECTIONS_PER_ORIGIN = 10;
4343
const TELEGRAM_DISPATCHER_PIPELINING = 1;
4444
const TELEGRAM_STICKY_FALLBACK_PRIMARY_PROBE_SUCCESS_THRESHOLD = 5;
45+
const TELEGRAM_TRANSPORT_ATTEMPT_FAILURE_THRESHOLD = 5;
46+
const TELEGRAM_TRANSPORT_ATTEMPT_INITIAL_COOLDOWN_MS = 10_000;
47+
const TELEGRAM_TRANSPORT_ATTEMPT_MAX_COOLDOWN_MS = 60_000;
4548

4649
type TelegramAgentPoolOptions = {
4750
allowH2: false;
@@ -80,6 +83,12 @@ type TelegramTransportAttempt = {
8083
logMessage?: string;
8184
};
8285

86+
type TelegramTransportAttemptHealth = {
87+
consecutiveFailures: number;
88+
cooldownMs: number;
89+
unhealthyUntilMs: number;
90+
};
91+
8392
type TelegramDnsResultOrder = "ipv4first" | "verbatim";
8493

8594
type LookupCallback =
@@ -110,22 +119,6 @@ type TelegramTransportFallbackContext = {
110119
codes: Set<string>;
111120
};
112121

113-
type TelegramTransportFallbackRule = {
114-
name: string;
115-
matches: (ctx: TelegramTransportFallbackContext) => boolean;
116-
};
117-
118-
const TELEGRAM_TRANSPORT_FALLBACK_RULES: readonly TelegramTransportFallbackRule[] = [
119-
{
120-
name: "fetch-failed-envelope",
121-
matches: ({ message }) => message.includes("fetch failed"),
122-
},
123-
{
124-
name: "known-network-code",
125-
matches: ({ codes }) => FALLBACK_RETRY_ERROR_CODES.some((code) => codes.has(code)),
126-
},
127-
];
128-
129122
function normalizeDnsResultOrder(value: string | null): TelegramDnsResultOrder | null {
130123
if (value === "ipv4first" || value === "verbatim") {
131124
return value;
@@ -446,20 +439,28 @@ function formatErrorCodes(err: unknown): string {
446439
return codes.length > 0 ? codes.join(",") : "none";
447440
}
448441

442+
class TelegramTransportAttemptUnhealthyError extends Error {
443+
constructor(unhealthyUntilMs: number) {
444+
const remainingMs = Math.max(0, unhealthyUntilMs - Date.now());
445+
super(`telegram transport attempt temporarily unhealthy; retry after ${remainingMs}ms`);
446+
this.name = "TelegramTransportAttemptUnhealthyError";
447+
}
448+
}
449+
449450
function shouldUseTelegramTransportFallback(err: unknown): boolean {
451+
if (err instanceof TelegramTransportAttemptUnhealthyError) {
452+
return true;
453+
}
450454
const ctx: TelegramTransportFallbackContext = {
451455
message:
452456
err && typeof err === "object" && "message" in err
453457
? normalizeLowercaseStringOrEmpty(String(err.message))
454458
: "",
455459
codes: collectErrorCodes(err),
456460
};
457-
for (const rule of TELEGRAM_TRANSPORT_FALLBACK_RULES) {
458-
if (!rule.matches(ctx)) {
459-
return false;
460-
}
461-
}
462-
return true;
461+
const hasFetchFailedEnvelope = ctx.message.includes("fetch failed");
462+
const hasKnownNetworkCode = FALLBACK_RETRY_ERROR_CODES.some((code) => ctx.codes.has(code));
463+
return hasKnownNetworkCode || (hasFetchFailedEnvelope && ctx.codes.size === 0);
463464
}
464465

465466
export function shouldRetryTelegramTransportFallback(err: unknown): boolean {
@@ -643,12 +644,46 @@ export function resolveTelegramTransport(
643644
let stickyAttemptIndex = 0;
644645
let stickySuccessCount = 0;
645646
let primaryProbeDue = false;
647+
const attemptHealth = transportAttempts.map<TelegramTransportAttemptHealth>(() => ({
648+
consecutiveFailures: 0,
649+
cooldownMs: TELEGRAM_TRANSPORT_ATTEMPT_INITIAL_COOLDOWN_MS,
650+
unhealthyUntilMs: 0,
651+
}));
646652

647653
const resetStickyRecoveryProbe = (): void => {
648654
stickySuccessCount = 0;
649655
primaryProbeDue = false;
650656
};
651657

658+
const getAttemptCooldownError = (attemptIndex: number): Error | null => {
659+
const health = attemptHealth[attemptIndex];
660+
if (health.unhealthyUntilMs <= Date.now()) {
661+
return null;
662+
}
663+
return new TelegramTransportAttemptUnhealthyError(health.unhealthyUntilMs);
664+
};
665+
666+
const recordAttemptFailure = (attemptIndex: number, err: unknown): void => {
667+
if (!shouldUseTelegramTransportFallback(err)) {
668+
return;
669+
}
670+
const health = attemptHealth[attemptIndex];
671+
health.consecutiveFailures += 1;
672+
if (health.consecutiveFailures < TELEGRAM_TRANSPORT_ATTEMPT_FAILURE_THRESHOLD) {
673+
return;
674+
}
675+
const cooldownMs = Math.min(
676+
TELEGRAM_TRANSPORT_ATTEMPT_MAX_COOLDOWN_MS,
677+
Math.max(TELEGRAM_TRANSPORT_ATTEMPT_INITIAL_COOLDOWN_MS, health.cooldownMs),
678+
);
679+
health.consecutiveFailures = 0;
680+
health.cooldownMs = Math.min(TELEGRAM_TRANSPORT_ATTEMPT_MAX_COOLDOWN_MS, cooldownMs * 2);
681+
health.unhealthyUntilMs = Date.now() + cooldownMs;
682+
log.warn(
683+
`telegram transport attempt marked temporarily unhealthy for ${cooldownMs}ms (codes=${formatErrorCodes(err)})`,
684+
);
685+
};
686+
652687
const promoteStickyAttempt = (nextIndex: number, err: unknown, reason?: string): boolean => {
653688
if (nextIndex <= stickyAttemptIndex || nextIndex >= transportAttempts.length) {
654689
return false;
@@ -669,6 +704,11 @@ export function resolveTelegramTransport(
669704
};
670705

671706
const recordSuccessfulAttempt = (attemptIndex: number): void => {
707+
const health = attemptHealth[attemptIndex];
708+
health.consecutiveFailures = 0;
709+
health.cooldownMs = TELEGRAM_TRANSPORT_ATTEMPT_INITIAL_COOLDOWN_MS;
710+
health.unhealthyUntilMs = 0;
711+
672712
if (stickyAttemptIndex === 0) {
673713
resetStickyRecoveryProbe();
674714
return;
@@ -700,50 +740,63 @@ export function resolveTelegramTransport(
700740
(init as RequestInitWithDispatcher | undefined)?.dispatcher,
701741
);
702742
const stickyStartIndex = Math.min(stickyAttemptIndex, transportAttempts.length - 1);
703-
const primaryProbe = !callerProvidedDispatcher && primaryProbeDue && stickyStartIndex > 0;
743+
const stickyCooldownError = callerProvidedDispatcher
744+
? null
745+
: getAttemptCooldownError(stickyStartIndex);
746+
const primaryProbe =
747+
!callerProvidedDispatcher &&
748+
stickyStartIndex > 0 &&
749+
(primaryProbeDue || stickyCooldownError !== null);
704750
const startIndex = primaryProbe ? 0 : stickyStartIndex;
705751
if (primaryProbe) {
706752
primaryProbeDue = false;
707-
log.debug("fetch fallback: re-probing primary dispatcher after sticky fallback successes");
708-
}
709-
let err: unknown;
710-
711-
try {
712-
const response = await sourceFetch(
713-
input,
714-
withDispatcherIfMissing(init, transportAttempts[startIndex].createDispatcher()),
753+
log.debug(
754+
stickyCooldownError
755+
? "fetch fallback: re-probing primary dispatcher while sticky fallback is cooling down"
756+
: "fetch fallback: re-probing primary dispatcher after sticky fallback successes",
715757
);
716-
captureHttpExchange({
717-
url: resolveRequestUrl(input),
718-
method: init?.method ?? "GET",
719-
requestHeaders: init?.headers as Headers | Record<string, string> | undefined,
720-
requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
721-
response,
722-
flowId: randomUUID(),
723-
meta: { subsystem: "telegram-fetch" },
724-
});
725-
if (!callerProvidedDispatcher) {
726-
recordSuccessfulAttempt(startIndex);
727-
}
728-
return response;
729-
} catch (caught) {
730-
err = caught;
731758
}
759+
let err: unknown;
732760

733-
if (!shouldUseTelegramTransportFallback(err)) {
734-
throw err;
735-
}
736761
if (callerProvidedDispatcher) {
737-
return sourceFetch(input, init ?? {});
762+
try {
763+
const response = await sourceFetch(input, init);
764+
captureHttpExchange({
765+
url: resolveRequestUrl(input),
766+
method: init?.method ?? "GET",
767+
requestHeaders: init?.headers as Headers | Record<string, string> | undefined,
768+
requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
769+
response,
770+
flowId: randomUUID(),
771+
meta: { subsystem: "telegram-fetch" },
772+
});
773+
return response;
774+
} catch (caught) {
775+
if (!shouldUseTelegramTransportFallback(caught)) {
776+
throw caught;
777+
}
778+
return sourceFetch(input, init ?? {});
779+
}
738780
}
739781

740-
for (let nextIndex = startIndex + 1; nextIndex < transportAttempts.length; nextIndex += 1) {
741-
const nextAttempt = transportAttempts[nextIndex];
742-
promoteStickyAttempt(nextIndex, err);
782+
for (
783+
let attemptIndex = startIndex;
784+
attemptIndex < transportAttempts.length;
785+
attemptIndex += 1
786+
) {
787+
const attempt = transportAttempts[attemptIndex];
788+
if (attemptIndex > startIndex) {
789+
promoteStickyAttempt(attemptIndex, err);
790+
}
791+
const cooldownError = getAttemptCooldownError(attemptIndex);
792+
if (cooldownError) {
793+
err = cooldownError;
794+
continue;
795+
}
743796
try {
744797
const response = await sourceFetch(
745798
input,
746-
withDispatcherIfMissing(init, nextAttempt.createDispatcher()),
799+
withDispatcherIfMissing(init, attempt.createDispatcher()),
747800
);
748801
captureHttpExchange({
749802
url: resolveRequestUrl(input),
@@ -752,15 +805,19 @@ export function resolveTelegramTransport(
752805
requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
753806
response,
754807
flowId: randomUUID(),
755-
meta: { subsystem: "telegram-fetch", fallbackAttempt: nextIndex },
808+
meta:
809+
attemptIndex === startIndex
810+
? { subsystem: "telegram-fetch" }
811+
: { subsystem: "telegram-fetch", fallbackAttempt: attemptIndex },
756812
});
757-
recordSuccessfulAttempt(nextIndex);
813+
recordSuccessfulAttempt(attemptIndex);
758814
return response;
759815
} catch (caught) {
760816
err = caught;
761817
if (!shouldUseTelegramTransportFallback(err)) {
762818
throw err;
763819
}
820+
recordAttemptFailure(attemptIndex, err);
764821
}
765822
}
766823

0 commit comments

Comments
 (0)