Files
OmniRoute/open-sse/services/rateLimitManager/headers.ts
Nguyen Thanh Dat 466d0306be fix(resilience): parse RFC 3339 and fractional-second rate-limit resets (#13321)
Rate-limit reset headers arriving as RFC 3339 or with fractional seconds were not parsed, so the reset hint was silently dropped and the generic cooldown applied instead. Probe on your head: 10/10 pass in `tests/unit/ratelimitmanager-headers-split.test.ts`, including your 3 new RFC3339/fractional/unix-timestamp cases.

Thanks, @datrixlab — covering all three shapes in one pass, rather than only the one you hit, is why this needed no follow-up.

**Batch validation** — boarded with the other 10 PRs of your batch into one worktree cut from `release/v3.8.51`; every PR verified as an ancestor of the combined HEAD before validating.

- Focused tests across all 11 PRs: **104/104 pass** on the combined tree.
- Gates on the combined tree: `check-changelog-integrity` PASS, `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check:open-sse-typecheck` PASS.
- `check-file-size` is red, but reproduces with byte-identical line counts on the pure `release/v3.8.51` tip (`open-sse/handlers/imageGeneration.ts` 3304, `open-sse/services/combo/roundRobinCombo.ts` 1221, `open-sse/utils/stream.ts` 3115). Inherited base-red, nothing added by this batch — it is also why this PR's "Fast Quality Gates" check was red.
2026-09-16 21:02:04 -03:00

101 lines
3.1 KiB
TypeScript

// ─── Header Parsing ──────────────────────────────────────────────────────────
/**
* Standard headers used by most providers (OpenAI, Fireworks, etc.)
*/
export const STANDARD_HEADERS = {
limit: "x-ratelimit-limit-requests",
remaining: "x-ratelimit-remaining-requests",
reset: "x-ratelimit-reset-requests",
limitTokens: "x-ratelimit-limit-tokens",
remainingTokens: "x-ratelimit-remaining-tokens",
resetTokens: "x-ratelimit-reset-tokens",
retryAfter: "retry-after",
overLimit: "x-ratelimit-over-limit",
};
/**
* Anthropic uses custom headers
*/
export const ANTHROPIC_HEADERS = {
limit: "anthropic-ratelimit-requests-limit",
remaining: "anthropic-ratelimit-requests-remaining",
reset: "anthropic-ratelimit-requests-reset",
limitTokens: "anthropic-ratelimit-input-tokens-limit",
remainingTokens: "anthropic-ratelimit-input-tokens-remaining",
resetTokens: "anthropic-ratelimit-input-tokens-reset",
retryAfter: "retry-after",
};
/**
* Parse a reset time string into milliseconds.
* Formats: "1s", "1m", "1h", "1ms", "2m59.56s", "60", ISO date, Unix timestamp
*/
export function parseResetTime(value) {
const text = value?.trim();
if (!text) return null;
// Duration strings: "1s", "500ms", "1m30s", "2m59.56s"
const durationMatch = text.match(
/^(?:(\d+)h)?(?:(\d+)m(?!s))?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$/
);
if (durationMatch) {
const [, h, m, s, ms] = durationMatch;
return Math.round(
(parseInt(h || 0) * 3600 + parseInt(m || 0) * 60 + parseFloat(s || 0)) * 1000 +
parseFloat(ms || 0)
);
}
// Pure number: assume seconds. Test the whole string — parseFloat alone also reads
// the leading year of an ISO date ("2026-09-11T06:27:29Z" → 2026).
if (/^\d+(?:\.\d+)?$/.test(text)) {
const num = parseFloat(text);
if (num > 0) {
// If it looks like a Unix timestamp (> year 2025)
if (num > 1700000000) {
return Math.max(0, num * 1000 - Date.now());
}
return num * 1000;
}
}
// ISO date string (Anthropic's anthropic-ratelimit-*-reset headers are RFC 3339)
try {
const date = new Date(text);
if (!isNaN(date.getTime())) {
return Math.max(0, date.getTime() - Date.now());
}
} catch {}
return null;
}
export function toPlainHeaders(headers: unknown): Record<string, string> {
if (!headers) return {};
const plain: Record<string, string> = {};
const obj = headers as Record<string, unknown>;
if (typeof obj.forEach === "function") {
try {
(obj.forEach as (cb: (v: string, k: string) => void) => void)((v: string, k: string) => {
plain[k.toLowerCase()] = v;
});
return plain;
} catch {}
}
if (typeof obj.entries === "function") {
try {
for (const [k, v] of (obj.entries as () => Iterable<[string, string]>)()) {
plain[k.toLowerCase()] = v;
}
return plain;
} catch {}
}
try {
for (const [k, v] of Object.entries(obj)) {
plain[k.toLowerCase()] = v == null ? "" : String(v);
}
} catch {}
return plain;
}