From ec1e27d5620adf5107df4fe315f054e7e451d3ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 10:48:01 -0400 Subject: [PATCH] fix(msteams): ignore unsafe retry-after delays --- extensions/msteams/src/errors.test.ts | 33 ++++++++++++++++++++++++++ extensions/msteams/src/errors.ts | 34 +++++++++++++++++++-------- src/plugin-sdk/number-runtime.ts | 1 + 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/extensions/msteams/src/errors.test.ts b/extensions/msteams/src/errors.test.ts index a9b4cc3cc2c..9b88c60b194 100644 --- a/extensions/msteams/src/errors.test.ts +++ b/extensions/msteams/src/errors.test.ts @@ -59,6 +59,39 @@ describe("msteams errors", () => { ).toBeUndefined(); }); + it("ignores unsafe retry-after magnitudes", () => { + expect( + classifyMSTeamsSendError({ + statusCode: 429, + retryAfterMs: Number.MAX_SAFE_INTEGER + 1, + }).retryAfterMs, + ).toBeUndefined(); + expect( + classifyMSTeamsSendError({ + statusCode: 429, + retryAfter: Number.MAX_SAFE_INTEGER, + }).retryAfterMs, + ).toBeUndefined(); + expect( + classifyMSTeamsSendError({ + statusCode: 429, + retryAfter: "9007199254741", + }).retryAfterMs, + ).toBeUndefined(); + expect( + classifyMSTeamsSendError({ + statusCode: 429, + response: { headers: { "retry-after": "9007199254741" } }, + }).retryAfterMs, + ).toBeUndefined(); + expect( + classifyMSTeamsSendError({ + statusCode: 429, + response: { headers: new Headers({ "retry-after": "9007199254741" }) }, + }).retryAfterMs, + ).toBeUndefined(); + }); + it("does not parse partial or fractional status codes", () => { expect(classifyMSTeamsSendError({ statusCode: "429oops" }).kind).toBe("unknown"); expect(classifyMSTeamsSendError({ statusCode: 429.5 }).kind).toBe("unknown"); diff --git a/extensions/msteams/src/errors.ts b/extensions/msteams/src/errors.ts index d3a22b71100..56ba62833aa 100644 --- a/extensions/msteams/src/errors.ts +++ b/extensions/msteams/src/errors.ts @@ -1,5 +1,8 @@ +import { asFiniteNumberInRange, parseStrictFiniteNumber } from "openclaw/plugin-sdk/number-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +const MAX_SAFE_RETRY_AFTER_SECONDS = Number.MAX_SAFE_INTEGER / 1000; + export function formatUnknownError(err: unknown): string { if (err instanceof Error) { return err.message; @@ -96,17 +99,25 @@ function extractRetryAfterMs(err: unknown): number | null { } const direct = err.retryAfterMs ?? err.retry_after_ms; - if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) { - return direct; + const directMs = asFiniteNumberInRange(direct, { + min: 0, + max: Number.MAX_SAFE_INTEGER, + }); + if (directMs !== undefined) { + return directMs; } const retryAfter = err.retryAfter ?? err.retry_after; - if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) { - return retryAfter >= 0 ? retryAfter * 1000 : null; + const retryAfterSeconds = asFiniteNumberInRange(retryAfter, { + min: 0, + max: MAX_SAFE_RETRY_AFTER_SECONDS, + }); + if (retryAfterSeconds !== undefined) { + return retryAfterSeconds * 1000; } if (typeof retryAfter === "string") { const parsed = parseNonNegativeRetryAfterSeconds(retryAfter); - if (Number.isFinite(parsed) && parsed >= 0) { + if (parsed !== undefined) { return parsed * 1000; } } @@ -125,7 +136,7 @@ function extractRetryAfterMs(err: unknown): number | null { const raw = headers["retry-after"] ?? headers["Retry-After"]; if (typeof raw === "string") { const parsed = parseNonNegativeRetryAfterSeconds(raw); - if (Number.isFinite(parsed) && parsed >= 0) { + if (parsed !== undefined) { return parsed * 1000; } } @@ -141,7 +152,7 @@ function extractRetryAfterMs(err: unknown): number | null { const raw = (headers as { get: (name: string) => string | null }).get("retry-after"); if (raw) { const parsed = parseNonNegativeRetryAfterSeconds(raw); - if (Number.isFinite(parsed) && parsed >= 0) { + if (parsed !== undefined) { return parsed * 1000; } } @@ -150,12 +161,15 @@ function extractRetryAfterMs(err: unknown): number | null { return null; } -function parseNonNegativeRetryAfterSeconds(raw: string): number { +function parseNonNegativeRetryAfterSeconds(raw: string): number | undefined { const trimmed = raw.trim(); if (!/^\d+(?:\.\d+)?$/.test(trimmed)) { - return Number.NaN; + return undefined; } - return Number(trimmed); + return asFiniteNumberInRange(parseStrictFiniteNumber(trimmed), { + min: 0, + max: MAX_SAFE_RETRY_AFTER_SECONDS, + }); } type MSTeamsSendErrorKind = diff --git a/src/plugin-sdk/number-runtime.ts b/src/plugin-sdk/number-runtime.ts index 8c2cd51278c..f793ff4ac9b 100644 --- a/src/plugin-sdk/number-runtime.ts +++ b/src/plugin-sdk/number-runtime.ts @@ -1,6 +1,7 @@ // Numeric coercion helpers for plugin runtime inputs. export { + asFiniteNumberInRange, parseFiniteNumber, resolveIntegerOption, resolveNonNegativeIntegerOption,