Skip to content

Commit

Permalink
feat: Allow for customizing retry strategy (box/box-codegen#635) (#457)
Browse files Browse the repository at this point in the history
Co-authored-by: box-sdk-build <[email protected]>
  • Loading branch information
box-sdk-build and box-sdk-build authored Dec 30, 2024
1 parent 09765a4 commit 530ca33
Show file tree
Hide file tree
Showing 6 changed files with 266 additions and 86 deletions.
2 changes: 1 addition & 1 deletion .codegen.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{ "engineHash": "cc6ffb8", "specHash": "6886603", "version": "1.9.0" }
{ "engineHash": "a2387ff", "specHash": "6886603", "version": "1.9.0" }
53 changes: 53 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Configuration

<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->

- [Max retry attempts](#max-retry-attempts)
- [Custom retry strategy](#custom-retry-strategy)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

## Max retry attempts

The default maximum number of retries in case of failed API call is 5.
To change this number you should initialize `BoxRetryStrategy` with the new value and pass it to `NetworkSession`.

```js
const auth = new BoxDeveloperTokenAuth({ token: 'DEVELOPER_TOKEN_GOES_HERE' });
const networkSession = new NetworkSession({
retryStrategy: new BoxRetryStrategy({ maxAttempts: 6 }),
});
const client = new BoxClient({ auth, networkSession });
```

## Custom retry strategy

You can also implement your own retry strategy by subclassing `RetryStrategy` and overriding `shouldRetry` and `retryAfter` methods.
This example shows how to set custom strategy that retries on 5xx status codes and waits 1 second between retries.

```ts
export class CustomRetryStrategy implements RetryStrategy {
async shouldRetry(
fetchOptions: FetchOptions,
fetchResponse: FetchResponse,
attemptNumber: number,
): Promise<boolean> {
return false;
}

retryAfter(
fetchOptions: FetchOptions,
fetchResponse: FetchResponse,
attemptNumber: number,
): number {
return 1.0;
}
}

const auth = new BoxDeveloperTokenAuth({ token: 'DEVELOPER_TOKEN_GOES_HERE' });
const networkSession = new NetworkSession({
retryStrategy: new CustomRetryStrategy(),
});
const client = new BoxClient({ auth, networkSession });
```
4 changes: 4 additions & 0 deletions src/internal/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,3 +527,7 @@ export async function computeWebhookSignature(
}
return signature;
}

export function random(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
143 changes: 58 additions & 85 deletions src/networking/boxNetworkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { Interceptor } from './interceptors.generated';
import { FetchOptions } from './fetchOptions.generated';
import { FetchResponse } from './fetchResponse.generated';
import { NetworkSession } from './network.generated';

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

const DEFAULT_MAX_ATTEMPTS = 5;
const RETRY_BASE_INTERVAL = 1;
const STATUS_CODE_ACCEPTED = 202,
STATUS_CODE_UNAUTHORIZED = 401,
STATUS_CODE_TOO_MANY_REQUESTS = 429;

export class BoxNetworkClient implements NetworkClient {
constructor(
fields?: Omit<BoxNetworkClient, 'fetch'> &
Expand All @@ -150,9 +145,10 @@ export class BoxNetworkClient implements NetworkClient {
Object.assign(this, fields);
}
async fetch(options: FetchOptionsExtended): Promise<FetchResponse> {
const fetchOptions: typeof options = options.networkSession?.interceptors
?.length
? options.networkSession?.interceptors.reduce(
const numRetries = options.numRetries ?? 0;
const networkSession = options.networkSession ?? new NetworkSession({});
const fetchOptions: typeof options = networkSession.interceptors?.length
? networkSession.interceptors.reduce(
(modifiedOptions: FetchOptions, interceptor: Interceptor) =>
interceptor.beforeRequest(modifiedOptions),
options,
Expand Down Expand Up @@ -203,14 +199,30 @@ export class BoxNetworkClient implements NetworkClient {
content,
headers: Object.fromEntries(Array.from(response.headers.entries())),
};
if (fetchOptions.networkSession?.interceptors?.length) {
fetchResponse = fetchOptions.networkSession?.interceptors.reduce(
if (networkSession.interceptors?.length) {
fetchResponse = networkSession.interceptors.reduce(
(modifiedResponse: FetchResponse, interceptor: Interceptor) =>
interceptor.afterRequest(modifiedResponse),
fetchResponse,
);
}

const shouldRetry = await networkSession.retryStrategy.shouldRetry(
fetchOptions,
fetchResponse,
numRetries,
);

if (shouldRetry) {
const retryTimeout = networkSession.retryStrategy.retryAfter(
fetchOptions,
fetchResponse,
numRetries,
);
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
return this.fetch({ ...options, numRetries: numRetries + 1 });
}

if (
fetchResponse.status >= 300 &&
fetchResponse.status < 400 &&
Expand All @@ -227,82 +239,43 @@ export class BoxNetworkClient implements NetworkClient {
});
}

const acceptedWithRetryAfter =
fetchResponse.status === STATUS_CODE_ACCEPTED &&
fetchResponse.headers['retry-after'];
const { numRetries = 0 } = fetchOptions;
if (
fetchResponse.status >= 400 ||
(acceptedWithRetryAfter && numRetries < DEFAULT_MAX_ATTEMPTS)
) {
const reauthenticationNeeded =
fetchResponse.status == STATUS_CODE_UNAUTHORIZED;
if (reauthenticationNeeded && fetchOptions.auth) {
await fetchOptions.auth.refreshToken(fetchOptions.networkSession);

// retry the request right away
return this.fetch({
...options,
numRetries: numRetries + 1,
fileStream: fileStreamBuffer
? generateByteStreamFromBuffer(fileStreamBuffer)
: void 0,
});
}

const isRetryable =
fetchOptions.contentType !== 'application/x-www-form-urlencoded' &&
(fetchResponse.status === STATUS_CODE_TOO_MANY_REQUESTS ||
acceptedWithRetryAfter ||
fetchResponse.status >= 500);

if (isRetryable && numRetries < DEFAULT_MAX_ATTEMPTS) {
const retryTimeout = fetchResponse.headers['retry-after']
? parseFloat(fetchResponse.headers['retry-after']!) * 1000
: getRetryTimeout(numRetries, RETRY_BASE_INTERVAL * 1000);

await new Promise((resolve) => setTimeout(resolve, retryTimeout));
return this.fetch({ ...options, numRetries: numRetries + 1 });
}

const [code, contextInfo, requestId, helpUrl] = sdIsMap(
fetchResponse.data,
)
? [
sdToJson(fetchResponse.data['code']),
sdIsMap(fetchResponse.data['context_info'])
? fetchResponse.data['context_info']
: undefined,
sdToJson(fetchResponse.data['request_id']),
sdToJson(fetchResponse.data['help_url']),
]
: [];

throw new BoxApiError({
message: `${fetchResponse.status}`,
timestamp: `${Date.now()}`,
requestInfo: {
method: requestInit.method!,
url: fetchOptions.url,
queryParams: params,
headers: (requestInit.headers as { [key: string]: string }) ?? {},
body:
typeof requestInit.body === 'string' ? requestInit.body : undefined,
},
responseInfo: {
statusCode: fetchResponse.status,
headers: fetchResponse.headers,
body: fetchResponse.data,
rawBody: new TextDecoder().decode(responseBytesBuffer),
code: code,
contextInfo: contextInfo,
requestId: requestId,
helpUrl: helpUrl,
},
});
if (fetchResponse.status >= 200 && fetchResponse.status < 400) {
return fetchResponse;
}

return fetchResponse;
const [code, contextInfo, requestId, helpUrl] = sdIsMap(fetchResponse.data)
? [
sdToJson(fetchResponse.data['code']),
sdIsMap(fetchResponse.data['context_info'])
? fetchResponse.data['context_info']
: undefined,
sdToJson(fetchResponse.data['request_id']),
sdToJson(fetchResponse.data['help_url']),
]
: [];

throw new BoxApiError({
message: `${fetchResponse.status}`,
timestamp: `${Date.now()}`,
requestInfo: {
method: requestInit.method!,
url: fetchOptions.url,
queryParams: params,
headers: (requestInit.headers as { [key: string]: string }) ?? {},
body:
typeof requestInit.body === 'string' ? requestInit.body : undefined,
},
responseInfo: {
statusCode: fetchResponse.status,
headers: fetchResponse.headers,
body: fetchResponse.data,
rawBody: new TextDecoder().decode(responseBytesBuffer),
code: code,
contextInfo: contextInfo,
requestId: requestId,
helpUrl: helpUrl,
},
});
}
}

Expand Down
33 changes: 33 additions & 0 deletions src/networking/network.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { createAgent } from '../internal/utils.js';
import { ProxyConfig } from './proxyConfig.generated.js';
import { BoxNetworkClient } from './boxNetworkClient.js';
import { NetworkClient } from './networkClient.generated.js';
import { RetryStrategy } from './retries.generated.js';
import { BoxRetryStrategy } from './retries.generated.js';
export class NetworkSession {
readonly additionalHeaders: {
readonly [key: string]: string;
Expand All @@ -16,6 +18,7 @@ export class NetworkSession {
readonly agentOptions?: AgentOptions;
readonly proxyConfig?: ProxyConfig;
readonly networkClient: NetworkClient = new BoxNetworkClient({});
readonly retryStrategy: RetryStrategy = new BoxRetryStrategy({});
constructor(
fields: Omit<
NetworkSession,
Expand All @@ -24,12 +27,14 @@ export class NetworkSession {
| 'interceptors'
| 'agent'
| 'networkClient'
| 'retryStrategy'
| 'withAdditionalHeaders'
| 'withCustomBaseUrls'
| 'withCustomAgentOptions'
| 'withInterceptors'
| 'withProxy'
| 'withNetworkClient'
| 'withRetryStrategy'
> &
Partial<
Pick<
Expand All @@ -39,6 +44,7 @@ export class NetworkSession {
| 'interceptors'
| 'agent'
| 'networkClient'
| 'retryStrategy'
>
>,
) {
Expand All @@ -63,6 +69,9 @@ export class NetworkSession {
if (fields.networkClient !== undefined) {
this.networkClient = fields.networkClient;
}
if (fields.retryStrategy !== undefined) {
this.retryStrategy = fields.retryStrategy;
}
}
/**
* 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.
Expand All @@ -84,6 +93,7 @@ export class NetworkSession {
agentOptions: this.agentOptions,
proxyConfig: this.proxyConfig,
networkClient: this.networkClient,
retryStrategy: this.retryStrategy,
});
}
/**
Expand All @@ -100,6 +110,7 @@ export class NetworkSession {
agentOptions: this.agentOptions,
proxyConfig: this.proxyConfig,
networkClient: this.networkClient,
retryStrategy: this.retryStrategy,
});
}
/**
Expand All @@ -116,6 +127,7 @@ export class NetworkSession {
agentOptions: this.agentOptions,
proxyConfig: this.proxyConfig,
networkClient: this.networkClient,
retryStrategy: this.retryStrategy,
});
}
/**
Expand All @@ -132,6 +144,7 @@ export class NetworkSession {
agentOptions: this.agentOptions,
proxyConfig: this.proxyConfig,
networkClient: this.networkClient,
retryStrategy: this.retryStrategy,
});
}
/**
Expand All @@ -148,6 +161,7 @@ export class NetworkSession {
agentOptions: this.agentOptions,
proxyConfig: proxyConfig,
networkClient: this.networkClient,
retryStrategy: this.retryStrategy,
});
}
/**
Expand All @@ -164,6 +178,24 @@ export class NetworkSession {
agentOptions: this.agentOptions,
proxyConfig: this.proxyConfig,
networkClient: networkClient,
retryStrategy: this.retryStrategy,
});
}
/**
* Generate a fresh network session by duplicating the existing configuration and network parameters, while also applying retry strategy
* @param {RetryStrategy} retryStrategy
* @returns {NetworkSession}
*/
withRetryStrategy(retryStrategy: RetryStrategy): NetworkSession {
return new NetworkSession({
additionalHeaders: this.additionalHeaders,
baseUrls: this.baseUrls,
interceptors: this.interceptors,
agent: this.agent,
agentOptions: this.agentOptions,
proxyConfig: this.proxyConfig,
networkClient: this.networkClient,
retryStrategy: retryStrategy,
});
}
}
Expand All @@ -177,4 +209,5 @@ export interface NetworkSessionInput {
readonly agentOptions?: AgentOptions;
readonly proxyConfig?: ProxyConfig;
readonly networkClient?: NetworkClient;
readonly retryStrategy?: RetryStrategy;
}
Loading

0 comments on commit 530ca33

Please sign in to comment.