mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 16:42:16 +03:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
135
tests/unit/repro-glm-iso-reset-24h-cap.test.ts
Normal file
135
tests/unit/repro-glm-iso-reset-24h-cap.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user