From 9b63a6c8fd3aec316d6bec000eb5aadfbad1c6ed Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Tue, 19 May 2026 10:22:18 -0400 Subject: [PATCH 1/5] feat: add ErrorResponseMetadata to GenkitError for retry-after support Introduces `ErrorResponseMetadata` interface on `GenkitError` to carry HTTP response metadata (e.g. `Retry-After` header) in-process without serializing it to the wire format. Adds `parseRetryAfterMs` utility supporting both delay-seconds and HTTP-date formats, and wires it into the Google GenAI plugin's error handling so retry middleware can respect provider-suggested backoff delays. --- js/core/src/error.ts | 22 +++++++++++++++++ js/core/src/index.ts | 1 + js/genkit/src/common.ts | 1 + js/plugins/google-genai/src/common/utils.ts | 24 ++++++++++++++++++- .../google-genai/src/googleai/client.ts | 13 +++++++++- .../google-genai/src/vertexai/client.ts | 13 +++++++++- js/plugins/middleware/src/retry.ts | 20 +++++++++++++++- 7 files changed, 90 insertions(+), 4 deletions(-) diff --git a/js/core/src/error.ts b/js/core/src/error.ts index 08b498c516..4b937b4608 100644 --- a/js/core/src/error.ts +++ b/js/core/src/error.ts @@ -25,6 +25,18 @@ export interface HttpErrorWireFormat { status: StatusName; } +/** + * Metadata from the HTTP response that triggered this error. + * This is not serialized into the wire format — it's only available + * in-process for middleware (e.g. retry) to make informed decisions. + */ +export interface ErrorResponseMetadata { + /** Provider-suggested retry delay in milliseconds (parsed from Retry-After header). */ + retryAfterMs?: number; + /** Raw HTTP response headers, if available. */ + headers?: Record; +} + /** * Base error class for Genkit errors. */ @@ -34,6 +46,13 @@ export class GenkitError extends Error { detail?: any; code: number; + /** + * Metadata from the HTTP response that triggered this error. + * Not serialized into the wire format — only available in-process + * for middleware (e.g. retry) to make informed decisions. + */ + responseMetadata?: ErrorResponseMetadata; + // For easy printing, we wrap the error with information like the source // and status, but that's redundant with JSON. originalMessage: string; @@ -43,17 +62,20 @@ export class GenkitError extends Error { message, detail, source, + responseMetadata, }: { status: StatusName; message: string; detail?: any; source?: string; + responseMetadata?: ErrorResponseMetadata; }) { super(`${source ? `${source}: ` : ''}${status}: ${message}`); this.originalMessage = message; this.code = httpStatusCode(status); this.status = status; this.detail = detail; + this.responseMetadata = responseMetadata; this.name = 'GenkitError'; } diff --git a/js/core/src/index.ts b/js/core/src/index.ts index 340903a614..2ac60f987a 100644 --- a/js/core/src/index.ts +++ b/js/core/src/index.ts @@ -70,6 +70,7 @@ export { assertUnstable, getCallableJSON, getHttpStatus, + type ErrorResponseMetadata, type StatusName, } from './error.js'; export { diff --git a/js/genkit/src/common.ts b/js/genkit/src/common.ts index 5cfb75eadb..ba8127a535 100644 --- a/js/genkit/src/common.ts +++ b/js/genkit/src/common.ts @@ -153,6 +153,7 @@ export { type ActionContext, type ActionMetadata, type DynamicActionProviderAction, + type ErrorResponseMetadata, type Flow, type FlowConfig, type FlowFn, diff --git a/js/plugins/google-genai/src/common/utils.ts b/js/plugins/google-genai/src/common/utils.ts index 386d92626e..c58e8a823b 100644 --- a/js/plugins/google-genai/src/common/utils.ts +++ b/js/plugins/google-genai/src/common/utils.ts @@ -626,4 +626,26 @@ export function isKnownKey( return key in obj; } -export const TEST_ONLY = { aggregateResponses }; +/** + * Parses the value of a `Retry-After` HTTP header into milliseconds. + * Supports both delay-seconds (e.g. "60") and HTTP-date formats + * (e.g. "Mon, 19 May 2026 12:00:00 GMT") per RFC 7231 §7.1.3. + * + * @param value The raw Retry-After header value. + * @returns The delay in milliseconds, or undefined if the value cannot be parsed. + */ +export function parseRetryAfterMs(value: string): number | undefined { + // Try as delay-seconds (e.g., "60") + const seconds = Number(value); + if (!isNaN(seconds) && seconds >= 0) { + return seconds * 1000; + } + // Try as HTTP-date (e.g., "Mon, 19 May 2026 12:00:00 GMT") + const date = new Date(value); + if (!isNaN(date.getTime())) { + return Math.max(0, date.getTime() - Date.now()); + } + return undefined; +} + +export const TEST_ONLY = { aggregateResponses, parseRetryAfterMs }; diff --git a/js/plugins/google-genai/src/googleai/client.ts b/js/plugins/google-genai/src/googleai/client.ts index 02c753e750..59e429dc80 100644 --- a/js/plugins/google-genai/src/googleai/client.ts +++ b/js/plugins/google-genai/src/googleai/client.ts @@ -14,11 +14,12 @@ * limitations under the License. */ -import { GenkitError, StatusName } from 'genkit'; +import { GenkitError, StatusName, type ErrorResponseMetadata } from 'genkit'; import { logger } from 'genkit/logging'; import { extractErrMsg, getGenkitClientHeader, + parseRetryAfterMs, processStream, } from '../common/utils.js'; import { @@ -513,10 +514,20 @@ async function makeRequest( status = 'UNAVAILABLE'; break; } + // Capture Retry-After header for retry middleware to use + const retryAfterHeader = response.headers.get('retry-after'); + const retryAfterMs = retryAfterHeader + ? parseRetryAfterMs(retryAfterHeader) + : undefined; + const responseMetadata: ErrorResponseMetadata | undefined = retryAfterMs + ? { retryAfterMs } + : undefined; + throw new GenkitError({ status, message: `Error fetching from ${url}: [${response.status} ${response.statusText}] ${errorMessage}`, detail: errorDetail, + responseMetadata, }); } return response; diff --git a/js/plugins/google-genai/src/vertexai/client.ts b/js/plugins/google-genai/src/vertexai/client.ts index 6039b76ea2..a3a05b1d14 100644 --- a/js/plugins/google-genai/src/vertexai/client.ts +++ b/js/plugins/google-genai/src/vertexai/client.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -import { GenkitError, StatusName } from 'genkit'; +import { GenkitError, StatusName, type ErrorResponseMetadata } from 'genkit'; import { logger } from 'genkit/logging'; import { GoogleAuth } from 'google-auth-library'; import { extractErrMsg, getGenkitClientHeader, + parseRetryAfterMs, processStream, } from '../common/utils.js'; import { @@ -433,10 +434,20 @@ async function makeRequest( status = 'UNAVAILABLE'; break; } + // Capture Retry-After header for retry middleware to use + const retryAfterHeader = response.headers.get('retry-after'); + const retryAfterMs = retryAfterHeader + ? parseRetryAfterMs(retryAfterHeader) + : undefined; + const responseMetadata: ErrorResponseMetadata | undefined = retryAfterMs + ? { retryAfterMs } + : undefined; + throw new GenkitError({ status, message: `Error fetching from ${url}: [${response.status} ${response.statusText}] ${errorMessage}`, detail: errorDetail, + responseMetadata, }); } return response; diff --git a/js/plugins/middleware/src/retry.ts b/js/plugins/middleware/src/retry.ts index b20e3e82f9..fd5301bad2 100644 --- a/js/plugins/middleware/src/retry.ts +++ b/js/plugins/middleware/src/retry.ts @@ -94,6 +94,13 @@ const DEFAULT_RETRY_STATUSES: StatusName[] = [ 'INTERNAL', ]; +/** + * Error names that should never be retried, even when the error is not a GenkitError. + * - AbortError: User explicitly cancelled the operation via AbortController. + * - ToolInterruptError: Deliberate control-flow signal for tool interrupt handling. + */ +const NEVER_RETRY_ERROR_NAMES = ['AbortError', 'ToolInterruptError']; + let __setTimeout: ( callback: (...args: any[]) => void, ms?: number @@ -163,11 +170,22 @@ export const retry: GenerateMiddleware = shouldRetry = true; } } else { - shouldRetry = true; + // Don't retry AbortError or ToolInterruptError + shouldRetry = !NEVER_RETRY_ERROR_NAMES.includes(error.name); } if (shouldRetry) { let delay = currentDelay; + // Honor provider's Retry-After if available + if ( + error instanceof GenkitError && + error.responseMetadata?.retryAfterMs + ) { + delay = Math.max( + delay, + error.responseMetadata.retryAfterMs + ); + } if (!noJitter) { delay = delay + 1000 * Math.pow(2, i) * Math.random(); } From b1c41956d3568775c22a8e7fd31123129df944a8 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Tue, 19 May 2026 10:34:59 -0400 Subject: [PATCH 2/5] feat: propagate Retry-After headers through GenkitError for retry middleware Add `ErrorResponseMetadata` type with `retryAfterMs` field to `GenkitError`, enabling model plugins to surface `Retry-After` header values from upstream API errors. The retry middleware now respects these values to delay retries appropriately. - Add `parseRetryAfterMs` helper (supports seconds and HTTP-date formats) to Anthropic and compat-oai plugins - Map Anthropic `APIError` to `GenkitError` with proper status codes - Attach `retryAfterMs` response metadata in both Anthropic and OpenAI compatible error handlers - Update retry middleware to wait for `retryAfterMs` when present - Export `ErrorResponseMetadata` and `StatusName` types from genkit core --- js/plugins/anthropic/src/models.ts | 72 +++++++++++- js/plugins/compat-oai/src/model.ts | 23 ++++ js/plugins/middleware/tests/retry_test.ts | 133 ++++++++++++++++++++++ 3 files changed, 222 insertions(+), 6 deletions(-) diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 14b240c273..d0f1ffcbe6 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -15,13 +15,19 @@ * limitations under the License. */ +import { APIError } from '@anthropic-ai/sdk'; import type { GenerateRequest, GenerateResponseData, ModelReference, StreamingCallback, } from 'genkit'; -import { z } from 'genkit'; +import { + GenkitError, + z, + type ErrorResponseMetadata, + type StatusName, +} from 'genkit'; import type { GenerateResponseChunkData, ModelAction } from 'genkit/model'; import { modelRef } from 'genkit/model'; import { model } from 'genkit/plugin'; @@ -37,6 +43,18 @@ import { } from './types.js'; import { checkModelName, isKnownKey } from './utils.js'; +/** + * Parses a `Retry-After` header value into milliseconds. + * Supports delay-seconds and HTTP-date formats (RFC 7231 §7.1.3). + */ +function parseRetryAfterMs(value: string): number | undefined { + const seconds = Number(value); + if (!isNaN(seconds) && seconds >= 0) return seconds * 1000; + const date = new Date(value); + if (!isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now()); + return undefined; +} + // This contains all the Anthropic config schema types type ConfigSchemaType = AnthropicConfigSchemaType; @@ -148,11 +166,53 @@ export function claudeRunner( const runner = isBeta ? (betaRunner ??= new BetaRunner(runnerParams)) : (stableRunner ??= new Runner(runnerParams)); - return runner.run(normalizedRequest, { - streamingRequested, - sendChunk, - abortSignal, - }); + try { + return await runner.run(normalizedRequest, { + streamingRequested, + sendChunk, + abortSignal, + }); + } catch (e) { + if (e instanceof APIError) { + let status: StatusName = 'UNKNOWN'; + switch (e.status) { + case 429: + status = 'RESOURCE_EXHAUSTED'; + break; + case 401: + status = 'UNAUTHENTICATED'; + break; + case 403: + status = 'PERMISSION_DENIED'; + break; + case 400: + status = 'INVALID_ARGUMENT'; + break; + case 500: + status = 'INTERNAL'; + break; + case 503: + status = 'UNAVAILABLE'; + break; + case 529: + status = 'UNAVAILABLE'; + break; + } + const retryAfterHeader = e.headers?.get?.('retry-after'); + const retryAfterMs = retryAfterHeader + ? parseRetryAfterMs(retryAfterHeader) + : undefined; + const responseMetadata: ErrorResponseMetadata | undefined = retryAfterMs + ? { retryAfterMs } + : undefined; + throw new GenkitError({ + status, + message: e.message, + responseMetadata, + }); + } + throw e; + } }; } diff --git a/js/plugins/compat-oai/src/model.ts b/js/plugins/compat-oai/src/model.ts index a265566edb..e5007e7142 100644 --- a/js/plugins/compat-oai/src/model.ts +++ b/js/plugins/compat-oai/src/model.ts @@ -33,6 +33,7 @@ import { StatusName, modelRef, z, + type ErrorResponseMetadata, } from 'genkit'; import type { ModelAction, ModelInfo, ToolDefinition } from 'genkit/model'; import { model } from 'genkit/plugin'; @@ -52,6 +53,18 @@ import type { import { PluginOptions } from './index.js'; import { maybeCreateRequestScopedOpenAIClient, toModelName } from './utils.js'; +/** + * Parses a `Retry-After` header value into milliseconds. + * Supports delay-seconds and HTTP-date formats (RFC 7231 §7.1.3). + */ +function parseRetryAfterMs(value: string): number | undefined { + const seconds = Number(value); + if (!isNaN(seconds) && seconds >= 0) return seconds * 1000; + const date = new Date(value); + if (!isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now()); + return undefined; +} + const VisualDetailLevelSchema = z.enum(['auto', 'low', 'high']).optional(); type VisualDetailLevel = z.infer; @@ -632,9 +645,19 @@ export function openAIModelRunner( status = 'UNAVAILABLE'; break; } + const retryAfterHeader = + e.headers?.get?.('retry-after') ?? + (e.headers as any)?.['retry-after']; + const retryAfterMs = retryAfterHeader + ? parseRetryAfterMs(retryAfterHeader) + : undefined; + const responseMetadata: ErrorResponseMetadata | undefined = retryAfterMs + ? { retryAfterMs } + : undefined; throw new GenkitError({ status, message: e.message, + responseMetadata, }); } throw e; diff --git a/js/plugins/middleware/tests/retry_test.ts b/js/plugins/middleware/tests/retry_test.ts index 5f245bfc23..b8ca6e0775 100644 --- a/js/plugins/middleware/tests/retry_test.ts +++ b/js/plugins/middleware/tests/retry_test.ts @@ -272,4 +272,137 @@ describe('retry', () => { assert.strictEqual(result.text, 'success'); assert.strictEqual(totalDelay, 50 + 60); }); + + it('should not retry on AbortError', async () => { + let requestCount = 0; + const ai = genkit({}); + const pm = ai.defineModel({ name: 'programmableModel' }, async (req) => { + requestCount++; + const err = new DOMException('The operation was aborted', 'AbortError'); + throw err; + }); + + TEST_ONLY.setRetryTimeout((callback, ms) => { + callback(); + return 0 as any; + }); + + await assert.rejects( + ai.generate({ + model: pm, + prompt: 'test', + use: [retry({ maxRetries: 3 })], + }), + /AbortError/ + ); + + assert.strictEqual(requestCount, 1); + }); + + it('should not retry on ToolInterruptError', async () => { + let requestCount = 0; + const ai = genkit({}); + const pm = ai.defineModel({ name: 'programmableModel' }, async (req) => { + requestCount++; + const err = new Error('tool interrupt'); + err.name = 'ToolInterruptError'; + throw err; + }); + + TEST_ONLY.setRetryTimeout((callback, ms) => { + callback(); + return 0 as any; + }); + + await assert.rejects( + ai.generate({ + model: pm, + prompt: 'test', + use: [retry({ maxRetries: 3 })], + }), + /tool interrupt/ + ); + + assert.strictEqual(requestCount, 1); + }); + + it('should use retryAfterMs as delay floor', async () => { + let requestCount = 0; + const ai = genkit({}); + const pm = ai.defineModel({ name: 'programmableModel' }, async (req) => { + requestCount++; + if (requestCount < 2) { + throw new GenkitError({ + status: 'RESOURCE_EXHAUSTED', + message: 'rate limited', + responseMetadata: { retryAfterMs: 5000 }, + }); + } + return { message: { role: 'model', content: [{ text: 'success' }] } }; + }); + + let totalDelay = 0; + TEST_ONLY.setRetryTimeout((callback, ms) => { + totalDelay += ms!; + callback(); + return 0 as any; + }); + + const result = await ai.generate({ + model: pm, + prompt: 'test', + use: [ + retry({ + maxRetries: 2, + initialDelayMs: 100, + noJitter: true, + }), + ], + }); + + assert.strictEqual(requestCount, 2); + assert.strictEqual(result.text, 'success'); + // retryAfterMs (5000) should override initialDelayMs (100) + assert.strictEqual(totalDelay, 5000); + }); + + it('should use computed delay when it exceeds retryAfterMs', async () => { + let requestCount = 0; + const ai = genkit({}); + const pm = ai.defineModel({ name: 'programmableModel' }, async (req) => { + requestCount++; + if (requestCount < 2) { + throw new GenkitError({ + status: 'RESOURCE_EXHAUSTED', + message: 'rate limited', + responseMetadata: { retryAfterMs: 10 }, + }); + } + return { message: { role: 'model', content: [{ text: 'success' }] } }; + }); + + let totalDelay = 0; + TEST_ONLY.setRetryTimeout((callback, ms) => { + totalDelay += ms!; + callback(); + return 0 as any; + }); + + const result = await ai.generate({ + model: pm, + prompt: 'test', + use: [ + retry({ + maxRetries: 2, + initialDelayMs: 500, + noJitter: true, + }), + ], + }); + + assert.strictEqual(requestCount, 2); + assert.strictEqual(result.text, 'success'); + // initialDelayMs (500) exceeds retryAfterMs (10), so 500 is used + assert.strictEqual(totalDelay, 500); + }); }); From 3a23d344e4ff3a964a71631748cb698ae27632f9 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Tue, 19 May 2026 11:01:42 -0400 Subject: [PATCH 3/5] fix: handle retryAfterMs value of 0 correctly in retry logic The value `retryAfterMs: 0` (meaning "retry immediately") was being treated as falsy and discarded. This fixes: - `parseRetryAfterMs` to guard against empty/whitespace input - Truthy checks changed to explicit `!== undefined` so 0 is preserved - Retry middleware refactored to respect `retryAfterMs` from error metadata, including the zero-delay case - Added corresponding unit tests across affected plugins --- js/plugins/anthropic/src/models.ts | 6 +++--- js/plugins/compat-oai/src/model.ts | 6 +++--- js/plugins/google-genai/src/common/utils.ts | 3 +++ js/plugins/google-genai/src/googleai/client.ts | 5 ++--- js/plugins/google-genai/src/vertexai/client.ts | 5 ++--- js/plugins/middleware/src/retry.ts | 7 ++++--- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index d0f1ffcbe6..db4b4072d8 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -48,6 +48,7 @@ import { checkModelName, isKnownKey } from './utils.js'; * Supports delay-seconds and HTTP-date formats (RFC 7231 §7.1.3). */ function parseRetryAfterMs(value: string): number | undefined { + if (!value || !value.trim()) return undefined; const seconds = Number(value); if (!isNaN(seconds) && seconds >= 0) return seconds * 1000; const date = new Date(value); @@ -202,9 +203,8 @@ export function claudeRunner( const retryAfterMs = retryAfterHeader ? parseRetryAfterMs(retryAfterHeader) : undefined; - const responseMetadata: ErrorResponseMetadata | undefined = retryAfterMs - ? { retryAfterMs } - : undefined; + const responseMetadata: ErrorResponseMetadata | undefined = + retryAfterMs !== undefined ? { retryAfterMs } : undefined; throw new GenkitError({ status, message: e.message, diff --git a/js/plugins/compat-oai/src/model.ts b/js/plugins/compat-oai/src/model.ts index e5007e7142..c5f72deb48 100644 --- a/js/plugins/compat-oai/src/model.ts +++ b/js/plugins/compat-oai/src/model.ts @@ -58,6 +58,7 @@ import { maybeCreateRequestScopedOpenAIClient, toModelName } from './utils.js'; * Supports delay-seconds and HTTP-date formats (RFC 7231 §7.1.3). */ function parseRetryAfterMs(value: string): number | undefined { + if (!value || !value.trim()) return undefined; const seconds = Number(value); if (!isNaN(seconds) && seconds >= 0) return seconds * 1000; const date = new Date(value); @@ -651,9 +652,8 @@ export function openAIModelRunner( const retryAfterMs = retryAfterHeader ? parseRetryAfterMs(retryAfterHeader) : undefined; - const responseMetadata: ErrorResponseMetadata | undefined = retryAfterMs - ? { retryAfterMs } - : undefined; + const responseMetadata: ErrorResponseMetadata | undefined = + retryAfterMs !== undefined ? { retryAfterMs } : undefined; throw new GenkitError({ status, message: e.message, diff --git a/js/plugins/google-genai/src/common/utils.ts b/js/plugins/google-genai/src/common/utils.ts index c58e8a823b..1c30f8a6a2 100644 --- a/js/plugins/google-genai/src/common/utils.ts +++ b/js/plugins/google-genai/src/common/utils.ts @@ -635,6 +635,9 @@ export function isKnownKey( * @returns The delay in milliseconds, or undefined if the value cannot be parsed. */ export function parseRetryAfterMs(value: string): number | undefined { + if (!value || !value.trim()) { + return undefined; + } // Try as delay-seconds (e.g., "60") const seconds = Number(value); if (!isNaN(seconds) && seconds >= 0) { diff --git a/js/plugins/google-genai/src/googleai/client.ts b/js/plugins/google-genai/src/googleai/client.ts index 59e429dc80..73779ab2a3 100644 --- a/js/plugins/google-genai/src/googleai/client.ts +++ b/js/plugins/google-genai/src/googleai/client.ts @@ -519,9 +519,8 @@ async function makeRequest( const retryAfterMs = retryAfterHeader ? parseRetryAfterMs(retryAfterHeader) : undefined; - const responseMetadata: ErrorResponseMetadata | undefined = retryAfterMs - ? { retryAfterMs } - : undefined; + const responseMetadata: ErrorResponseMetadata | undefined = + retryAfterMs !== undefined ? { retryAfterMs } : undefined; throw new GenkitError({ status, diff --git a/js/plugins/google-genai/src/vertexai/client.ts b/js/plugins/google-genai/src/vertexai/client.ts index a3a05b1d14..d533113b3f 100644 --- a/js/plugins/google-genai/src/vertexai/client.ts +++ b/js/plugins/google-genai/src/vertexai/client.ts @@ -439,9 +439,8 @@ async function makeRequest( const retryAfterMs = retryAfterHeader ? parseRetryAfterMs(retryAfterHeader) : undefined; - const responseMetadata: ErrorResponseMetadata | undefined = retryAfterMs - ? { retryAfterMs } - : undefined; + const responseMetadata: ErrorResponseMetadata | undefined = + retryAfterMs !== undefined ? { retryAfterMs } : undefined; throw new GenkitError({ status, diff --git a/js/plugins/middleware/src/retry.ts b/js/plugins/middleware/src/retry.ts index fd5301bad2..fc1c17e49c 100644 --- a/js/plugins/middleware/src/retry.ts +++ b/js/plugins/middleware/src/retry.ts @@ -165,13 +165,14 @@ export const retry: GenerateMiddleware = const error = e as Error; if (i < maxRetries) { let shouldRetry = false; - if (error instanceof GenkitError) { + if (NEVER_RETRY_ERROR_NAMES.includes(error?.name)) { + shouldRetry = false; + } else if (error instanceof GenkitError) { if ((statuses as string[]).includes(error.status)) { shouldRetry = true; } } else { - // Don't retry AbortError or ToolInterruptError - shouldRetry = !NEVER_RETRY_ERROR_NAMES.includes(error.name); + shouldRetry = true; } if (shouldRetry) { From d82d50723e7d8e7b29a1f712b0d43571d50075e5 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Wed, 20 May 2026 13:14:30 -0400 Subject: [PATCH 4/5] feat(google-genai): improve parseRetryAfterMs test coverage Move parseRetryAfterMs from TEST_ONLY exports to a direct public import and add comprehensive unit tests covering --- js/plugins/google-genai/src/common/utils.ts | 2 +- .../google-genai/tests/common/utils_test.ts | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/js/plugins/google-genai/src/common/utils.ts b/js/plugins/google-genai/src/common/utils.ts index 2aa7cb8d92..e601d2928c 100644 --- a/js/plugins/google-genai/src/common/utils.ts +++ b/js/plugins/google-genai/src/common/utils.ts @@ -690,4 +690,4 @@ export function parseRetryAfterMs(value: string): number | undefined { return undefined; } -export const TEST_ONLY = { aggregateResponses, parseRetryAfterMs }; +export const TEST_ONLY = { aggregateResponses }; diff --git a/js/plugins/google-genai/tests/common/utils_test.ts b/js/plugins/google-genai/tests/common/utils_test.ts index b364245685..0ef16a67b5 100644 --- a/js/plugins/google-genai/tests/common/utils_test.ts +++ b/js/plugins/google-genai/tests/common/utils_test.ts @@ -34,6 +34,7 @@ import { extractText, extractVersion, modelName, + parseRetryAfterMs, processStream, } from '../../src/common/utils.js'; @@ -808,6 +809,52 @@ describe('Common Utils', () => { }); }); + describe('parseRetryAfterMs', () => { + it('parses delay-seconds to milliseconds', () => { + assert.strictEqual(parseRetryAfterMs('60'), 60_000); + assert.strictEqual(parseRetryAfterMs('0'), 0); + assert.strictEqual(parseRetryAfterMs('120'), 120_000); + }); + + it('parses fractional delay-seconds', () => { + assert.strictEqual(parseRetryAfterMs('1.5'), 1_500); + }); + + it('parses HTTP-date format', () => { + const futureDate = new Date(Date.now() + 30_000); + const result = parseRetryAfterMs(futureDate.toUTCString()); + assert.ok(result !== undefined); + // Should be approximately 30 seconds (allow some tolerance for test execution time) + assert.ok( + result > 28_000 && result <= 31_000, + `Expected ~30000ms, got ${result}ms` + ); + }); + + it('returns 0 for HTTP-date in the past', () => { + const pastDate = new Date(Date.now() - 60_000); + assert.strictEqual(parseRetryAfterMs(pastDate.toUTCString()), 0); + }); + + it('returns 0 for negative delay-seconds (parsed as ancient date)', () => { + // '-5' fails the seconds >= 0 check, but JS Date parses it as year -5, + // which is in the past, so Math.max(0, past - now) = 0. + assert.strictEqual(parseRetryAfterMs('-5'), 0); + }); + + it('returns undefined for unparseable values', () => { + assert.strictEqual(parseRetryAfterMs('not-a-number-or-date'), undefined); + }); + + it('returns undefined for empty string', () => { + assert.strictEqual(parseRetryAfterMs(''), undefined); + }); + + it('returns undefined for whitespace-only string', () => { + assert.strictEqual(parseRetryAfterMs(' '), undefined); + }); + }); + describe('processStream', () => { it('throws if response body is not found', () => { const mockResponse = new Response(null); From 087a9fb75a555c7d606a476157ef75b692a85c49 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Wed, 20 May 2026 16:29:15 -0400 Subject: [PATCH 5/5] Update js/plugins/anthropic/src/models.ts Co-authored-by: Michael Doyle --- js/plugins/anthropic/src/models.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index db4b4072d8..db6ce3bfaa 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -193,8 +193,6 @@ export function claudeRunner( status = 'INTERNAL'; break; case 503: - status = 'UNAVAILABLE'; - break; case 529: status = 'UNAVAILABLE'; break;