Skip to content

Commit 37d4f2e

Browse files
Annhiluccopybara-github
authored andcommitted
feat: Add HTTP retry support to the SDK
PiperOrigin-RevId: 867654877
1 parent 75788d4 commit 37d4f2e

File tree

8 files changed

+189
-3
lines changed

8 files changed

+189
-3
lines changed

api-report/genai-node.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,6 +1854,7 @@ export interface HttpOptions {
18541854
baseUrlResourceScope?: ResourceScope;
18551855
extraBody?: Record<string, unknown>;
18561856
headers?: Record<string, string>;
1857+
retryOptions?: HttpRetryOptions;
18571858
timeout?: number;
18581859
}
18591860

@@ -1866,6 +1867,11 @@ export class HttpResponse {
18661867
responseInternal: Response;
18671868
}
18681869

1870+
// @public
1871+
export interface HttpRetryOptions {
1872+
attempts?: number;
1873+
}
1874+
18691875
// @public
18701876
interface Image_2 {
18711877
gcsUri?: string;

api-report/genai-web.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,6 +1854,7 @@ export interface HttpOptions {
18541854
baseUrlResourceScope?: ResourceScope;
18551855
extraBody?: Record<string, unknown>;
18561856
headers?: Record<string, string>;
1857+
retryOptions?: HttpRetryOptions;
18571858
timeout?: number;
18581859
}
18591860

@@ -1866,6 +1867,11 @@ export class HttpResponse {
18661867
responseInternal: Response;
18671868
}
18681869

1870+
// @public
1871+
export interface HttpRetryOptions {
1872+
attempts?: number;
1873+
}
1874+
18691875
// @public
18701876
interface Image_2 {
18711877
gcsUri?: string;

api-report/genai.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,6 +1854,7 @@ export interface HttpOptions {
18541854
baseUrlResourceScope?: ResourceScope;
18551855
extraBody?: Record<string, unknown>;
18561856
headers?: Record<string, string>;
1857+
retryOptions?: HttpRetryOptions;
18571858
timeout?: number;
18581859
}
18591860

@@ -1866,6 +1867,11 @@ export class HttpResponse {
18661867
responseInternal: Response;
18671868
}
18681869

1870+
// @public
1871+
export interface HttpRetryOptions {
1872+
attempts?: number;
1873+
}
1874+
18691875
// @public
18701876
interface Image_2 {
18711877
gcsUri?: string;

package-lock.json

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"web/package.json"
106106
],
107107
"devDependencies": {
108+
"@cfworker/json-schema": "^4.1.1",
108109
"@eslint/js": "9.20.0",
109110
"@microsoft/api-extractor": "^7.52.9",
110111
"@modelcontextprotocol/sdk": "^1.25.2",
@@ -114,7 +115,6 @@
114115
"@types/node-fetch": "^2.6.13",
115116
"@types/unist": "^3.0.3",
116117
"@types/ws": "^8.5.14",
117-
"@cfworker/json-schema": "^4.1.1",
118118
"c8": "^10.1.3",
119119
"eslint": "8.57.0",
120120
"gts": "^5.2.0",
@@ -142,6 +142,7 @@
142142
},
143143
"dependencies": {
144144
"google-auth-library": "^10.3.0",
145+
"p-retry": "^7.1.1",
145146
"protobufjs": "^7.5.4",
146147
"ws": "^8.18.0"
147148
},

src/_api_client.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import pRetry, {AbortError} from 'p-retry';
78
import {Auth} from './_auth.js';
89
import * as common from './_common.js';
910
import {Downloader} from './_downloader.js';
@@ -22,6 +23,20 @@ const LIBRARY_LABEL = `google-genai-sdk/${SDK_VERSION}`;
2223
const VERTEX_AI_API_DEFAULT_VERSION = 'v1beta1';
2324
const GOOGLE_AI_API_DEFAULT_VERSION = 'v1beta';
2425

26+
// Default retry options.
27+
// The config is based on https://cloud.google.com/storage/docs/retry-strategy.
28+
const DEFAULT_RETRY_ATTEMPTS = 5; // Including the initial call
29+
// LINT.IfChange
30+
const DEFAULT_RETRY_HTTP_STATUS_CODES = [
31+
408, // Request timeout
32+
429, // Too many requests
33+
500, // Internal server error
34+
502, // Bad gateway
35+
503, // Service unavailable
36+
504, // Gateway timeout
37+
];
38+
// LINT.ThenChange(//depot/google3/third_party/py/google/genai/_api_client.py)
39+
2540
/**
2641
* Options for initializing the ApiClient. The ApiClient uses the parameters
2742
* for authentication purposes as well as to infer if SDK should send the
@@ -659,8 +674,33 @@ export class ApiClient implements GeminiNextGenAPIClientAdapter {
659674
url: string,
660675
requestInit: RequestInit,
661676
): Promise<Response> {
662-
return fetch(url, requestInit).catch((e) => {
663-
throw new Error(`exception ${e} sending request`);
677+
if (
678+
!this.clientOptions.httpOptions ||
679+
!this.clientOptions.httpOptions.retryOptions
680+
) {
681+
return fetch(url, requestInit);
682+
}
683+
684+
const retryOptions = this.clientOptions.httpOptions.retryOptions;
685+
const runFetch = async () => {
686+
const response = await fetch(url, requestInit);
687+
688+
if (response.ok) {
689+
return response;
690+
}
691+
692+
if (DEFAULT_RETRY_HTTP_STATUS_CODES.includes(response.status)) {
693+
throw new Error(`Retryable HTTP Error: ${response.statusText}`);
694+
}
695+
696+
throw new AbortError(
697+
`Non-retryable exception ${response.statusText} sending request`,
698+
);
699+
};
700+
701+
return pRetry(runFetch, {
702+
// Retry attempts is one less than the number of total attempts.
703+
retries: (retryOptions.attempts ?? DEFAULT_RETRY_ATTEMPTS) - 1,
664704
});
665705
}
666706

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,13 @@ export function createModelContent(
16141614
parts: _toParts(partOrString),
16151615
};
16161616
}
1617+
/** HTTP retry options to be used in each of the requests. */
1618+
export declare interface HttpRetryOptions {
1619+
/** Maximum number of attempts, including the original request.
1620+
If 0 or 1, it means no retries. If not specified, default to 5. */
1621+
attempts?: number;
1622+
}
1623+
16171624
/** HTTP options to be used in each of the requests. */
16181625
export declare interface HttpOptions {
16191626
/** The base URL for the AI platform service endpoint. */
@@ -1631,6 +1638,8 @@ export declare interface HttpOptions {
16311638
- VertexAI backend API docs: https://cloud.google.com/vertex-ai/docs/reference/rest
16321639
- GeminiAPI backend API docs: https://ai.google.dev/api/rest */
16331640
extraBody?: Record<string, unknown>;
1641+
/** HTTP retry options for the request. */
1642+
retryOptions?: HttpRetryOptions;
16341643
}
16351644

16361645
/** Schema is used to define the format of input/output data.

test/unit/api_client_test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,8 @@ describe('processStreamResponse', () => {
385385

386386
describe('ApiClient', () => {
387387
describe('constructor', () => {
388+
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000; // 60 seconds.
389+
388390
it('should initialize with provided values', () => {
389391
const client = new ApiClient({
390392
auth: new FakeAuth(),
@@ -721,6 +723,94 @@ describe('ApiClient', () => {
721723
expect(headers['x-goog-api-client']).toContain('google-genai-sdk/');
722724
expect(client.getApiVersion()).toBe('v1beta1');
723725
});
726+
727+
it('should retry requests if retry options are set', async () => {
728+
const client = new ApiClient({
729+
auth: new FakeAuth(),
730+
project: 'vertex-project',
731+
location: 'vertex-location',
732+
vertexai: true,
733+
apiVersion: 'v1beta1',
734+
httpOptions: {
735+
retryOptions: {
736+
attempts: 2,
737+
},
738+
},
739+
uploader: new CrossUploader(),
740+
downloader: new CrossDownloader(),
741+
});
742+
const fetchSpy = spyOn(global, 'fetch').and.returnValue(
743+
Promise.resolve(
744+
new Response(
745+
JSON.stringify({'error': 'Internal Server Error'}),
746+
fetch500Options,
747+
),
748+
),
749+
);
750+
await client
751+
.request({path: 'test-path', httpMethod: 'POST'})
752+
.catch((e) => {
753+
console.log(e);
754+
});
755+
expect(fetchSpy).toHaveBeenCalledTimes(2);
756+
});
757+
758+
it('should not retry requests if retry options are not set', async () => {
759+
const client = new ApiClient({
760+
auth: new FakeAuth(),
761+
project: 'vertex-project',
762+
location: 'vertex-location',
763+
vertexai: true,
764+
apiVersion: 'v1beta1',
765+
uploader: new CrossUploader(),
766+
downloader: new CrossDownloader(),
767+
});
768+
const fetchSpy = spyOn(global, 'fetch').and.returnValue(
769+
Promise.resolve(
770+
new Response(
771+
JSON.stringify({'error': 'Internal Server Error'}),
772+
fetch500Options,
773+
),
774+
),
775+
);
776+
await client
777+
.request({path: 'test-path', httpMethod: 'POST'})
778+
.catch((e) => {
779+
expect(e.name).toEqual('ApiError');
780+
expect(e.message).toContain('Internal Server Error');
781+
expect(e.status).toEqual(500);
782+
});
783+
expect(fetchSpy).toHaveBeenCalledTimes(1);
784+
});
785+
786+
it('should retry requests with default retry options if retry options are not set', async () => {
787+
const client = new ApiClient({
788+
auth: new FakeAuth(),
789+
project: 'vertex-project',
790+
location: 'vertex-location',
791+
vertexai: true,
792+
apiVersion: 'v1beta1',
793+
httpOptions: {
794+
retryOptions: {},
795+
},
796+
uploader: new CrossUploader(),
797+
downloader: new CrossDownloader(),
798+
});
799+
const fetchSpy = spyOn(global, 'fetch').and.returnValue(
800+
Promise.resolve(
801+
new Response(
802+
JSON.stringify({'error': 'Internal Server Error'}),
803+
fetch500Options,
804+
),
805+
),
806+
);
807+
await client
808+
.request({path: 'test-path', httpMethod: 'POST'})
809+
.catch((e) => {
810+
console.log(e);
811+
});
812+
expect(fetchSpy).toHaveBeenCalledTimes(5); // Default retry attempts is 5.
813+
});
724814
});
725815

726816
describe('post/get methods', () => {

0 commit comments

Comments
 (0)