Skip to content

Commit 3f67581

Browse files
fix: retry safe wrapped Telegram send failures (#51895) (thanks @chinar-amrutkar)
* fix(telegram): traverse error .cause chain in formatErrorMessage and match grammY HttpError grammY wraps network failures in HttpError with message 'Network request for ... failed!' and the original error in .cause. formatErrorMessage only checked err.message, so shouldRetry never fired for the most common transient failure class. Changes: - formatErrorMessage now traverses .cause chain, appending nested error messages (with cycle protection) - Added 'Network request' to TELEGRAM_RETRY_RE as belt-and-suspenders - Added tests for .cause traversal, circular references, and grammY HttpError retry behavior Fixes #51525 * style: fix oxfmt formatting in retry-policy.ts * fix: add braces to satisfy oxlint requirement * fix(telegram): keep send retries strict * test(telegram): cover wrapped retry paths * fix(telegram): retry rate-limited sends safely * fix: retry safe wrapped Telegram send failures (#51895) (thanks @chinar-amrutkar) * fix: preserve wrapped Telegram rate-limit retries (#51895) (thanks @chinar-amrutkar) --------- Co-authored-by: chinar-amrutkar <chinar-amrutkar@users.noreply.github.com> Co-authored-by: Ayaan Zaidi <hi@obviy.us>
1 parent 5c8d9da commit 3f67581

8 files changed

Lines changed: 217 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
4444
- Plugins/install: forward `--dangerously-force-unsafe-install` through archive and npm-spec plugin installs so the documented override reaches the security scanner on those install paths. (#58879) Thanks @ryanlee-gemini.
4545
- Chat/error replies: stop leaking raw provider/runtime failures into external chat channels, return a friendly retry message instead, and add a specific `/new` hint for Bedrock toolResult/toolUse session mismatches. (#58831) Thanks @ImLukeF.
4646
- Memory/session indexing: keep full reindexes from skipping session transcripts when sync is triggered by `session-start` or `watch`, so restart-driven reindexes preserve session memory (#39732) thanks @upupc
47+
- Telegram/retries: keep non-idempotent sends on the strict safe-send path, retry wrapped pre-connect failures, and preserve `429` / `retry_after` backoff for safe delivery retries. (#51895) Thanks @chinar-amrutkar
4748

4849
## 2026.3.31
4950

extensions/telegram/src/network-errors.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
import {
33
getTelegramNetworkErrorOrigin,
44
isRecoverableTelegramNetworkError,
5+
isTelegramRateLimitError,
56
isSafeToRetrySendError,
67
isTelegramClientRejection,
78
isTelegramPollingNetworkError,
@@ -160,6 +161,16 @@ describe("isRecoverableTelegramNetworkError", () => {
160161
});
161162

162163
describe("isSafeToRetrySendError", () => {
164+
class MockHttpError extends Error {
165+
constructor(
166+
message: string,
167+
public readonly error: unknown,
168+
) {
169+
super(message);
170+
this.name = "HttpError";
171+
}
172+
}
173+
163174
it.each([
164175
["ECONNREFUSED", "connect ECONNREFUSED", true],
165176
["ENOTFOUND", "getaddrinfo ENOTFOUND", true],
@@ -184,6 +195,13 @@ describe("isSafeToRetrySendError", () => {
184195
const wrapped = Object.assign(new Error("fetch failed"), { cause: root });
185196
expect(isSafeToRetrySendError(wrapped)).toBe(true);
186197
});
198+
199+
it("detects pre-connect error wrapped in grammY HttpError", () => {
200+
const root = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" });
201+
const fetchError = Object.assign(new TypeError("fetch failed"), { cause: root });
202+
const wrapped = new MockHttpError("Network request for 'sendMessage' failed!", fetchError);
203+
expect(isSafeToRetrySendError(wrapped)).toBe(true);
204+
});
187205
});
188206

189207
describe("isTelegramServerError", () => {
@@ -200,6 +218,26 @@ describe("isTelegramServerError", () => {
200218
});
201219
});
202220

221+
describe("isTelegramRateLimitError", () => {
222+
it("returns true for Telegram 429 errors", () => {
223+
expect(isTelegramRateLimitError(errorWithTelegramCode("Too Many Requests", 429))).toBe(true);
224+
});
225+
226+
it("detects wrapped 429 retry_after errors without error_code", () => {
227+
const wrapped = {
228+
message: "429 Too Many Requests",
229+
response: { parameters: { retry_after: 1 } },
230+
};
231+
expect(isTelegramRateLimitError(wrapped)).toBe(true);
232+
});
233+
234+
it("detects error_code in nested cause", () => {
235+
const inner = Object.assign(new Error("Too Many Requests"), { error_code: 429 });
236+
const outer = Object.assign(new Error("wrapped"), { cause: inner });
237+
expect(isTelegramRateLimitError(outer)).toBe(true);
238+
});
239+
});
240+
203241
describe("isTelegramClientRejection", () => {
204242
it.each([
205243
["Bad Request", 400, true],

extensions/telegram/src/network-errors.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,49 @@ function hasTelegramErrorCode(err: unknown, matches: (code: number) => boolean):
183183
return false;
184184
}
185185

186+
function hasTelegramRetryAfter(err: unknown): boolean {
187+
for (const candidate of collectTelegramErrorCandidates(err)) {
188+
if (!candidate || typeof candidate !== "object") {
189+
continue;
190+
}
191+
const retryAfter =
192+
"parameters" in candidate && candidate.parameters && typeof candidate.parameters === "object"
193+
? (candidate.parameters as { retry_after?: unknown }).retry_after
194+
: "response" in candidate &&
195+
candidate.response &&
196+
typeof candidate.response === "object" &&
197+
"parameters" in candidate.response
198+
? (
199+
candidate.response as {
200+
parameters?: { retry_after?: unknown };
201+
}
202+
).parameters?.retry_after
203+
: "error" in candidate &&
204+
candidate.error &&
205+
typeof candidate.error === "object" &&
206+
"parameters" in candidate.error
207+
? (candidate.error as { parameters?: { retry_after?: unknown } }).parameters
208+
?.retry_after
209+
: undefined;
210+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
211+
return true;
212+
}
213+
}
214+
return false;
215+
}
216+
186217
/** Returns true for HTTP 5xx server errors (error may have been processed). */
187218
export function isTelegramServerError(err: unknown): boolean {
188219
return hasTelegramErrorCode(err, (code) => code >= 500);
189220
}
190221

222+
export function isTelegramRateLimitError(err: unknown): boolean {
223+
return (
224+
hasTelegramErrorCode(err, (code) => code === 429) ||
225+
(hasTelegramRetryAfter(err) && /(?:^|\b)429\b|too many requests/i.test(formatErrorMessage(err)))
226+
);
227+
}
228+
191229
/** Returns true for HTTP 4xx client errors (Telegram explicitly rejected, not applied). */
192230
export function isTelegramClientRejection(err: unknown): boolean {
193231
return hasTelegramErrorCode(err, (code) => code >= 400 && code < 500);

extensions/telegram/src/send.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,40 @@ describe("sendMessageTelegram", () => {
940940
vi.useRealTimers();
941941
});
942942

943+
it("retries wrapped pre-connect HttpError sends", async () => {
944+
vi.useFakeTimers();
945+
const chatId = "123";
946+
const root = Object.assign(new Error("connect ECONNREFUSED api.telegram.org"), {
947+
code: "ECONNREFUSED",
948+
});
949+
const fetchError = Object.assign(new TypeError("fetch failed"), { cause: root });
950+
const err = Object.assign(new Error("Network request for 'sendMessage' failed!"), {
951+
name: "HttpError",
952+
error: fetchError,
953+
});
954+
const sendMessage = vi
955+
.fn()
956+
.mockRejectedValueOnce(err)
957+
.mockResolvedValueOnce({
958+
message_id: 1,
959+
chat: { id: chatId },
960+
});
961+
const api = { sendMessage } as unknown as {
962+
sendMessage: typeof sendMessage;
963+
};
964+
965+
const promise = sendMessageTelegram(chatId, "hi", {
966+
token: "tok",
967+
api,
968+
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
969+
});
970+
971+
await vi.runAllTimersAsync();
972+
await expect(promise).resolves.toEqual({ messageId: "1", chatId });
973+
expect(sendMessage).toHaveBeenCalledTimes(2);
974+
vi.useRealTimers();
975+
});
976+
943977
it("does not retry on non-transient errors", async () => {
944978
const chatId = "123";
945979
const sendMessage = vi.fn().mockRejectedValue(new Error("400: Bad Request"));
@@ -1861,6 +1895,57 @@ describe("sendStickerTelegram", () => {
18611895
}),
18621896
).rejects.toThrow(/returned no message_id/i);
18631897
});
1898+
1899+
it("does not retry generic grammY failed envelopes for sticker sends", async () => {
1900+
const chatId = "123";
1901+
const sendSticker = vi
1902+
.fn()
1903+
.mockRejectedValueOnce(new Error("Network request for 'sendSticker' failed!"));
1904+
const api = { sendSticker } as unknown as {
1905+
sendSticker: typeof sendSticker;
1906+
};
1907+
1908+
await expect(
1909+
sendStickerTelegram(chatId, "fileId123", {
1910+
token: "tok",
1911+
api,
1912+
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
1913+
}),
1914+
).rejects.toThrow(/Network request for 'sendSticker' failed!/i);
1915+
expect(sendSticker).toHaveBeenCalledTimes(1);
1916+
});
1917+
1918+
it("retries rate-limited sticker sends and honors retry_after", async () => {
1919+
vi.useFakeTimers();
1920+
const chatId = "123";
1921+
const sendSticker = vi
1922+
.fn()
1923+
.mockRejectedValueOnce({
1924+
message: "429 Too Many Requests",
1925+
response: { parameters: { retry_after: 1 } },
1926+
})
1927+
.mockResolvedValueOnce({
1928+
message_id: 109,
1929+
chat: { id: chatId },
1930+
});
1931+
const api = { sendSticker } as unknown as {
1932+
sendSticker: typeof sendSticker;
1933+
};
1934+
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
1935+
1936+
const promise = sendStickerTelegram(chatId, "fileId123", {
1937+
token: "tok",
1938+
api,
1939+
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
1940+
});
1941+
1942+
await vi.runAllTimersAsync();
1943+
await expect(promise).resolves.toEqual({ messageId: "109", chatId });
1944+
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(1000);
1945+
expect(sendSticker).toHaveBeenCalledTimes(2);
1946+
setTimeoutSpy.mockRestore();
1947+
vi.useRealTimers();
1948+
});
18641949
});
18651950

18661951
describe("shared send behaviors", () => {

extensions/telegram/src/send.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
3131
import { renderTelegramHtmlText, splitTelegramHtmlChunks } from "./format.js";
3232
import {
3333
isRecoverableTelegramNetworkError,
34+
isTelegramRateLimitError,
3435
isSafeToRetrySendError,
3536
isTelegramServerError,
3637
} from "./network-errors.js";
@@ -606,7 +607,7 @@ function createTelegramNonIdempotentRequestWithDiag(params: {
606607
retry: params.retry,
607608
verbose: params.verbose,
608609
useApiErrorLogging: params.useApiErrorLogging,
609-
shouldRetry: (err) => isSafeToRetrySendError(err),
610+
shouldRetry: (err) => isSafeToRetrySendError(err) || isTelegramRateLimitError(err),
610611
strictShouldRetry: true,
611612
});
612613
}
@@ -1523,7 +1524,7 @@ export async function sendStickerTelegram(
15231524
});
15241525
const hasThreadParams = Object.keys(threadParams).length > 0;
15251526

1526-
const requestWithDiag = createTelegramRequestWithDiag({
1527+
const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({
15271528
cfg,
15281529
account,
15291530
retry: opts.retry,

src/infra/errors.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,25 @@ describe("error helpers", () => {
6868
expect(formatErrorMessage(value)).toBe(expected);
6969
});
7070

71+
it("traverses .cause chain to include nested error messages", () => {
72+
const rootCause = new Error("ECONNRESET");
73+
const httpError = Object.assign(new Error("Network request for 'sendMessage' failed!"), {
74+
cause: rootCause,
75+
});
76+
const formatted = formatErrorMessage(httpError);
77+
expect(formatted).toContain("Network request for 'sendMessage' failed!");
78+
expect(formatted).toContain("ECONNRESET");
79+
});
80+
81+
it("handles circular .cause references without infinite loop", () => {
82+
const a: Error & { cause?: unknown } = new Error("error A");
83+
const b: Error & { cause?: unknown } = new Error("error B");
84+
a.cause = b;
85+
b.cause = a;
86+
const formatted = formatErrorMessage(a);
87+
expect(formatted).toBe("error A | error B");
88+
});
89+
7190
it("redacts sensitive tokens from formatted error messages", () => {
7291
const token = "sk-abcdefghijklmnopqrstuv";
7392
const formatted = formatErrorMessage(new Error(`Authorization: Bearer ${token}`));

src/infra/errors.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ export function formatErrorMessage(err: unknown): string {
6969
let formatted: string;
7070
if (err instanceof Error) {
7171
formatted = err.message || err.name || "Error";
72+
// Traverse .cause chain to include nested error messages (e.g. grammY HttpError wraps network errors in .cause)
73+
let cause: unknown = err.cause;
74+
const seen = new Set<unknown>([err]);
75+
while (cause && !seen.has(cause)) {
76+
seen.add(cause);
77+
if (cause instanceof Error) {
78+
if (cause.message) {
79+
formatted += ` | ${cause.message}`;
80+
}
81+
cause = cause.cause;
82+
} else if (typeof cause === "string") {
83+
formatted += ` | ${cause}`;
84+
break;
85+
} else {
86+
break;
87+
}
88+
}
7289
} else if (typeof err === "string") {
7390
formatted = err;
7491
} else if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {

src/infra/retry-policy.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ describe("createTelegramRetryRunner", () => {
117117
expectedCalls: 1,
118118
expectedError: "permission denied",
119119
},
120+
{
121+
name: "retries grammY HttpError wrapping network error via .cause traversal",
122+
runnerOptions: {
123+
retry: { ...ZERO_DELAY_RETRY, attempts: 2 },
124+
},
125+
fnSteps: [
126+
{
127+
type: "reject" as const,
128+
value: Object.assign(new Error("Network request for 'sendMessage' failed!"), {
129+
cause: new Error("ECONNRESET"),
130+
}),
131+
},
132+
],
133+
expectedCalls: 2,
134+
expectedError: "Network request",
135+
},
120136
{
121137
name: "keeps retrying retriable errors until attempts are exhausted",
122138
runnerOptions: {

0 commit comments

Comments
 (0)