Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions js/core/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

/**
* Base error class for Genkit errors.
*/
Expand All @@ -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;
Expand All @@ -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';
}

Expand Down
1 change: 1 addition & 0 deletions js/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export {
assertUnstable,
getCallableJSON,
getHttpStatus,
type ErrorResponseMetadata,
type StatusName,
} from './error.js';
export {
Expand Down
1 change: 1 addition & 0 deletions js/genkit/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export {
type ActionContext,
type ActionMetadata,
type DynamicActionProviderAction,
type ErrorResponseMetadata,
type Flow,
type FlowConfig,
type FlowFn,
Expand Down
70 changes: 64 additions & 6 deletions js/plugins/anthropic/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,6 +43,19 @@ 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 {
Comment thread
pavelgj marked this conversation as resolved.
if (!value || !value.trim()) return 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;

Expand Down Expand Up @@ -148,11 +167,50 @@ export function claudeRunner<TConfigSchema extends z.ZodTypeAny>(
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:
case 529:
status = 'UNAVAILABLE';
break;
Comment thread
pavelgj marked this conversation as resolved.
}
const retryAfterHeader = e.headers?.get?.('retry-after');
const retryAfterMs = retryAfterHeader
? parseRetryAfterMs(retryAfterHeader)
: undefined;
const responseMetadata: ErrorResponseMetadata | undefined =
retryAfterMs !== undefined ? { retryAfterMs } : undefined;
throw new GenkitError({
status,
message: e.message,
responseMetadata,
});
}
throw e;
}
};
}

Expand Down
23 changes: 23 additions & 0 deletions js/plugins/compat-oai/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -52,6 +53,19 @@ 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 {
if (!value || !value.trim()) return 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<typeof VisualDetailLevelSchema>;
Expand Down Expand Up @@ -632,9 +646,18 @@ 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 !== undefined ? { retryAfterMs } : undefined;
throw new GenkitError({
status,
message: e.message,
responseMetadata,
Comment thread
pavelgj marked this conversation as resolved.
});
}
throw e;
Expand Down
25 changes: 25 additions & 0 deletions js/plugins/google-genai/src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,4 +665,29 @@ export function isKnownKey<T extends object>(
return key in obj;
}

/**
* 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 {
if (!value || !value.trim()) {
return 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;
}
Comment thread
pavelgj marked this conversation as resolved.

export const TEST_ONLY = { aggregateResponses };
12 changes: 11 additions & 1 deletion js/plugins/google-genai/src/googleai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
* limitations under the License.
*/

import { GenkitError, StatusName } from 'genkit';
import { GenkitError, StatusName, type ErrorResponseMetadata } from 'genkit';
import { logger } from 'genkit/logging';
import { runInNewSpan } from 'genkit/tracing';
import {
buildTraceMetadataInput,
extractErrMsg,
getGenkitClientHeader,
parseRetryAfterMs,
processStream,
} from '../common/utils.js';
import {
Expand Down Expand Up @@ -585,10 +586,19 @@ 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 !== undefined ? { retryAfterMs } : undefined;

throw new GenkitError({
status,
message: `Error fetching from ${url}: [${response.status} ${response.statusText}] ${errorMessage}`,
detail: errorDetail,
responseMetadata,
});
}
return response;
Expand Down
12 changes: 11 additions & 1 deletion js/plugins/google-genai/src/vertexai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
* limitations under the License.
*/

import { GenkitError, StatusName } from 'genkit';
import { GenkitError, StatusName, type ErrorResponseMetadata } from 'genkit';
import { logger } from 'genkit/logging';
import { runInNewSpan } from 'genkit/tracing';
import { GoogleAuth } from 'google-auth-library';
import {
buildTraceMetadataInput,
extractErrMsg,
getGenkitClientHeader,
parseRetryAfterMs,
processStream,
} from '../common/utils.js';
import {
Expand Down Expand Up @@ -501,10 +502,19 @@ 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 !== undefined ? { retryAfterMs } : undefined;

throw new GenkitError({
status,
message: `Error fetching from ${url}: [${response.status} ${response.statusText}] ${errorMessage}`,
detail: errorDetail,
responseMetadata,
});
}
return response;
Expand Down
47 changes: 47 additions & 0 deletions js/plugins/google-genai/tests/common/utils_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
extractText,
extractVersion,
modelName,
parseRetryAfterMs,
processStream,
} from '../../src/common/utils.js';

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading