From e1c2b347f9310518319cf5cca82aa5b46d15b69a Mon Sep 17 00:00:00 2001 From: sprintberlin Date: Mon, 24 Aug 2026 17:12:26 +0200 Subject: [PATCH] fix(quota): parse absolute ISO datetime reset timestamps in weekly quota fallback (#11353) Merged via consolidated batch validation. Fixes GLM/Z.AI weekly quota fallback: parseDayGranularityResetMs only recognized 'reset in N days', dropping the real multi-day cooldown when upstream returns a full absolute ISO datetime. Own repro test passes. --- open-sse/services/quotaResetParsing.ts | 116 ++++++++++++++- open-sse/services/quotaTextCooldowns.ts | 18 ++- .../unit/repro-glm-iso-reset-24h-cap.test.ts | 135 ++++++++++++++++++ 3 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 tests/unit/repro-glm-iso-reset-24h-cap.test.ts diff --git a/open-sse/services/quotaResetParsing.ts b/open-sse/services/quotaResetParsing.ts index b3606b02a2..c6d9be7667 100644 --- a/open-sse/services/quotaResetParsing.ts +++ b/open-sse/services/quotaResetParsing.ts @@ -26,19 +26,121 @@ export function shouldPreserveQuotaSignals( } /** - * Parse a day-granularity quota reset countdown ("Your quota will reset in - * 3 days.", "Resets in 13 days") out of an upstream 429 body. + * Parse a day-granularity quota reset countdown (\"Your quota will reset in + * 3 days.\", \"Resets in 13 days\") out of an upstream 429 body. * * Companion to the Xh/Ym/Zs countdown parsing already handled inline by * `parseRetryFromErrorText` — none of those patterns match when the upstream * expresses the reset window in whole days rather than hours/minutes/seconds, * so a multi-day quota reset previously parsed to `null` and fell back to the * engine's ~seconds-scale default cooldown. + * + * Delegates to `parseIsoDateTimeResetMs` (absolute \"reset at YYYY-MM-DD HH:MM:SS\") + * and then `parseMonthDayResetMs` (year-less \"reset at MM-DD HH:MM:SS UTC\") so + * every absolute-reset shape an upstream uses resolves to the real wait. */ -export function parseDayGranularityResetMs(msg: string, maxMs: number): number | null { +export function parseDayGranularityResetMs( + msg: string, + maxMs: number, + nowMs: number = Date.now() +): number | null { const dayMatch = /reset(?:s)?\s+in\s+(\d+)\s*day(?:s)?/i.exec(msg); - if (!dayMatch) return null; - const days = Number.parseInt(dayMatch[1], 10); - if (!Number.isFinite(days) || days <= 0) return null; - return Math.min(days * 24 * 3600 * 1000, maxMs); + if (dayMatch) { + const days = Number.parseInt(dayMatch[1], 10); + if (Number.isFinite(days) && days > 0) { + return Math.min(days * 24 * 3600 * 1000, maxMs); + } + } + const isoMs = parseIsoDateTimeResetMs(msg, maxMs, nowMs); + if (isoMs !== null) return isoMs; + return parseMonthDayResetMs(msg, maxMs, nowMs); +} + +/** + * Z.AI (GLM) reports an exhausted weekly/monthly cap with a FULL absolute + * datetime rather than a countdown: + * + * \"[1310][Weekly/Monthly Limit Exhausted. … Your limit will reset at + * 2026-08-29 21:01:21]\" + * + * `parseRetryFromErrorText` (accountFallback.ts) has an equivalent ISO matcher, + * but `buildWeeklyQuotaFallback` never reaches it: it calls + * `parseDayGranularityResetMs` directly, and neither the \"reset in N days\" nor + * the year-less MM-DD parser matched this shape. The weekly fallback therefore + * fell back to WEEKLY_QUOTA_COOLDOWN_MS (24h) and the connection was dispatched + * again — into a real upstream 429 — every day until the true reset ~6 days out. + * + * The datetime may use a `T` or a space separator, and may carry `Z` or a + * `±HH:MM` offset. A NAIVE datetime (no zone) is interpreted as UTC: Z.AI + * reports in UTC, and treating it as local time would shift the cooldown by the + * host offset. Returns null when the instant is not in the future. + */ +export function parseIsoDateTimeResetMs( + msg: string, + maxMs: number, + nowMs: number = Date.now() +): number | null { + const match = + /\b(?:try again at|wait until|reset(?:s)?\s+at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)\s*(Z|[+-]\d{2}:?\d{2})?/i.exec( + msg + ); + if (!match) return null; + const stamp = match[1].replace(/[Tt ]/, "T"); + // No zone in the body → UTC (see doc comment). Normalize \"+0200\" to \"+02:00\": + // the bare-offset form is not part of the ES Date.parse grammar. + const rawZone = match[2] ? match[2].toUpperCase() : "Z"; + const zone = /^[+-]\d{4}$/.test(rawZone) + ? `${rawZone.slice(0, 3)}:${rawZone.slice(3)}` + : rawZone; + const resetMs = Date.parse(`${stamp}${zone}`); + if (!Number.isFinite(resetMs)) return null; + const waitMs = resetMs - nowMs; + if (waitMs <= 0) return null; + return Math.min(waitMs, maxMs); +} + +/** + * Qwen token-plan (and similar apikey providers) report the weekly reset as + * \"The quota will reset at 08-29 15:29:00 UTC\" without a year. Treat that as + * the next occurrence of MM-DD HH:MM[:SS] UTC; if the date already passed this + * year, roll to next year. Returns null when the parsed instant is not in the + * future or the wait would exceed maxMs. + */ +export function parseMonthDayResetMs( + msg: string, + maxMs: number, + nowMs: number = Date.now() +): number | null { + const match = + /reset(?:s)?\s+at\s+(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s*(?:UTC|Z)?/i.exec( + msg + ); + if (!match) return null; + const month = Number.parseInt(match[1], 10); + const day = Number.parseInt(match[2], 10); + const hour = Number.parseInt(match[3], 10); + const minute = Number.parseInt(match[4], 10); + const second = match[5] ? Number.parseInt(match[5], 10) : 0; + if ( + month < 1 || + month > 12 || + day < 1 || + day > 31 || + hour > 23 || + minute > 59 || + second > 59 + ) { + return null; + } + const now = new Date(nowMs); + let year = now.getUTCFullYear(); + let resetMs = Date.UTC(year, month - 1, day, hour, minute, second); + if (!Number.isFinite(resetMs)) return null; + if (resetMs <= nowMs) { + year += 1; + resetMs = Date.UTC(year, month - 1, day, hour, minute, second); + } + const waitMs = resetMs - nowMs; + if (!Number.isFinite(waitMs) || waitMs <= 0) return null; + return Math.min(waitMs, maxMs); } diff --git a/open-sse/services/quotaTextCooldowns.ts b/open-sse/services/quotaTextCooldowns.ts index c0e1a17ec6..6fa3f4893d 100644 --- a/open-sse/services/quotaTextCooldowns.ts +++ b/open-sse/services/quotaTextCooldowns.ts @@ -11,6 +11,7 @@ */ import { RateLimitReason } from "../config/constants.ts"; +import { parseDayGranularityResetMs } from "./quotaResetParsing.ts"; type RateLimitReasonValue = (typeof RateLimitReason)[keyof typeof RateLimitReason]; @@ -97,16 +98,29 @@ export function isWeeklyUsageLimitText(lower: string): boolean { return ( lower.includes("weekly usage limit") || lower.includes("weekly limit reached") || - lower.includes("reached your weekly") + lower.includes("reached your weekly") || + lower.includes("1-week quota") || + lower.includes("week quota") || + lower.includes("weekly/monthly limit") || + (lower.includes("weekly") && lower.includes("quota") && lower.includes("exhaust")) ); } +const MAX_WEEKLY_QUOTA_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; + export function buildWeeklyQuotaFallback(errorStr: string): QuotaTextFallback | null { if (!isWeeklyUsageLimitText(errorStr.toLowerCase())) return null; + const parsedResetMs = parseDayGranularityResetMs(errorStr, MAX_WEEKLY_QUOTA_COOLDOWN_MS); + const cooldownMs = + typeof parsedResetMs === "number" && parsedResetMs > 0 + ? parsedResetMs + : WEEKLY_QUOTA_COOLDOWN_MS; return { shouldFallback: true, - cooldownMs: WEEKLY_QUOTA_COOLDOWN_MS, + cooldownMs, reason: RateLimitReason.QUOTA_EXHAUSTED, + usedUpstreamRetryHint: typeof parsedResetMs === "number" && parsedResetMs > 0, + quotaResetHintMs: typeof parsedResetMs === "number" && parsedResetMs > 0 ? parsedResetMs : undefined, }; } diff --git a/tests/unit/repro-glm-iso-reset-24h-cap.test.ts b/tests/unit/repro-glm-iso-reset-24h-cap.test.ts new file mode 100644 index 0000000000..485f639497 --- /dev/null +++ b/tests/unit/repro-glm-iso-reset-24h-cap.test.ts @@ -0,0 +1,135 @@ +/** + * Regression: Z.AI (GLM) weekly quota was capped at a 24h cooldown instead of + * the real ~6-day reset the upstream reported. + * + * Body from production (connection zai/glm-5.3): + * "[1310][Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-08-29 21:01:21]" + * + * looksLikeQuotaExhausted() and isWeeklyUsageLimitText() both matched, so the + * weekly branch was taken — but buildWeeklyQuotaFallback() calls + * parseDayGranularityResetMs() FIRST and that only knew "reset in N days" and + * the year-less "reset at MM-DD HH:MM:SS UTC" shape (#qwen). A full ISO + * datetime parsed to null, so the weekly fallback used its + * WEEKLY_QUOTA_COOLDOWN_MS default of 24h. The ISO matcher that DOES handle + * this shape lives in parseRetryFromErrorText() and is never reached from the + * weekly branch. + * + * Result: rate_limited_until was written 24h out instead of the true reset, + * and the connection was dispatched into a real upstream 429 every day for + * the rest of the week. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { looksLikeQuotaExhausted } from "../../src/shared/utils/classify429.ts"; +import { + isWeeklyUsageLimitText, + buildWeeklyQuotaFallback, +} from "../../open-sse/services/quotaTextCooldowns.ts"; +import { + parseDayGranularityResetMs, + parseIsoDateTimeResetMs, + parseMonthDayResetMs, + shouldPreserveQuotaSignals, +} from "../../open-sse/services/quotaResetParsing.ts"; +import { RateLimitReason } from "../../open-sse/config/constants.ts"; + +const GLM_BODY = + "[1310][Weekly/Monthly Limit Exhausted. Your current plan has run out of its weekly/monthly quota. " + + "Your limit will reset at 2026-08-29 21:01:21]"; +const MAX_MS = 30 * 24 * 60 * 60 * 1000; // MAX_WEEKLY_QUOTA_COOLDOWN_MS +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = Date.UTC(2026, 7, 23, 20, 30, 56); // 2026-08-23 20:30:56 UTC +const RESET = Date.UTC(2026, 7, 29, 21, 1, 21); // 2026-08-29 21:01:21 UTC + +describe("Z.AI GLM weekly quota — absolute ISO reset", () => { + it("looksLikeQuotaExhausted matches the [1310] weekly/monthly body", () => { + assert.equal(looksLikeQuotaExhausted(GLM_BODY), true); + }); + + it("shouldPreserveQuotaSignals is true for zai with this body", () => { + assert.equal(shouldPreserveQuotaSignals("zai", GLM_BODY), true); + }); + + it("isWeeklyUsageLimitText matches weekly/monthly limit wording", () => { + assert.equal(isWeeklyUsageLimitText(GLM_BODY.toLowerCase()), true); + }); + + it("parseIsoDateTimeResetMs reads a space-separated naive datetime as UTC", () => { + assert.equal(parseIsoDateTimeResetMs(GLM_BODY, MAX_MS, NOW), RESET - NOW); + }); + + it("parseIsoDateTimeResetMs accepts the T separator and an explicit Z", () => { + assert.equal( + parseIsoDateTimeResetMs("reset at 2026-08-29T21:01:21Z", MAX_MS, NOW), + RESET - NOW + ); + }); + + it("parseIsoDateTimeResetMs honours an explicit UTC offset", () => { + // 23:01:21+02:00 is the same instant as 21:01:21Z. + assert.equal( + parseIsoDateTimeResetMs("reset at 2026-08-29 23:01:21+02:00", MAX_MS, NOW), + RESET - NOW + ); + assert.equal( + parseIsoDateTimeResetMs("reset at 2026-08-29 23:01:21+0200", MAX_MS, NOW), + RESET - NOW + ); + }); + + it("parseIsoDateTimeResetMs returns null for a past reset and caps at maxMs", () => { + assert.equal(parseIsoDateTimeResetMs("reset at 2026-08-22 10:00:00", MAX_MS, NOW), null); + assert.equal(parseIsoDateTimeResetMs("reset at 2027-08-29 21:01:21", MAX_MS, NOW), MAX_MS); + }); + + it("parseDayGranularityResetMs returns the real reset, not the 24h cap", () => { + const waitMs = parseDayGranularityResetMs(GLM_BODY, MAX_MS, NOW); + assert.equal(waitMs, RESET - NOW); + assert.ok(waitMs! > DAY_MS, `expected more than 24h, got ${waitMs}`); + }); + + it("keeps the Qwen year-less MM-DD parser working", () => { + const qwenBody = + "Your token-plan 1-week quota has been exhausted. The quota will reset at 08-29 15:29:00 UTC."; + const expected = Date.UTC(2026, 7, 29, 15, 29, 0) - NOW; + assert.equal(parseMonthDayResetMs(qwenBody, MAX_MS, NOW), expected); + assert.equal(parseDayGranularityResetMs(qwenBody, MAX_MS, NOW), expected); + }); + + it("keeps the 'reset in N days' parser winning over the ISO branch", () => { + assert.equal(parseDayGranularityResetMs("quota will reset in 3 days", MAX_MS, NOW), 3 * DAY_MS); + }); + + it("buildWeeklyQuotaFallback uses the parsed ISO reset, not the 24h default", () => { + const result = buildWeeklyQuotaFallback(GLM_BODY); + assert.ok(result); + assert.equal(result!.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(result!.usedUpstreamRetryHint, true); + assert.ok( + result!.cooldownMs > 5 * DAY_MS, + `expected a multi-day cooldown, got ${result!.cooldownMs}` + ); + assert.ok(result!.cooldownMs <= MAX_MS); + assert.ok(result!.cooldownMs !== DAY_MS, "must not fall back to WEEKLY_QUOTA_COOLDOWN_MS (24h)"); + }); + + it("checkFallbackError classifies the GLM 429 as QUOTA_EXHAUSTED with the real wait", async () => { + const { checkFallbackError, parseRetryFromErrorText } = await import( + "../../open-sse/services/accountFallback.ts" + ); + + const parsed = parseRetryFromErrorText(GLM_BODY); + assert.ok(parsed && parsed > 5 * DAY_MS, `parsed reset was ${parsed}`); + + const out = checkFallbackError(429, GLM_BODY, 0, "glm-5.3", "zai", null, null, null); + assert.equal(out.shouldFallback, true); + assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.ok( + (out.cooldownMs ?? 0) > 5 * DAY_MS, + `expected a multi-day cooldown, got ${out.cooldownMs}` + ); + assert.ok((out.cooldownMs ?? 0) !== DAY_MS, "must not land on the 24h weekly default"); + }); +});