Skip to content

Commit 751277e

Browse files
SS-8172 Add retry on 429 and 502
1 parent 0b2479b commit 751277e

File tree

7 files changed

+246
-2
lines changed

7 files changed

+246
-2
lines changed

justfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ generate version:
5353
"{{target_dir}}/configuration.ts" \
5454
"{{target_dir}}/index.ts"
5555

56+
# Apply manual modifications to the generated DTO files.
57+
just _post-generation
58+
5659
# Clean up.
5760
rm -rf "{{target_dir}}"
5861

@@ -74,3 +77,13 @@ test-generator:
7477
--dry-run \
7578
-i "{{oas_repo}}/geometry_backend_v2.yaml" \
7679
-g typescript-axios
80+
81+
# Steps to be executed after the generation of the TypeScript client.
82+
_post-generation:
83+
#!/usr/bin/env bash
84+
# Replace client BaseAPI import in api.ts with custom BaseAPI implementation.
85+
pattern="^import \{(.*)BaseAPI,?\s?(.*)\} from '.\/base';$"
86+
replacement="import \{\1\2\} from '.\/base';"
87+
added_line="import \{ BaseAPI \} from '..\/base';"
88+
sed -ri "s/${pattern}/${replacement}\n${added_line}/g" \
89+
"packages/sdk.geometry-api-sdk-v2/src/client/api.ts"

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"downstream": "bash ./scripts/downstream.sh"
2121
},
2222
"devDependencies": {
23+
"axios-mock-adapter": "~2.1.0",
2324
"eslint": "~9.12.0",
2425
"@eslint/js": "~9.12.0",
2526
"eslint-plugin-jest": "~28.8.3",
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Configuration, SessionApi } from '../src';
2+
import { Configuration as ClientConfig } from '../src/client/configuration';
3+
import AxiosMockAdapter from 'axios-mock-adapter';
4+
import axios from 'axios';
5+
6+
describe('constructor', function () {
7+
test.each([
8+
['no config', undefined],
9+
['client config', new ClientConfig()],
10+
['sd-config without useCustomAxios', new Configuration()],
11+
['sd-config with useCustomAxios enabled', new Configuration({ useCustomAxios: true })],
12+
])('%s; should be defined', (_, config) => {
13+
const session = new SessionApi(config);
14+
expect(session.defaultAxios).toBeDefined();
15+
});
16+
17+
test('sd-config with useCustomAxios disabled; should be defined', () => {
18+
const config = new Configuration({ useCustomAxios: false });
19+
const session = new SessionApi(config);
20+
expect(session.defaultAxios).toBeUndefined();
21+
});
22+
});
23+
24+
describe('inceptor', function () {
25+
const config = new Configuration({ useCustomAxios: true, maxRetries: 2 }),
26+
api = new SessionApi(config), // use actual api instance to test BaseAPI swapping
27+
mock = new AxiosMockAdapter(api.defaultAxios!);
28+
let spyPost: number;
29+
30+
beforeEach(() => {
31+
mock.resetHandlers();
32+
spyPost = 0;
33+
});
34+
35+
test('retry enabled, general error status; should not retry', async () => {
36+
mock.onPost().reply(() => {
37+
spyPost++;
38+
return [400, {}];
39+
});
40+
41+
await expect(api.createSessionByTicket('foobar')).rejects.toThrow(
42+
/Request failed with status code 400/
43+
);
44+
expect(spyPost).toBe(1);
45+
});
46+
47+
test.each([
48+
[429, { 'retry-after': '1' }],
49+
[502, {}],
50+
])(
51+
'retry enabled, status code %s; should retry until failure',
52+
async (statusCode, headers) => {
53+
mock.onPost().reply(() => {
54+
spyPost++;
55+
return [statusCode, {}, headers];
56+
});
57+
58+
await expect(api.createSessionByTicket('foobar')).rejects.toThrow(
59+
new RegExp(`Request failed with status code ${statusCode}`)
60+
);
61+
expect(spyPost).toBe(3);
62+
}
63+
);
64+
65+
test.each([
66+
[429, { 'retry-after': '1' }],
67+
[502, {}],
68+
])('retry enabled, status code %s once; should retry once', async (statusCode, headers) => {
69+
mock.onPost().reply(() => {
70+
if (spyPost++ === 0) return [statusCode, {}, headers];
71+
else return [200, {}];
72+
});
73+
74+
await expect(api.createSessionByTicket('foobar')).resolves.toBeDefined();
75+
expect(spyPost).toBe(2);
76+
});
77+
78+
test('retry disabled, retry-able error status; should not retry', async () => {
79+
const api = new SessionApi(new Configuration({ useCustomAxios: false })),
80+
mock = new AxiosMockAdapter(axios); // Mock global Axios
81+
82+
mock.onPost().reply(() => {
83+
spyPost++;
84+
return [429, {}];
85+
});
86+
87+
await expect(api.createSessionByTicket('foobar')).rejects.toThrow(
88+
/Request failed with status code 429/
89+
);
90+
expect(spyPost).toBe(1);
91+
92+
mock.restore();
93+
});
94+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Configuration as ClientConfig } from './client/configuration';
2+
import { BaseAPI as ClientBaseAPI } from './client/base';
3+
import { Configuration } from './configuration';
4+
import globalAxios, { AxiosInstance } from 'axios';
5+
6+
/** Axios singleton instance used by all APIs by default. */
7+
let AXIOS: AxiosInstance | undefined;
8+
9+
/**
10+
* Overrides the `BaseAPI` class that is used by all resource APIs.
11+
*
12+
* NOTE: The classes are swapped out in `client/base.ts` by a script during generation.
13+
*
14+
* Extensions:
15+
* - Replace the global Axios instance with a custom instance.
16+
* - Extend the Axios instance by an Interceptor to handle retries on 429 and 502 status codes.
17+
*/
18+
export class BaseAPI extends ClientBaseAPI {
19+
// Exposed for testing purposes.
20+
readonly defaultAxios: AxiosInstance | undefined;
21+
22+
constructor(config?: Configuration | ClientConfig, basePath?: string) {
23+
let axios: AxiosInstance | undefined;
24+
if (!config || !('useCustomAxios' in config) || config.useCustomAxios) {
25+
if (AXIOS === undefined)
26+
AXIOS = createCustomAxiosInstance((config as any)?.maxRetries);
27+
axios = AXIOS;
28+
}
29+
super(config, basePath, axios);
30+
this.defaultAxios = axios;
31+
}
32+
}
33+
34+
/**
35+
* Creates a custom Axios instance with a response interceptor to automatically retry requests on
36+
* `429` and `502` status codes.
37+
*
38+
* @param {number} maxRetries - Maximum number of retry attempts before failing (default: 5).
39+
* @returns A custom Axios instance with retry functionality.
40+
*/
41+
function createCustomAxiosInstance(maxRetries = 5): AxiosInstance {
42+
const axios = globalAxios.create();
43+
44+
axios.interceptors.response.use(undefined, async (error) => {
45+
const { config, response } = error;
46+
47+
// Exit early if no response or config is available
48+
if (!response || !config) return Promise.reject(error);
49+
50+
// Check if retry limit is reached
51+
config.retryCount = config.retryCount ?? 0;
52+
if (config.retryCount >= maxRetries) return Promise.reject(error);
53+
54+
// Only retry for 429 or 502 status codes
55+
if (response.status === 429 || response.status === 502) {
56+
config.retryCount += 1;
57+
58+
const delay =
59+
response.status === 429
60+
? // use retry-after or default to 60s
61+
(parseInt(response.headers['retry-after'], 10) || 60) * 1000
62+
: // 1 second delay for 502 status
63+
1000;
64+
65+
// Delay retry and send the request again
66+
await new Promise((resolve) => setTimeout(resolve, delay));
67+
return axios.request(config);
68+
}
69+
70+
// Reject for all other error statuses
71+
return Promise.reject(error);
72+
});
73+
74+
return axios;
75+
}

packages/sdk.geometry-api-sdk-v2/src/client/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import globalAxios from 'axios';
2121
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from './common';
2222
import type { RequestArgs } from './base';
2323
// @ts-ignore
24-
import { BASE_PATH, COLLECTION_FORMATS, BaseAPI, RequiredError, operationServerMap } from './base';
24+
import { BASE_PATH, COLLECTION_FORMATS, RequiredError, operationServerMap } from './base';
25+
import { BaseAPI } from '../base';
2526

2627
/**
2728
* Reference to the s-type parameter asset to be used.

packages/sdk.geometry-api-sdk-v2/src/configuration.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,48 @@
1+
import { RawAxiosRequestConfig } from 'axios';
12
import {
23
Configuration as ClientConfig,
34
ConfigurationParameters as ClientConfigParams,
45
} from './client/configuration';
56

6-
export interface ConfigurationParameters extends ClientConfigParams {}
7+
export interface ConfigurationParameters
8+
extends Pick<ClientConfigParams, 'accessToken' | 'basePath'> {
9+
/**
10+
* Base options for Axios calls.
11+
*
12+
* @type {RawAxiosRequestConfig}
13+
* @memberof ConfigurationParameters
14+
*/
15+
baseOptions?: RawAxiosRequestConfig;
16+
17+
/**
18+
* Enables the use of a custom Axios instance for all API requests by default, instead of the
19+
* global Axios instance. This custom instance includes additional functionality to
20+
* automatically retry requests on `429` and `502` status codes.
21+
*
22+
* Default: `true`.
23+
*/
24+
useCustomAxios?: boolean;
25+
26+
/**
27+
* Specifies the maximum number of automatic HTTP retries for failed requests.
28+
*
29+
* **Note:** This setting is only applicable when using a custom Axios instance.
30+
*
31+
* Default: `5`
32+
*/
33+
maxRetries?: number;
34+
}
735

836
export class Configuration extends ClientConfig {
937
protected readonly sdkVersion = '2.0.1'; // WARNING: This value is updated automatically!
1038

39+
public readonly useCustomAxios: boolean;
40+
public readonly maxRetries: number;
41+
1142
constructor(param: ConfigurationParameters = {}) {
1243
super(param);
44+
this.useCustomAxios = param.useCustomAxios ?? true;
45+
this.maxRetries = param.maxRetries ?? 5;
1346

1447
this.baseOptions = this.baseOptions ?? {};
1548
this.baseOptions.headers = this.baseOptions.headers ?? {};

pnpm-lock.yaml

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

0 commit comments

Comments
 (0)