Skip to content

Commit 530ca33

Browse files
box-sdk-buildbox-sdk-build
and
box-sdk-build
authored
feat: Allow for customizing retry strategy (box/box-codegen#635) (#457)
Co-authored-by: box-sdk-build <[email protected]>
1 parent 09765a4 commit 530ca33

File tree

6 files changed

+266
-86
lines changed

6 files changed

+266
-86
lines changed

.codegen.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{ "engineHash": "cc6ffb8", "specHash": "6886603", "version": "1.9.0" }
1+
{ "engineHash": "a2387ff", "specHash": "6886603", "version": "1.9.0" }

docs/configuration.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Configuration
2+
3+
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
4+
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
5+
6+
- [Max retry attempts](#max-retry-attempts)
7+
- [Custom retry strategy](#custom-retry-strategy)
8+
9+
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
10+
11+
## Max retry attempts
12+
13+
The default maximum number of retries in case of failed API call is 5.
14+
To change this number you should initialize `BoxRetryStrategy` with the new value and pass it to `NetworkSession`.
15+
16+
```js
17+
const auth = new BoxDeveloperTokenAuth({ token: 'DEVELOPER_TOKEN_GOES_HERE' });
18+
const networkSession = new NetworkSession({
19+
retryStrategy: new BoxRetryStrategy({ maxAttempts: 6 }),
20+
});
21+
const client = new BoxClient({ auth, networkSession });
22+
```
23+
24+
## Custom retry strategy
25+
26+
You can also implement your own retry strategy by subclassing `RetryStrategy` and overriding `shouldRetry` and `retryAfter` methods.
27+
This example shows how to set custom strategy that retries on 5xx status codes and waits 1 second between retries.
28+
29+
```ts
30+
export class CustomRetryStrategy implements RetryStrategy {
31+
async shouldRetry(
32+
fetchOptions: FetchOptions,
33+
fetchResponse: FetchResponse,
34+
attemptNumber: number,
35+
): Promise<boolean> {
36+
return false;
37+
}
38+
39+
retryAfter(
40+
fetchOptions: FetchOptions,
41+
fetchResponse: FetchResponse,
42+
attemptNumber: number,
43+
): number {
44+
return 1.0;
45+
}
46+
}
47+
48+
const auth = new BoxDeveloperTokenAuth({ token: 'DEVELOPER_TOKEN_GOES_HERE' });
49+
const networkSession = new NetworkSession({
50+
retryStrategy: new CustomRetryStrategy(),
51+
});
52+
const client = new BoxClient({ auth, networkSession });
53+
```

src/internal/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,3 +527,7 @@ export async function computeWebhookSignature(
527527
}
528528
return signature;
529529
}
530+
531+
export function random(min: number, max: number): number {
532+
return Math.random() * (max - min) + min;
533+
}

src/networking/boxNetworkClient.ts

Lines changed: 58 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { Interceptor } from './interceptors.generated';
2222
import { FetchOptions } from './fetchOptions.generated';
2323
import { FetchResponse } from './fetchResponse.generated';
24+
import { NetworkSession } from './network.generated';
2425

2526
export const userAgentHeader = `Box JavaScript generated SDK v${sdkVersion} (${
2627
isBrowser() ? navigator.userAgent : `Node ${process.version}`
@@ -136,12 +137,6 @@ async function createRequestInit(options: FetchOptions): Promise<RequestInit> {
136137
};
137138
}
138139

139-
const DEFAULT_MAX_ATTEMPTS = 5;
140-
const RETRY_BASE_INTERVAL = 1;
141-
const STATUS_CODE_ACCEPTED = 202,
142-
STATUS_CODE_UNAUTHORIZED = 401,
143-
STATUS_CODE_TOO_MANY_REQUESTS = 429;
144-
145140
export class BoxNetworkClient implements NetworkClient {
146141
constructor(
147142
fields?: Omit<BoxNetworkClient, 'fetch'> &
@@ -150,9 +145,10 @@ export class BoxNetworkClient implements NetworkClient {
150145
Object.assign(this, fields);
151146
}
152147
async fetch(options: FetchOptionsExtended): Promise<FetchResponse> {
153-
const fetchOptions: typeof options = options.networkSession?.interceptors
154-
?.length
155-
? options.networkSession?.interceptors.reduce(
148+
const numRetries = options.numRetries ?? 0;
149+
const networkSession = options.networkSession ?? new NetworkSession({});
150+
const fetchOptions: typeof options = networkSession.interceptors?.length
151+
? networkSession.interceptors.reduce(
156152
(modifiedOptions: FetchOptions, interceptor: Interceptor) =>
157153
interceptor.beforeRequest(modifiedOptions),
158154
options,
@@ -203,14 +199,30 @@ export class BoxNetworkClient implements NetworkClient {
203199
content,
204200
headers: Object.fromEntries(Array.from(response.headers.entries())),
205201
};
206-
if (fetchOptions.networkSession?.interceptors?.length) {
207-
fetchResponse = fetchOptions.networkSession?.interceptors.reduce(
202+
if (networkSession.interceptors?.length) {
203+
fetchResponse = networkSession.interceptors.reduce(
208204
(modifiedResponse: FetchResponse, interceptor: Interceptor) =>
209205
interceptor.afterRequest(modifiedResponse),
210206
fetchResponse,
211207
);
212208
}
213209

210+
const shouldRetry = await networkSession.retryStrategy.shouldRetry(
211+
fetchOptions,
212+
fetchResponse,
213+
numRetries,
214+
);
215+
216+
if (shouldRetry) {
217+
const retryTimeout = networkSession.retryStrategy.retryAfter(
218+
fetchOptions,
219+
fetchResponse,
220+
numRetries,
221+
);
222+
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
223+
return this.fetch({ ...options, numRetries: numRetries + 1 });
224+
}
225+
214226
if (
215227
fetchResponse.status >= 300 &&
216228
fetchResponse.status < 400 &&
@@ -227,82 +239,43 @@ export class BoxNetworkClient implements NetworkClient {
227239
});
228240
}
229241

230-
const acceptedWithRetryAfter =
231-
fetchResponse.status === STATUS_CODE_ACCEPTED &&
232-
fetchResponse.headers['retry-after'];
233-
const { numRetries = 0 } = fetchOptions;
234-
if (
235-
fetchResponse.status >= 400 ||
236-
(acceptedWithRetryAfter && numRetries < DEFAULT_MAX_ATTEMPTS)
237-
) {
238-
const reauthenticationNeeded =
239-
fetchResponse.status == STATUS_CODE_UNAUTHORIZED;
240-
if (reauthenticationNeeded && fetchOptions.auth) {
241-
await fetchOptions.auth.refreshToken(fetchOptions.networkSession);
242-
243-
// retry the request right away
244-
return this.fetch({
245-
...options,
246-
numRetries: numRetries + 1,
247-
fileStream: fileStreamBuffer
248-
? generateByteStreamFromBuffer(fileStreamBuffer)
249-
: void 0,
250-
});
251-
}
252-
253-
const isRetryable =
254-
fetchOptions.contentType !== 'application/x-www-form-urlencoded' &&
255-
(fetchResponse.status === STATUS_CODE_TOO_MANY_REQUESTS ||
256-
acceptedWithRetryAfter ||
257-
fetchResponse.status >= 500);
258-
259-
if (isRetryable && numRetries < DEFAULT_MAX_ATTEMPTS) {
260-
const retryTimeout = fetchResponse.headers['retry-after']
261-
? parseFloat(fetchResponse.headers['retry-after']!) * 1000
262-
: getRetryTimeout(numRetries, RETRY_BASE_INTERVAL * 1000);
263-
264-
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
265-
return this.fetch({ ...options, numRetries: numRetries + 1 });
266-
}
267-
268-
const [code, contextInfo, requestId, helpUrl] = sdIsMap(
269-
fetchResponse.data,
270-
)
271-
? [
272-
sdToJson(fetchResponse.data['code']),
273-
sdIsMap(fetchResponse.data['context_info'])
274-
? fetchResponse.data['context_info']
275-
: undefined,
276-
sdToJson(fetchResponse.data['request_id']),
277-
sdToJson(fetchResponse.data['help_url']),
278-
]
279-
: [];
280-
281-
throw new BoxApiError({
282-
message: `${fetchResponse.status}`,
283-
timestamp: `${Date.now()}`,
284-
requestInfo: {
285-
method: requestInit.method!,
286-
url: fetchOptions.url,
287-
queryParams: params,
288-
headers: (requestInit.headers as { [key: string]: string }) ?? {},
289-
body:
290-
typeof requestInit.body === 'string' ? requestInit.body : undefined,
291-
},
292-
responseInfo: {
293-
statusCode: fetchResponse.status,
294-
headers: fetchResponse.headers,
295-
body: fetchResponse.data,
296-
rawBody: new TextDecoder().decode(responseBytesBuffer),
297-
code: code,
298-
contextInfo: contextInfo,
299-
requestId: requestId,
300-
helpUrl: helpUrl,
301-
},
302-
});
242+
if (fetchResponse.status >= 200 && fetchResponse.status < 400) {
243+
return fetchResponse;
303244
}
304245

305-
return fetchResponse;
246+
const [code, contextInfo, requestId, helpUrl] = sdIsMap(fetchResponse.data)
247+
? [
248+
sdToJson(fetchResponse.data['code']),
249+
sdIsMap(fetchResponse.data['context_info'])
250+
? fetchResponse.data['context_info']
251+
: undefined,
252+
sdToJson(fetchResponse.data['request_id']),
253+
sdToJson(fetchResponse.data['help_url']),
254+
]
255+
: [];
256+
257+
throw new BoxApiError({
258+
message: `${fetchResponse.status}`,
259+
timestamp: `${Date.now()}`,
260+
requestInfo: {
261+
method: requestInit.method!,
262+
url: fetchOptions.url,
263+
queryParams: params,
264+
headers: (requestInit.headers as { [key: string]: string }) ?? {},
265+
body:
266+
typeof requestInit.body === 'string' ? requestInit.body : undefined,
267+
},
268+
responseInfo: {
269+
statusCode: fetchResponse.status,
270+
headers: fetchResponse.headers,
271+
body: fetchResponse.data,
272+
rawBody: new TextDecoder().decode(responseBytesBuffer),
273+
code: code,
274+
contextInfo: contextInfo,
275+
requestId: requestId,
276+
helpUrl: helpUrl,
277+
},
278+
});
306279
}
307280
}
308281

src/networking/network.generated.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { createAgent } from '../internal/utils.js';
66
import { ProxyConfig } from './proxyConfig.generated.js';
77
import { BoxNetworkClient } from './boxNetworkClient.js';
88
import { NetworkClient } from './networkClient.generated.js';
9+
import { RetryStrategy } from './retries.generated.js';
10+
import { BoxRetryStrategy } from './retries.generated.js';
911
export class NetworkSession {
1012
readonly additionalHeaders: {
1113
readonly [key: string]: string;
@@ -16,6 +18,7 @@ export class NetworkSession {
1618
readonly agentOptions?: AgentOptions;
1719
readonly proxyConfig?: ProxyConfig;
1820
readonly networkClient: NetworkClient = new BoxNetworkClient({});
21+
readonly retryStrategy: RetryStrategy = new BoxRetryStrategy({});
1922
constructor(
2023
fields: Omit<
2124
NetworkSession,
@@ -24,12 +27,14 @@ export class NetworkSession {
2427
| 'interceptors'
2528
| 'agent'
2629
| 'networkClient'
30+
| 'retryStrategy'
2731
| 'withAdditionalHeaders'
2832
| 'withCustomBaseUrls'
2933
| 'withCustomAgentOptions'
3034
| 'withInterceptors'
3135
| 'withProxy'
3236
| 'withNetworkClient'
37+
| 'withRetryStrategy'
3338
> &
3439
Partial<
3540
Pick<
@@ -39,6 +44,7 @@ export class NetworkSession {
3944
| 'interceptors'
4045
| 'agent'
4146
| 'networkClient'
47+
| 'retryStrategy'
4248
>
4349
>,
4450
) {
@@ -63,6 +69,9 @@ export class NetworkSession {
6369
if (fields.networkClient !== undefined) {
6470
this.networkClient = fields.networkClient;
6571
}
72+
if (fields.retryStrategy !== undefined) {
73+
this.retryStrategy = fields.retryStrategy;
74+
}
6675
}
6776
/**
6877
* Generate a fresh network session by duplicating the existing configuration and network parameters, while also including additional headers to be attached to every API call.
@@ -84,6 +93,7 @@ export class NetworkSession {
8493
agentOptions: this.agentOptions,
8594
proxyConfig: this.proxyConfig,
8695
networkClient: this.networkClient,
96+
retryStrategy: this.retryStrategy,
8797
});
8898
}
8999
/**
@@ -100,6 +110,7 @@ export class NetworkSession {
100110
agentOptions: this.agentOptions,
101111
proxyConfig: this.proxyConfig,
102112
networkClient: this.networkClient,
113+
retryStrategy: this.retryStrategy,
103114
});
104115
}
105116
/**
@@ -116,6 +127,7 @@ export class NetworkSession {
116127
agentOptions: this.agentOptions,
117128
proxyConfig: this.proxyConfig,
118129
networkClient: this.networkClient,
130+
retryStrategy: this.retryStrategy,
119131
});
120132
}
121133
/**
@@ -132,6 +144,7 @@ export class NetworkSession {
132144
agentOptions: this.agentOptions,
133145
proxyConfig: this.proxyConfig,
134146
networkClient: this.networkClient,
147+
retryStrategy: this.retryStrategy,
135148
});
136149
}
137150
/**
@@ -148,6 +161,7 @@ export class NetworkSession {
148161
agentOptions: this.agentOptions,
149162
proxyConfig: proxyConfig,
150163
networkClient: this.networkClient,
164+
retryStrategy: this.retryStrategy,
151165
});
152166
}
153167
/**
@@ -164,6 +178,24 @@ export class NetworkSession {
164178
agentOptions: this.agentOptions,
165179
proxyConfig: this.proxyConfig,
166180
networkClient: networkClient,
181+
retryStrategy: this.retryStrategy,
182+
});
183+
}
184+
/**
185+
* Generate a fresh network session by duplicating the existing configuration and network parameters, while also applying retry strategy
186+
* @param {RetryStrategy} retryStrategy
187+
* @returns {NetworkSession}
188+
*/
189+
withRetryStrategy(retryStrategy: RetryStrategy): NetworkSession {
190+
return new NetworkSession({
191+
additionalHeaders: this.additionalHeaders,
192+
baseUrls: this.baseUrls,
193+
interceptors: this.interceptors,
194+
agent: this.agent,
195+
agentOptions: this.agentOptions,
196+
proxyConfig: this.proxyConfig,
197+
networkClient: this.networkClient,
198+
retryStrategy: retryStrategy,
167199
});
168200
}
169201
}
@@ -177,4 +209,5 @@ export interface NetworkSessionInput {
177209
readonly agentOptions?: AgentOptions;
178210
readonly proxyConfig?: ProxyConfig;
179211
readonly networkClient?: NetworkClient;
212+
readonly retryStrategy?: RetryStrategy;
180213
}

0 commit comments

Comments
 (0)