Files
AgentCompany/src/shared/provider-runtime-domain.ts
T
ZacharyZhang-NY 927bdc91f2 Self-healing queue: transient provider failures park the provider, never burn retries
The July 9 run died at 07:22 when the GPT account hit its plan-side usage
limit: every later request failed with "The usage limit has been reached",
each failure counted against the 3-attempt retry budget, and 31 tasks were
abandoned for good while the worker kept hammering 12 doomed requests a
minute until the queue drained.

Root fix, three parts sharing one classifier:
- isTransientProviderFailure() in provider-runtime-domain recognises
  provider-side verdicts (usage limit, rate limit, 429, overloaded) plus the
  internal monthly-quota message; matching is against recorded
  task_runs.error_message rows, so historical damage heals retroactively.
- enqueueHeartbeatRuns computes the retry budget with that predicate instead
  of an exact match on the internal quota string alone.
- A transient failure parks the provider for a 10-minute cooldown: the
  heartbeat stops enqueuing its tasks and the worker leaves its queued runs
  unclaimed, then a single probe run re-arms or clears the park. Transitions
  are logged once each way.

Tests: predicate unit tests, budget-exemption regression on the raw provider
message, cooldown park/resume with an injected clock, claim exclusion.
2026-07-09 19:04:44 -04:00

68 lines
2.6 KiB
TypeScript

export interface ProviderRateLimitStateRecord {
orgId: string;
providerId: string;
providerKey: string;
windowStart: string;
windowEnd: string;
monthlyRequestLimit: number;
requestCount: number;
inputCharCount: number;
outputCharCount: number;
remainingRequestCount: number;
exhausted: boolean;
}
export interface ProviderObservedRateLimitState {
requestLimit: number | null;
remainingRequests: number | null;
resetAt: string | null;
retryAfterMs: number | null;
rawStateJson: string;
}
export interface ProviderRateLimitObservationRecord extends ProviderObservedRateLimitState {
id: string;
orgId: string;
providerId: string;
providerKey: string;
model: string | null;
observedAt: string;
}
/**
* Exact message recorded on runs blocked by a provider's monthly usage window.
* The heartbeat scheduler matches task_runs.error_message against this string
* so quota-blocked attempts never consume a task's retry budget — the text is
* a cross-module contract and also matches rows written by earlier builds
* that inlined it.
*/
export const PROVIDER_MONTHLY_LIMIT_MESSAGE = "Provider monthly request limit has been reached.";
/**
* Failure-message signatures of transient provider conditions — the account's
* usage window filling up, rate limiting, or momentary overload. These verdicts
* come from the provider's side of the wire and say nothing about the task, so
* schedulers must treat them as "try again later", never as attempts consumed.
* Matched case-insensitively against recorded task_runs.error_message rows,
* which also heals historical rows written before this classification existed.
*/
const TRANSIENT_PROVIDER_FAILURE_SIGNATURES = [
PROVIDER_MONTHLY_LIMIT_MESSAGE,
"usage limit", // ChatGPT-account plans: "The usage limit has been reached"
"rate limit",
"too many requests", // HTTP 429 status text
"overloaded", // "Our servers are currently overloaded. Please try again later."
].map((signature) => signature.toLowerCase());
export function isTransientProviderFailure(message: string | null | undefined): boolean {
if (!message) return false;
const normalized = message.toLowerCase();
return TRANSIENT_PROVIDER_FAILURE_SIGNATURES.some((signature) => normalized.includes(signature));
}
export function currentProviderUsageWindow(now = new Date()): { start: string; end: string } {
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
return { start: start.toISOString(), end: end.toISOString() };
}