diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index f0bf023ec..2d6af2212 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -3172,7 +3172,8 @@ export class AuthStorage { } if (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports) return false; - const prior = this.#usageCache.getStale(cacheKey)?.value; + const priorEntry = this.#usageCache.getStale(cacheKey); + const prior = priorEntry?.value; let merged = report; if (prior && Array.isArray(prior.limits)) { const headerLimitsById = new Map(report.limits.map(limit => [limit.id, limit])); @@ -3203,9 +3204,12 @@ export class AuthStorage { } // Header-only reports are last-good hints, not completed usage fetches. - // Keep them durable but stale so the next poll probes the provider; a - // failed probe still applies the normal short backoff in #fetchUsageCached. - const expiresAt = merged.metadata?.source === "ratelimit-headers" ? now - 1 : now + USAGE_REPORT_TTL_MS; + // Keep them durable but stale so the next poll probes the provider, while + // preserving any active failure backoff installed by #fetchUsageCached. + const expiresAt = + merged.metadata?.source === "ratelimit-headers" + ? Math.max(priorEntry?.expiresAt ?? now - 1, now - 1) + : now + USAGE_REPORT_TTL_MS; this.#usageCache.set(cacheKey, { value: merged, expiresAt }); this.#usageHeaderIngestAt.set(cacheKey, now); return true; diff --git a/packages/ai/test/auth-storage-usage-cache.test.ts b/packages/ai/test/auth-storage-usage-cache.test.ts index 0d74a74c9..48bb8910d 100644 --- a/packages/ai/test/auth-storage-usage-cache.test.ts +++ b/packages/ai/test/auth-storage-usage-cache.test.ts @@ -460,6 +460,38 @@ describe("AuthStorage usage cache: header ingestion", () => { expect(storage.ingestUsageHeaders("anthropic", usageHeaders("0.05", "0.6"), { sessionId: "s" })).toBe(false); }); + it("preserves a failed fetch cooldown across exhausted header ingestion, then retries after expiry", async () => { + const start = Date.now(); + const now = vi.spyOn(Date, "now").mockReturnValue(start); + let calls = 0; + vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => { + calls += 1; + return null; + }); + + expect(await storage.getApiKey("anthropic", "cooldown-session")).toBe("oat-1"); + expect( + storage.ingestUsageHeaders("anthropic", usageHeaders("0.02", "0.3"), { + sessionId: "cooldown-session", + }), + ).toBe(true); + expect(await storage.fetchUsageReports()).toHaveLength(1); + expect(calls).toBe(1); + + now.mockReturnValue(start + 1_000); + expect( + storage.ingestUsageHeaders("anthropic", usageHeaders("1", "0.3"), { + sessionId: "cooldown-session", + }), + ).toBe(true); + expect(await storage.fetchUsageReports()).toHaveLength(1); + expect(calls).toBe(1); + + now.mockReturnValue(start + 12_501); + expect(await storage.fetchUsageReports()).toHaveLength(1); + expect(calls).toBe(2); + }); + it("merges header umbrella windows onto the last real report and preserves tier limits", async () => { const realReport = makeTieredReport("a@example.com"); let calls = 0;