diff --git a/package.json b/package.json index 02ea651e9b..60aceffc34 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "test:model-pool": "pnpm run test:model-pool:vitest && npx tsx test/continuous-test-suite-model-pool.ts", "test:model-capabilities:vitest": "pnpm exec vitest run test/modelCapabilities.test.ts", "test:agent-runtime:vitest": "pnpm exec vitest run test/agentRuntime.test.ts test/agentDelegation.test.ts test/agentPlumbing.test.ts test/toolExecutionRecorder.test.ts test/samplingParams.test.ts test/structuredRecovery.test.ts", + "test:retry-after:vitest": "pnpm exec vitest run test/retryAfter.test.ts", "test:ci": "pnpm run test && pnpm run test:client && pnpm run test:hitl", "// CI tier — fast, no live AI calls, safe for every commit": "", "test:unit:vitest": "pnpm exec vitest run test/toolRouting.test.ts", @@ -152,7 +153,7 @@ "test:system-messages:vitest": "pnpm exec vitest run test/systemMessages.test.ts", "test:tool-routing-semantic:vitest": "pnpm exec vitest run test/toolRoutingSemantic.test.ts", "test:tool-routing-semantic": "pnpm run test:tool-routing-semantic:vitest && npx tsx test/continuous-test-suite-tool-routing-semantic.ts", - "test:unit": "pnpm run test:envguard && pnpm run test:bugfixes && pnpm run test:file-detector-extension && pnpm run test:file-detector-magic-bytes && pnpm run test:mcp:infra && pnpm run test:mcp:bash && pnpm run test:mcp:limits && pnpm run test:mcp:spans && pnpm run test:autoresearch:redis && pnpm run test:unit:vitest && pnpm run test:tool-routing-cli:vitest && pnpm run test:tool-dedup:vitest && pnpm run test:model-pool:vitest && pnpm run test:litellm-context:vitest && pnpm run test:step-budget-guard:vitest && pnpm run test:system-messages:vitest && pnpm run test:tool-routing-semantic:vitest && pnpm run test:anthropic-tools-policy && pnpm run test:sagemaker-tools && pnpm run test:anthropic-multimodal && pnpm run test:excel-interop && pnpm run test:model-capabilities:vitest && pnpm run test:agent-runtime:vitest", + "test:unit": "pnpm run test:envguard && pnpm run test:bugfixes && pnpm run test:file-detector-extension && pnpm run test:file-detector-magic-bytes && pnpm run test:mcp:infra && pnpm run test:mcp:bash && pnpm run test:mcp:limits && pnpm run test:mcp:spans && pnpm run test:autoresearch:redis && pnpm run test:unit:vitest && pnpm run test:tool-routing-cli:vitest && pnpm run test:tool-dedup:vitest && pnpm run test:model-pool:vitest && pnpm run test:litellm-context:vitest && pnpm run test:step-budget-guard:vitest && pnpm run test:system-messages:vitest && pnpm run test:tool-routing-semantic:vitest && pnpm run test:anthropic-tools-policy && pnpm run test:sagemaker-tools && pnpm run test:anthropic-multimodal && pnpm run test:excel-interop && pnpm run test:model-capabilities:vitest && pnpm run test:agent-runtime:vitest && pnpm run test:retry-after:vitest", "// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit)": "", "test:live": "pnpm run test:providers && pnpm run test:mcp:http && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:observability && pnpm run test:context && pnpm run test:memory && pnpm run test:tool-reliability && pnpm run test:evaluation && pnpm run test:autoresearch && pnpm run test:credentials && pnpm run test:dynamic", "// CI tier — product output (image/video/TTS/PPT) — costs $$ per run": "", diff --git a/src/lib/adapters/replicate/predictionLifecycle.ts b/src/lib/adapters/replicate/predictionLifecycle.ts index 0c035f94ed..7c8a2d71b0 100644 --- a/src/lib/adapters/replicate/predictionLifecycle.ts +++ b/src/lib/adapters/replicate/predictionLifecycle.ts @@ -18,6 +18,8 @@ import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js"; import { logger } from "../../utils/logger.js"; import { NeuroLinkError, ERROR_CODES } from "../../utils/errorHandling.js"; import { sanitizeForLog } from "../../utils/logSanitize.js"; +import { withProviderRetry } from "../../utils/providerRetry.js"; +import { parseRetryAfterMs } from "../../utils/retryAfter.js"; import { safeDownload } from "../../utils/safeFetch.js"; import { MAX_VIDEO_BYTES } from "../../utils/sizeGuard.js"; import type { @@ -43,6 +45,7 @@ const DEFAULT_TOTAL_TIMEOUT_MS = 5 * 60_000; export async function createPrediction( auth: ReplicateAuth, input: ReplicateCreatePredictionInput, + sleep?: (delayMs: number) => Promise, ): Promise { const baseUrl = auth.baseUrl ?? "https://api.replicate.com"; const [modelPath, version] = input.model.split(":", 2); @@ -62,52 +65,62 @@ export async function createPrediction( body.webhook_events_filter = input.webhookEventsFilter; } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + return withProviderRetry( + async () => { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS, + ); - let response: Response; - try { - response = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Token ${auth.apiToken}`, - "Content-Type": "application/json", - Prefer: "wait=60", - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - } catch (err: unknown) { - if (err instanceof NeuroLinkError) { - throw err; - } - if (err instanceof Error && err.name === "AbortError") { - throw new NeuroLinkError({ - code: ERROR_CODES.OPERATION_ABORTED, - message: `Replicate predictions submit timed out after ${REQUEST_TIMEOUT_MS / 1000}s`, - category: ErrorCategory.TIMEOUT, - severity: ErrorSeverity.HIGH, - retriable: true, - originalError: err, - }); - } - throw err; - } finally { - clearTimeout(timeoutId); - } + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Token ${auth.apiToken}`, + "Content-Type": "application/json", + Prefer: "wait=60", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); - if (!response.ok) { - const raw = await response.text(); - throw new NeuroLinkError({ - code: ERROR_CODES.PROVIDER_NOT_AVAILABLE, - message: `Replicate predictions submit failed: ${response.status} — ${sanitizeForLog(raw, 500)}`, - category: ErrorCategory.NETWORK, - severity: ErrorSeverity.HIGH, - retriable: response.status >= 500, - }); - } + if (!response.ok) { + const raw = await response.text(); + throw new NeuroLinkError({ + code: ERROR_CODES.PROVIDER_NOT_AVAILABLE, + message: `Replicate predictions submit failed: ${response.status} — ${sanitizeForLog(raw, 500)}`, + category: ErrorCategory.NETWORK, + severity: ErrorSeverity.HIGH, + retriable: response.status >= 500 || response.status === 429, + retryAfterMs: parseRetryAfterMs(response.headers), + }); + } - return (await response.json()) as ReplicatePrediction; + return (await response.json()) as ReplicatePrediction; + } catch (error: unknown) { + if (error instanceof NeuroLinkError) { + throw error; + } + if (error instanceof Error && error.name === "AbortError") { + throw new NeuroLinkError({ + code: ERROR_CODES.OPERATION_ABORTED, + message: `Replicate predictions submit timed out after ${REQUEST_TIMEOUT_MS / 1000}s`, + category: ErrorCategory.TIMEOUT, + severity: ErrorSeverity.HIGH, + retriable: true, + originalError: error, + }); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + }, + undefined, + "Replicate prediction submission", + sleep, + ); } /** @@ -188,7 +201,8 @@ export async function pollPrediction( message: `Replicate poll failed: ${response.status} — ${sanitizeForLog(raw, 500)}`, category: ErrorCategory.NETWORK, severity: ErrorSeverity.HIGH, - retriable: response.status >= 500, + retriable: response.status >= 500 || response.status === 429, + retryAfterMs: parseRetryAfterMs(response.headers), }); } diff --git a/src/lib/mcp/httpRateLimiter.ts b/src/lib/mcp/httpRateLimiter.ts index 91bfeb3275..9a6627ba53 100644 --- a/src/lib/mcp/httpRateLimiter.ts +++ b/src/lib/mcp/httpRateLimiter.ts @@ -5,6 +5,7 @@ */ import { mcpLogger } from "../utils/logger.js"; +import { parseRetryAfterMs } from "../utils/retryAfter.js"; import type { TokenBucketRateLimitConfig, RateLimiterStats, @@ -229,61 +230,54 @@ export class HTTPRateLimiter { * @returns Wait time in milliseconds, or 0 if no rate limit headers found */ handleRateLimitResponse(headers: Headers): number { - // Check for Retry-After header (standard HTTP 429 response) + const parsedWaitTimeMs = parseRetryAfterMs(headers); + + // Keep the existing source-specific logs while delegating delay parsing. const retryAfter = headers.get("Retry-After"); - if (retryAfter) { - // Retry-After can be either a number of seconds or an HTTP-date + if (retryAfter && parsedWaitTimeMs !== undefined) { const seconds = parseInt(retryAfter, 10); if (!isNaN(seconds)) { - // It's a number of seconds - const waitTimeMs = seconds * 1000; mcpLogger.info( `[HTTPRateLimiter] Server requested retry after ${seconds} seconds`, ); - return waitTimeMs; + return parsedWaitTimeMs; } else { - // Try to parse as HTTP-date const retryDate = new Date(retryAfter); if (!isNaN(retryDate.getTime())) { - const waitTimeMs = Math.max(0, retryDate.getTime() - Date.now()); mcpLogger.info( - `[HTTPRateLimiter] Server requested retry at ${retryDate.toISOString()} (${waitTimeMs}ms)`, + `[HTTPRateLimiter] Server requested retry at ${retryDate.toISOString()} (${parsedWaitTimeMs}ms)`, ); - return waitTimeMs; + return parsedWaitTimeMs; } } } // Check for X-RateLimit-Reset header (common non-standard header) const rateLimitReset = headers.get("X-RateLimit-Reset"); - if (rateLimitReset) { + if (rateLimitReset && parsedWaitTimeMs !== undefined) { const resetTimestamp = parseInt(rateLimitReset, 10); if (!isNaN(resetTimestamp)) { - // Could be Unix timestamp (seconds) or milliseconds const resetTime = resetTimestamp > 1e12 ? resetTimestamp : resetTimestamp * 1000; - const waitTimeMs = Math.max(0, resetTime - Date.now()); mcpLogger.info( - `[HTTPRateLimiter] Rate limit resets at ${new Date(resetTime).toISOString()} (${waitTimeMs}ms)`, + `[HTTPRateLimiter] Rate limit resets at ${new Date(resetTime).toISOString()} (${parsedWaitTimeMs}ms)`, ); - return waitTimeMs; + return parsedWaitTimeMs; } } // Check for X-RateLimit-Remaining header const remaining = headers.get("X-RateLimit-Remaining"); - if (remaining === "0") { - // No remaining requests, use default backoff - const defaultBackoffMs = 1000; + if (remaining === "0" && parsedWaitTimeMs !== undefined) { mcpLogger.info( - `[HTTPRateLimiter] Rate limit exhausted, using default backoff: ${defaultBackoffMs}ms`, + `[HTTPRateLimiter] Rate limit exhausted, using default backoff: ${parsedWaitTimeMs}ms`, ); - return defaultBackoffMs; + return parsedWaitTimeMs; } - return 0; + return parsedWaitTimeMs ?? 0; } /** diff --git a/src/lib/utils/errorHandling.ts b/src/lib/utils/errorHandling.ts index ec5ea793b8..4f30b4ff6b 100644 --- a/src/lib/utils/errorHandling.ts +++ b/src/lib/utils/errorHandling.ts @@ -103,6 +103,7 @@ export class NeuroLinkError extends Error { public readonly category: ErrorCategory; public readonly severity: ErrorSeverity; public readonly retriable: boolean; + public readonly retryAfterMs?: number; public readonly context: Record; public readonly timestamp: Date; public readonly toolName?: string; @@ -114,6 +115,7 @@ export class NeuroLinkError extends Error { category: ErrorCategory; severity: ErrorSeverity; retriable: boolean; + retryAfterMs?: number; context?: Record; originalError?: Error; toolName?: string; @@ -125,6 +127,7 @@ export class NeuroLinkError extends Error { this.category = options.category; this.severity = options.severity; this.retriable = options.retriable; + this.retryAfterMs = options.retryAfterMs; this.context = options.context || {}; this.timestamp = new Date(); this.toolName = options.toolName; diff --git a/src/lib/utils/providerRetry.ts b/src/lib/utils/providerRetry.ts index 217e822f65..f64bd5b2cb 100644 --- a/src/lib/utils/providerRetry.ts +++ b/src/lib/utils/providerRetry.ts @@ -15,8 +15,10 @@ */ import { type Span } from "@opentelemetry/api"; +import { NeuroLinkError } from "./errorHandling.js"; import { logger } from "./logger.js"; import { APICallError } from "./generationErrors.js"; +import { parseRetryAfterMs } from "./retryAfter.js"; /** Maximum number of retry attempts after the initial call (total = 1 + MAX_PROVIDER_RETRIES). */ export const MAX_PROVIDER_RETRIES = 2; @@ -24,6 +26,15 @@ export const MAX_PROVIDER_RETRIES = 2; /** Base delay in ms for exponential backoff between retries. */ export const BASE_RETRY_DELAY_MS = 1000; +/** Minimum delay in ms when a retryable response provides no retry timing. */ +export const NO_HINT_FLOOR_MS = 10_000; + +/** Maximum server-requested retry delay honored by provider retries. */ +export const MAX_RETRY_AFTER_MS = 120_000; + +const sleepWithTimeout = (delayMs: number): Promise => + new Promise((resolve) => setTimeout(resolve, delayMs)); + /** * Check whether an error thrown by the AI SDK is retryable. * @@ -37,6 +48,10 @@ export function isRetryableProviderError(error: unknown): boolean { return error.isRetryable; } + if (error instanceof NeuroLinkError) { + return error.retriable; + } + // Fallback: duck-type for status codes on errors that aren't APICallError if (error && typeof error === "object" && "statusCode" in error) { const statusCode = (error as { statusCode: number }).statusCode; @@ -59,6 +74,21 @@ export function getErrorStatusCode(error: unknown): number | undefined { return undefined; } +function getRetryAfterMs(error: unknown): number | undefined { + if (APICallError.isInstance(error) && error.responseHeaders) { + const parsedDelay = parseRetryAfterMs(error.responseHeaders); + if (parsedDelay !== undefined) { + return parsedDelay; + } + } + + if (error instanceof NeuroLinkError && error.retryAfterMs !== undefined) { + return error.retryAfterMs; + } + + return undefined; +} + /** * Execute a provider call with instrumented retry logic. * @@ -69,15 +99,16 @@ export function getErrorStatusCode(error: unknown): number | undefined { */ export async function withProviderRetry( operation: () => Promise, - span: Span, + span: Span | undefined, label: string, + sleep: (delayMs: number) => Promise = sleepWithTimeout, ): Promise { for (let attempt = 0; attempt <= MAX_PROVIDER_RETRIES; attempt++) { try { const result = await operation(); // Record how many attempts it took on the span - span.setAttribute("gen_ai.provider.total_attempts", attempt + 1); + span?.setAttribute("gen_ai.provider.total_attempts", attempt + 1); if (attempt > 0) { logger.info( @@ -94,9 +125,9 @@ export async function withProviderRetry( if (!retryable || attempt === MAX_PROVIDER_RETRIES) { // Record failure details before re-throwing - span.setAttribute("gen_ai.provider.total_attempts", attempt + 1); + span?.setAttribute("gen_ai.provider.total_attempts", attempt + 1); if (attempt > 0) { - span.setAttribute("gen_ai.provider.retries_exhausted", true); + span?.setAttribute("gen_ai.provider.retries_exhausted", true); } logger.warn( @@ -112,11 +143,17 @@ export async function withProviderRetry( throw error; } - // Calculate exponential backoff delay - const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt); + const retryAfterMs = getRetryAfterMs(error); + const boundedRetryAfterMs = + retryAfterMs === undefined + ? undefined + : Math.min(MAX_RETRY_AFTER_MS, Math.max(0, retryAfterMs)); + const delay = + boundedRetryAfterMs ?? + Math.max(BASE_RETRY_DELAY_MS * Math.pow(2, attempt), NO_HINT_FLOOR_MS); // Record retry event on the OTel span - span.addEvent("gen_ai.provider.retry", { + span?.addEvent("gen_ai.provider.retry", { "retry.attempt": attempt + 1, "retry.delay_ms": delay, ...(statusCode !== undefined && { "retry.status_code": statusCode }), @@ -134,7 +171,7 @@ export async function withProviderRetry( }, ); - await new Promise((r) => setTimeout(r, delay)); + await sleep(delay); } } diff --git a/src/lib/utils/retryAfter.ts b/src/lib/utils/retryAfter.ts new file mode 100644 index 0000000000..8dd2d13460 --- /dev/null +++ b/src/lib/utils/retryAfter.ts @@ -0,0 +1,57 @@ +function getHeader( + headers: Pick | Readonly>, + headerName: string, +): string | null { + if ("get" in headers && typeof headers.get === "function") { + return headers.get(headerName); + } + + const normalizedHeaderName = headerName.toLowerCase(); + for (const [name, value] of Object.entries(headers)) { + if (name.toLowerCase() === normalizedHeaderName) { + return value; + } + } + + return null; +} + +/** + * Parse standard and commonly used rate-limit response headers. + * + * @returns The requested delay in milliseconds, or undefined when the + * response does not provide a usable rate-limit delay. + */ +export function parseRetryAfterMs( + headers: Pick | Readonly>, +): number | undefined { + const retryAfter = getHeader(headers, "Retry-After"); + + if (retryAfter) { + const seconds = parseInt(retryAfter, 10); + if (!Number.isNaN(seconds)) { + return Math.max(0, seconds * 1000); + } + + const retryDate = new Date(retryAfter); + if (!Number.isNaN(retryDate.getTime())) { + return Math.max(0, retryDate.getTime() - Date.now()); + } + } + + const rateLimitReset = getHeader(headers, "X-RateLimit-Reset"); + if (rateLimitReset) { + const resetTimestamp = parseInt(rateLimitReset, 10); + if (!Number.isNaN(resetTimestamp)) { + const resetTime = + resetTimestamp > 1e12 ? resetTimestamp : resetTimestamp * 1000; + return Math.max(0, resetTime - Date.now()); + } + } + + if (getHeader(headers, "X-RateLimit-Remaining") === "0") { + return 1000; + } + + return undefined; +} diff --git a/test/retryAfter.test.ts b/test/retryAfter.test.ts new file mode 100644 index 0000000000..070fa32a12 --- /dev/null +++ b/test/retryAfter.test.ts @@ -0,0 +1,221 @@ +import { APICallError } from "../src/lib/utils/generationErrors.js"; +import { + MAX_RETRY_AFTER_MS, + withProviderRetry, +} from "../src/lib/utils/providerRetry.js"; +import { parseRetryAfterMs } from "../src/lib/utils/retryAfter.js"; +import { createPrediction } from "../src/lib/adapters/replicate/predictionLifecycle.js"; +import { NeuroLinkError } from "../src/lib/utils/errorHandling.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const replicateAuth = { + apiToken: "test-token", + baseUrl: "https://example.invalid", +}; + +const replicateInput = { + model: "owner/model", + input: { prompt: "test prompt" }, +}; + +function createRetryableError( + responseHeaders?: Record, +): APICallError { + return new APICallError({ + message: "Provider request was rate limited", + url: "https://example.invalid/v1/generate", + requestBodyValues: {}, + statusCode: 429, + responseHeaders, + isRetryable: true, + }); +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("Retry-After parsing", () => { + it("parses a numeric Retry-After value as seconds", () => { + expect(parseRetryAfterMs(new Headers({ "Retry-After": "7" }))).toBe(7_000); + }); + + it("parses an HTTP-date Retry-After value relative to the current time", () => { + vi.useFakeTimers(); + vi.setSystemTime(Date.UTC(2026, 0, 1, 0, 0, 0)); + + const retryAt = new Date(Date.now() + 15_000).toUTCString(); + + expect(parseRetryAfterMs(new Headers({ "Retry-After": retryAt }))).toBe( + 15_000, + ); + }); + + it("returns undefined when rate-limit headers are absent", () => { + expect(parseRetryAfterMs(new Headers())).toBeUndefined(); + }); + + it("returns undefined when Retry-After is invalid", () => { + expect( + parseRetryAfterMs(new Headers({ "Retry-After": "not-a-date" })), + ).toBeUndefined(); + }); +}); + +describe("provider retry timing", () => { + it("waits for the Retry-After header duration before retrying an AI SDK error", async () => { + const operation = vi + .fn<() => Promise>() + .mockRejectedValueOnce(createRetryableError({ "retry-after": "3" })) + .mockResolvedValue("success"); + const recordedDelays: number[] = []; + + await expect( + withProviderRetry( + operation, + undefined, + "test provider call", + async (ms) => { + recordedDelays.push(ms); + }, + ), + ).resolves.toBe("success"); + + expect(recordedDelays).toEqual([3_000]); + }); + + it("waits at least ten seconds when a retryable error has no server delay", async () => { + const operation = vi + .fn<() => Promise>() + .mockRejectedValueOnce(createRetryableError()) + .mockResolvedValue("success"); + const recordedDelays: number[] = []; + + await withProviderRetry( + operation, + undefined, + "test provider call", + async (ms) => { + recordedDelays.push(ms); + }, + ); + + expect(recordedDelays).toEqual([10_000]); + }); + + it("caps a Retry-After delay at two minutes", async () => { + const operation = vi + .fn<() => Promise>() + .mockRejectedValueOnce(createRetryableError({ "Retry-After": "300" })) + .mockResolvedValue("success"); + const recordedDelays: number[] = []; + + await withProviderRetry( + operation, + undefined, + "test provider call", + async (ms) => { + recordedDelays.push(ms); + }, + ); + + expect(recordedDelays).toEqual([MAX_RETRY_AFTER_MS]); + }); +}); + +describe("Replicate prediction submission", () => { + it("preserves the Retry-After duration on Replicate rate-limit errors", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + return new Response("rate limited", { + status: 429, + headers: { "Retry-After": "4" }, + }); + }); + + let thrownError: unknown; + try { + await createPrediction(replicateAuth, replicateInput, async () => {}); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(NeuroLinkError); + expect((thrownError as NeuroLinkError).retriable).toBe(true); + expect((thrownError as NeuroLinkError).retryAfterMs).toBe(4_000); + }); + + it("retries Replicate submission after waiting for the Retry-After duration", async () => { + const events: string[] = []; + vi.spyOn(globalThis, "fetch") + .mockImplementationOnce(async () => { + events.push("first request"); + return new Response("rate limited", { + status: 429, + headers: { "Retry-After": "2" }, + }); + }) + .mockImplementationOnce(async () => { + events.push("second request"); + return new Response( + JSON.stringify({ + id: "prediction-1", + status: "succeeded", + output: "https://example.invalid/output", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }); + + const result = await createPrediction( + replicateAuth, + replicateInput, + async (ms) => { + events.push(`waited ${ms} milliseconds`); + }, + ); + + expect(result.id).toBe("prediction-1"); + expect(events).toEqual([ + "first request", + "waited 2000 milliseconds", + "second request", + ]); + }); +}); + +describe("parseRetryAfterMs fallbacks and clamping", () => { + it("clamps a negative Retry-After seconds value to an immediate retry", () => { + expect(parseRetryAfterMs({ "retry-after": "-5" })).toBe(0); + }); + + it("clamps a past HTTP-date Retry-After to an immediate retry", () => { + expect( + parseRetryAfterMs({ "retry-after": "Wed, 21 Oct 2015 07:28:00 GMT" }), + ).toBe(0); + }); + + it("falls back to a future X-RateLimit-Reset epoch-seconds timestamp", () => { + const resetSeconds = Math.floor(Date.now() / 1000) + 30; + const delayMs = parseRetryAfterMs({ + "x-ratelimit-reset": String(resetSeconds), + }); + expect(delayMs).toBeGreaterThan(25_000); + expect(delayMs).toBeLessThanOrEqual(30_000); + }); + + it("falls back to a short delay when X-RateLimit-Remaining is exhausted", () => { + expect(parseRetryAfterMs({ "x-ratelimit-remaining": "0" })).toBe(1000); + }); + + it("keeps HTTPRateLimiter.handleRateLimitResponse non-negative for negative Retry-After", async () => { + const { HTTPRateLimiter } = + await import("../src/lib/mcp/httpRateLimiter.js"); + const limiter = new HTTPRateLimiter(); + const headers = new Headers({ "Retry-After": "-5" }); + expect(limiter.handleRateLimitResponse(headers)).toBe(0); + }); +});