agent-ecosystem/src/shared/utils/rateLimitDetector.ts

331 lines
12 KiB
TypeScript

/**
* Detects rate limit messages from Claude and parses reset time from them.
*/
const RATE_LIMIT_SUBSTRING = "You've hit your limit";
const MODEL_COOLDOWN_CODE = 'model_cooldown';
interface StructuredRateLimitPayload {
code: string | null;
message: string | null;
resetSeconds: number | null;
resetTime: string | null;
}
/**
* Returns true if the message text contains the rate limit indicator.
*/
export function isRateLimitMessage(text: string): boolean {
if (!text) return false;
if (text.includes(RATE_LIMIT_SUBSTRING)) return true;
const structured = extractStructuredRateLimitPayload(text);
return structured ? isStructuredRateLimitPayload(structured) : false;
}
// ---------------------------------------------------------------------------
// Reset-time parsing
// ---------------------------------------------------------------------------
/**
* Maps known Claude timezone abbreviations to fixed UTC offsets in minutes.
* We only include zones Claude's API has been observed to emit. When the
* message contains an explicit parenthesized timezone that is NOT in this
* map, the parser returns `null` rather than guessing. When no timezone is
* present at all, the hour:minute is treated as user-local time.
*/
const TIMEZONE_OFFSETS_MIN: Record<string, number> = {
UTC: 0,
GMT: 0,
// North America — standard times
EST: -5 * 60,
CST: -6 * 60,
MST: -7 * 60,
PST: -8 * 60,
// North America — daylight times
EDT: -4 * 60,
CDT: -5 * 60,
MDT: -6 * 60,
PDT: -7 * 60,
};
/**
* Attempts to parse the reset time from a Claude rate-limit message.
*
* Supported formats (case-insensitive):
* - "limit will reset at 3pm (PST)"
* - "limit will reset at 3:30 pm (PST)"
* - "limit will reset at 15:30 UTC"
* - "resets at 3pm" (local time assumed)
* - "resets in 2 hours"
* - "resets in 45 minutes"
*
* Returns `null` when the reset time cannot be extracted reliably. Also returns
* null for text that does not look like a rate-limit message, so the parser is
* safe to call on arbitrary strings.
*
* @param text the full rate-limit message text
* @param now reference "now" used to resolve wall-clock times and relative
* offsets (exposed for testability; defaults to `new Date()`)
*/
export function parseRateLimitResetTime(text: string, now: Date = new Date()): Date | null {
if (!text) return null;
// Defensive gate: only parse text that actually looks like a rate-limit
// message. Prevents false positives from unrelated prose containing
// words like "reset" (e.g. "reset the 5pm meeting").
if (!isRateLimitMessage(text)) return null;
const structured = extractStructuredRateLimitPayload(text);
if (structured && isStructuredRateLimitPayload(structured)) {
const structuredReset = parseStructuredResetTime(structured, now);
if (structuredReset) {
return structuredReset;
}
}
const relative = parseRelativeResetDuration(text);
if (relative !== null) {
return new Date(now.getTime() + relative);
}
return parseAbsoluteResetClockTime(text, now);
}
/**
* Matches trailing qualifiers that shift the reset to a different day.
* When present, we can't reliably resolve the date without more context, so
* the parser bails out. Example: "reset at 3pm (PST) next week" — the naive
* "today or tomorrow" rollover would fire in hours instead of a week.
*/
const DAY_SHIFT_QUALIFIER_RE =
/\b(?:next\s+week|next\s+month|tomorrow|yesterday|on\s+(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*)\b/i;
// ---------------------------------------------------------------------------
// Relative durations: "resets in 2 hours", "resets in 45 minutes"
// ---------------------------------------------------------------------------
const RESET_VERB_RE = /\breset(?:s|ting)?\b/i;
const LEADING_FILLER_RE = /^(?:about|around)\s+/i;
const LEADING_TIME_VALUE_RE = /^(\d+(?:\.\d+)?)\s*([a-z]+)\b/i;
function parseRelativeResetDuration(text: string): number | null {
const resetVerbMatch = RESET_VERB_RE.exec(text);
if (!resetVerbMatch) return null;
const afterVerb = text.slice(resetVerbMatch.index + resetVerbMatch[0].length).trimStart();
if (!afterVerb.toLowerCase().startsWith('in')) return null;
let tail = afterVerb.slice(2).trimStart();
if (tail.startsWith('~')) {
tail = tail.slice(1).trimStart();
}
tail = tail.replace(LEADING_FILLER_RE, '');
const match = LEADING_TIME_VALUE_RE.exec(tail);
if (!match) return null;
const amount = Number.parseFloat(match[1]);
if (!Number.isFinite(amount) || amount < 0) return null;
const unit = match[2].toLowerCase();
if (['second', 'seconds', 'sec', 'secs', 's'].includes(unit)) {
return Math.round(amount * 1000);
}
if (['minute', 'minutes', 'min', 'mins', 'm'].includes(unit)) {
return Math.round(amount * 60 * 1000);
}
if (['hour', 'hours', 'hr', 'hrs', 'h'].includes(unit)) {
return Math.round(amount * 60 * 60 * 1000);
}
return null;
}
function extractStructuredRateLimitPayload(text: string): StructuredRateLimitPayload | null {
const trimmed = text.trim();
if (!trimmed) return null;
const prefixedMatch = /^(?:API Error:\s*\d+\s+|\d+\s+)?(\{[\s\S]*\})$/i.exec(trimmed);
const jsonCandidate = prefixedMatch?.[1] ?? (trimmed.startsWith('{') ? trimmed : null);
if (!jsonCandidate) return null;
try {
const parsed = JSON.parse(jsonCandidate) as {
error?: {
code?: unknown;
message?: unknown;
reset_seconds?: unknown;
reset_time?: unknown;
};
code?: unknown;
message?: unknown;
reset_seconds?: unknown;
reset_time?: unknown;
};
const errorPayload = parsed.error;
return {
code: readStringField(errorPayload?.code) ?? readStringField(parsed.code),
message: readStringField(errorPayload?.message) ?? readStringField(parsed.message),
resetSeconds:
readNumberField(errorPayload?.reset_seconds) ?? readNumberField(parsed.reset_seconds),
resetTime: readStringField(errorPayload?.reset_time) ?? readStringField(parsed.reset_time),
};
} catch {
return null;
}
}
function isStructuredRateLimitPayload(payload: StructuredRateLimitPayload): boolean {
const code = payload.code?.trim().toLowerCase();
if (code === MODEL_COOLDOWN_CODE) {
return true;
}
const message = payload.message?.trim().toLowerCase() ?? '';
return (
(message.includes('cooling down') || message.includes('model cooldown')) &&
(payload.resetSeconds !== null || payload.resetTime !== null)
);
}
function parseStructuredResetTime(payload: StructuredRateLimitPayload, now: Date): Date | null {
if (payload.resetSeconds !== null) {
return new Date(now.getTime() + Math.max(0, payload.resetSeconds) * 1000);
}
const resetTime = payload.resetTime?.trim();
if (!resetTime) return null;
const relative = parseRelativeResetDuration(`Resets in ${resetTime}`);
if (relative !== null) {
return new Date(now.getTime() + relative);
}
const absolute = Date.parse(resetTime);
return Number.isFinite(absolute) ? new Date(absolute) : null;
}
function readStringField(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function readNumberField(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
}
// ---------------------------------------------------------------------------
// Absolute clock times: "resets at 3pm (PST)", "resets at 15:30 UTC"
// ---------------------------------------------------------------------------
/**
* Captures the clock time + optional timezone abbreviation from phrases like
* "reset at 3pm (PST)" or "resets at 15:30 UTC".
*/
const LEADING_CLOCK_RE = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/i;
const PAREN_TZ_RE = /^\(([A-Za-z]{2,5})\)/;
const TRAILING_TZ_RE = /^([A-Za-z]{2,5})\b/;
function parseAbsoluteResetClockTime(text: string, now: Date): Date | null {
const resetVerbMatch = RESET_VERB_RE.exec(text);
if (!resetVerbMatch) return null;
let tail = text.slice(resetVerbMatch.index + resetVerbMatch[0].length).trimStart();
if (tail.toLowerCase().startsWith('at ')) {
tail = tail.slice(3).trimStart();
}
const match = LEADING_CLOCK_RE.exec(tail);
if (!match) return null;
tail = tail.slice(match[0].length).trimStart();
const parenthesizedTzMatch = PAREN_TZ_RE.exec(tail);
const bareWordMatch = parenthesizedTzMatch ? null : TRAILING_TZ_RE.exec(tail);
const bareTzMatch =
bareWordMatch && bareWordMatch[1].toUpperCase() in TIMEZONE_OFFSETS_MIN ? bareWordMatch : null;
const tzTokenLength = parenthesizedTzMatch?.[0].length ?? bareTzMatch?.[0].length ?? 0;
// If the text contains a day-shift qualifier ("next week", "on Tuesday",
// etc.), the "today or tomorrow" rollover below would produce a materially
// wrong time. Bail out and let the caller fall back to no auto-resume.
const afterMatch = tail.slice(tzTokenLength);
if (DAY_SHIFT_QUALIFIER_RE.test(afterMatch)) return null;
const hourRaw = Number.parseInt(match[1], 10);
const minuteRaw = match[2] ? Number.parseInt(match[2], 10) : 0;
const ampm = match[3]?.toLowerCase() ?? null;
const parenthesizedTz = parenthesizedTzMatch?.[1]?.toUpperCase() ?? '';
const trailingTz = bareTzMatch?.[1]?.toUpperCase() ?? '';
if (!Number.isFinite(hourRaw) || !Number.isFinite(minuteRaw)) return null;
if (minuteRaw < 0 || minuteRaw > 59) return null;
let hour = hourRaw;
if (ampm === 'pm' && hour < 12) hour += 12;
else if (ampm === 'am' && hour === 12) hour = 0;
if (hour < 0 || hour > 23) return null;
// Timezone resolution treats parenthesized vs bare tokens differently.
//
// "reset at 3pm (PST)" — parenthesized, authoritative. Unknown zone
// here means the sender meant a specific zone
// we don't model; bail out rather than guess.
// "reset at 3pm PST" — bare known abbreviation, same effect.
// "reset at 3pm today" — bare unknown word ("TODAY"). This is just a
// trailing word, not a real TZ claim; fall
// back to local time instead of suppressing.
// "reset at 3pm" — no token. Treat as user-local.
let tzOffset: number | null;
if (parenthesizedTz) {
if (!(parenthesizedTz in TIMEZONE_OFFSETS_MIN)) return null;
tzOffset = TIMEZONE_OFFSETS_MIN[parenthesizedTz]!;
} else if (trailingTz && trailingTz in TIMEZONE_OFFSETS_MIN) {
tzOffset = TIMEZONE_OFFSETS_MIN[trailingTz]!;
} else {
tzOffset = null;
}
const candidateSeed =
tzOffset === null
? buildLocalToday(now, hour, minuteRaw)
: buildUtcTodayWithOffset(now, hour, minuteRaw, tzOffset);
let candidate: Date = candidateSeed;
// If the computed time is materially in the past (e.g. "3pm" parsed while
// it's already 4pm), roll forward by one day. A small tolerance prevents
// near-present timestamps — stale messages, clock skew, sub-second drift —
// from being bumped 24 h forward, which would then trip the scheduler's
// 12 h ceiling and silently drop auto-resume altogether. Timestamps within
// `ROLLOVER_TOLERANCE_MS` of now fire immediately after the scheduler's
// own 30 s buffer and `Math.max(0, rawDelayMs)` clamp.
if (candidate.getTime() <= now.getTime() - ROLLOVER_TOLERANCE_MS) {
candidate = new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
}
return candidate;
}
const ROLLOVER_TOLERANCE_MS = 60 * 1000;
function buildLocalToday(now: Date, hour: number, minute: number): Date {
const d = new Date(now);
d.setHours(hour, minute, 0, 0);
return d;
}
function buildUtcTodayWithOffset(
now: Date,
hour: number,
minute: number,
offsetMinutes: number
): Date {
// The caller's "hour:minute" is expressed in the target zone. Anchor the
// calendar date in that zone too — not in UTC — otherwise we get a 24h
// error when the zone-local day differs from UTC's day (e.g. 01:00 UTC is
// still "yesterday" for any negative-offset zone like PST).
const zoned = new Date(now.getTime() + offsetMinutes * 60 * 1000);
const offsetMs = offsetMinutes * 60 * 1000;
return new Date(
Date.UTC(zoned.getUTCFullYear(), zoned.getUTCMonth(), zoned.getUTCDate(), hour, minute, 0, 0) -
offsetMs
);
}