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.
This commit is contained in:
sprintberlin
2026-08-24 17:12:26 +02:00
committed by GitHub
parent f88aa48847
commit e1c2b347f9
3 changed files with 260 additions and 9 deletions

View File

@@ -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);
}

View File

@@ -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,
};
}

View File

@@ -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");
});
});