Skip to content

Commit d743f4c

Browse files
box-sdk-buildbox-sdk-build
andauthored
feat(boxsdkgen): Add configurable timeouts for SDKs (box/box-codegen#924) (#1361)
Co-authored-by: box-sdk-build <box-sdk-build@box.com>
1 parent 2bcc3ef commit d743f4c

8 files changed

Lines changed: 185 additions & 13 deletions

File tree

.codegen.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{ "engineHash": "482939a", "specHash": "77eac4b", "version": "4.4.0" }
1+
{ "engineHash": "bc04b80", "specHash": "77eac4b", "version": "4.4.0" }

docs/sdk-gen/client.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ divided across resource managers.
1717
- [Custom Base URLs](#custom-base-urls)
1818
- [Custom Agent Options](#custom-agent-options)
1919
- [Interceptors](#interceptors)
20+
- [Use Timeouts for API calls](#use-timeouts-for-api-calls)
2021
- [Use Proxy for API calls](#use-proxy-for-api-calls)
2122

2223
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
@@ -181,6 +182,20 @@ const clientWithInterceptor: BoxClient = client.withInterceptors([
181182
]);
182183
```
183184

185+
# Use Timeouts for API calls
186+
187+
In order to configure timeout for API calls, call `client.withTimeouts(config)` to create a new client with timeout settings, leaving the original client unmodified.
188+
189+
`timeoutMs` is in milliseconds and is applied to each request attempt.
190+
191+
```js
192+
const newClient = client.withTimeouts({
193+
timeoutMs: 30000,
194+
});
195+
```
196+
197+
If `timeoutMs` is not provided or is less than or equal to `0`, no SDK timeout is applied.
198+
184199
# Use Proxy for API calls
185200

186201
In order to use a proxy for API calls, calling the `client.withProxy(proxyConfig)` method creates a new client, leaving the original client unmodified, with the username and password being optional.

docs/sdk-gen/configuration.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- [Network Exception Handling](#network-exception-handling)
1414
- [Customizing Retry Parameters](#customizing-retry-parameters)
1515
- [Custom Retry Strategy](#custom-retry-strategy)
16+
- [Timeouts](#timeouts)
1617

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

@@ -174,3 +175,24 @@ const networkSession = new NetworkSession({
174175
});
175176
const client = new BoxClient({ auth, networkSession });
176177
```
178+
179+
## Timeouts
180+
181+
You can configure request timeout using `timeoutConfig` on `NetworkSession`.
182+
`timeoutMs` is in milliseconds and applies to each HTTP request attempt.
183+
184+
```js
185+
const auth = new BoxDeveloperTokenAuth({ token: 'DEVELOPER_TOKEN_GOES_HERE' });
186+
const networkSession = new NetworkSession({
187+
timeoutConfig: { timeoutMs: 30000 },
188+
});
189+
const client = new BoxClient({ auth, networkSession });
190+
```
191+
192+
How timeout handling works:
193+
194+
- The SDK applies timeout only when `timeoutMs` is provided and greater than `0`.
195+
- To disable SDK timeout handling, set `timeoutMs` to `0` (or a negative value), or omit `timeoutMs`.
196+
- On timeout, the request is aborted and treated as a network error (`Connection timeout after <timeoutMs>ms`); if retries are exhausted, the SDK throws `BoxSdkError`.
197+
- Timeout failures are handled as request exceptions, then retry behavior is controlled by the configured retry strategy.
198+
- Timeout applies to a single HTTP request attempt to the Box API (not the total time across all retries).

src/sdk-gen/client.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import { BoxSdkError } from './box/errors';
9090
import { FetchOptions } from './networking/fetchOptions';
9191
import { FetchResponse } from './networking/fetchResponse';
9292
import { BaseUrls } from './networking/baseUrls';
93+
import { TimeoutConfig } from './networking/timeoutConfig';
9394
import { ProxyConfig } from './networking/proxyConfig';
9495
import { AgentOptions } from './internal/utils';
9596
import { Interceptor } from './networking/interceptors';
@@ -279,6 +280,7 @@ export class BoxClient {
279280
| 'withExtraHeaders'
280281
| 'withCustomBaseUrls'
281282
| 'withProxy'
283+
| 'withTimeouts'
282284
| 'withCustomAgentOptions'
283285
| 'withInterceptors'
284286
> &
@@ -739,6 +741,17 @@ export class BoxClient {
739741
networkSession: this.networkSession.withProxy(config),
740742
});
741743
}
744+
/**
745+
* Create a new client with custom timeouts that will be used for every API call
746+
* @param {TimeoutConfig} config Timeout configuration.
747+
* @returns {BoxClient}
748+
*/
749+
withTimeouts(config: TimeoutConfig): BoxClient {
750+
return new BoxClient({
751+
auth: this.auth,
752+
networkSession: this.networkSession.withTimeoutConfig(config),
753+
});
754+
}
742755
/**
743756
* Create a new client with a custom set of agent options that will be used for every API call
744757
* @param {AgentOptions} agentOptions Custom set of agent options that will be used for every API call

src/sdk-gen/networking/boxNetworkClient.ts

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,55 @@ export const shouldIncludeBoxUaHeader = (options: FetchOptions) => {
4040
);
4141
};
4242

43+
function createAbortSignalWithTimeout(
44+
baseSignal: RequestInit['signal'],
45+
timeoutMs: number
46+
): {
47+
signal: AbortSignal;
48+
clearTimeout: () => void;
49+
didTimeout: () => boolean;
50+
} {
51+
const controller = new AbortController();
52+
const upstream = baseSignal as unknown as AbortSignal | undefined;
53+
let timedOut = false;
54+
55+
const abortFromUpstream = () => {
56+
try {
57+
(controller as any).abort((upstream as any)?.reason);
58+
} catch {
59+
controller.abort();
60+
}
61+
};
62+
63+
if (upstream) {
64+
if (upstream.aborted) {
65+
abortFromUpstream();
66+
} else {
67+
upstream.addEventListener('abort', abortFromUpstream, { once: true });
68+
}
69+
}
70+
71+
const timeoutId = setTimeout(() => {
72+
timedOut = true;
73+
controller.abort();
74+
}, timeoutMs);
75+
76+
// Node.js timers keep the event loop alive. If the only pending work is this
77+
// watchdog timeout, we don't want it to prevent process exit (e.g. short CLI
78+
// runs, tests, scripts). `unref()` detaches the timer from the event loop.
79+
// It’s a no-op in environments where `unref` isn’t available.
80+
(timeoutId as any)?.unref?.();
81+
82+
return {
83+
signal: controller.signal,
84+
clearTimeout: () => {
85+
clearTimeout(timeoutId);
86+
if (upstream) upstream.removeEventListener('abort', abortFromUpstream);
87+
},
88+
didTimeout: () => timedOut,
89+
};
90+
}
91+
4392
type FetchOptionsExtended = FetchOptions & {
4493
attemptNumber?: number;
4594
numberOfRetriesOnException?: number;
@@ -193,19 +242,33 @@ export class BoxNetworkClient implements NetworkClient {
193242
: void 0,
194243
});
195244

245+
const timeoutConfig = fetchOptions.networkSession?.timeoutConfig;
246+
const timeoutMs = timeoutConfig?.timeoutMs;
247+
248+
const requestTimeout =
249+
timeoutMs != null && timeoutMs > 0
250+
? createAbortSignalWithTimeout(requestInit.signal, timeoutMs)
251+
: undefined;
252+
const requestInitWithTimeout: RequestInit = requestTimeout
253+
? {
254+
...requestInit,
255+
signal: requestTimeout.signal as unknown as RequestInit['signal'],
256+
}
257+
: requestInit;
258+
196259
try {
197-
const response = await nodeFetch(
198-
''.concat(
199-
fetchOptions.url,
200-
Object.keys(params).length === 0 || fetchOptions.url.endsWith('?')
201-
? ''
202-
: '?',
203-
new URLSearchParams(params).toString()
204-
),
205-
{ ...requestInit, redirect: isBrowser() ? 'follow' : 'manual' }
260+
const requestUrl = ''.concat(
261+
fetchOptions.url,
262+
Object.keys(params).length === 0 || fetchOptions.url.endsWith('?')
263+
? ''
264+
: '?',
265+
new URLSearchParams(params).toString()
206266
);
267+
const response = await nodeFetch(requestUrl, {
268+
...requestInitWithTimeout,
269+
redirect: isBrowser() ? 'follow' : 'manual',
270+
});
207271

208-
const contentType = response.headers.get('content-type') ?? '';
209272
const ignoreResponseBody = fetchOptions.followRedirects === false;
210273

211274
let data: SerializedData | undefined;
@@ -244,8 +307,14 @@ export class BoxNetworkClient implements NetworkClient {
244307
} catch (error) {
245308
isExceptionCase = true;
246309
numberOfRetriesOnException++;
247-
caughtError = error instanceof Error ? error : new Error(String(error));
310+
if (requestTimeout?.didTimeout()) {
311+
caughtError = new Error(`Connection timeout after ${timeoutMs}ms`);
312+
} else {
313+
caughtError = error instanceof Error ? error : new Error(String(error));
314+
}
248315
fetchResponse = fetchResponse ?? { status: 0, headers: {} };
316+
} finally {
317+
requestTimeout?.clearTimeout();
249318
}
250319
const attemptForRetry = isExceptionCase
251320
? numberOfRetriesOnException
@@ -325,7 +394,7 @@ export class BoxNetworkClient implements NetworkClient {
325394
: [];
326395
if (fetchResponse.status === 0) {
327396
throw new BoxSdkError({
328-
message: `Unexpected Error occurred`,
397+
message: caughtError?.message || `Unexpected Error occurred`,
329398
timestamp: `${Date.now()}`,
330399
error: caughtError,
331400
});

src/sdk-gen/networking/network.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Agent } from '../internal/utils';
55
import { AgentOptions } from '../internal/utils';
66
import { createAgent } from '../internal/utils';
77
import { ProxyConfig } from './proxyConfig';
8+
import { TimeoutConfig } from './timeoutConfig';
89
import { BoxNetworkClient } from './boxNetworkClient';
910
import { NetworkClient } from './networkClient';
1011
import { RetryStrategy } from './retries';
@@ -22,6 +23,7 @@ export class NetworkSession {
2223
readonly networkClient: NetworkClient = new BoxNetworkClient({});
2324
readonly retryStrategy: RetryStrategy = new BoxRetryStrategy({});
2425
readonly dataSanitizer: DataSanitizer = new DataSanitizer({});
26+
readonly timeoutConfig?: TimeoutConfig;
2527
constructor(
2628
fields: Omit<
2729
NetworkSession,
@@ -40,6 +42,7 @@ export class NetworkSession {
4042
| 'withNetworkClient'
4143
| 'withRetryStrategy'
4244
| 'withDataSanitizer'
45+
| 'withTimeoutConfig'
4346
> &
4447
Partial<
4548
Pick<
@@ -81,6 +84,9 @@ export class NetworkSession {
8184
if (fields.dataSanitizer !== undefined) {
8285
this.dataSanitizer = fields.dataSanitizer;
8386
}
87+
if (fields.timeoutConfig !== undefined) {
88+
this.timeoutConfig = fields.timeoutConfig;
89+
}
8490
}
8591
/**
8692
* 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.
@@ -122,6 +128,7 @@ export class NetworkSession {
122128
networkClient: this.networkClient,
123129
retryStrategy: this.retryStrategy,
124130
dataSanitizer: this.dataSanitizer,
131+
timeoutConfig: this.timeoutConfig,
125132
});
126133
}
127134
/**
@@ -140,6 +147,7 @@ export class NetworkSession {
140147
networkClient: this.networkClient,
141148
retryStrategy: this.retryStrategy,
142149
dataSanitizer: this.dataSanitizer,
150+
timeoutConfig: this.timeoutConfig,
143151
});
144152
}
145153
/**
@@ -158,6 +166,7 @@ export class NetworkSession {
158166
networkClient: this.networkClient,
159167
retryStrategy: this.retryStrategy,
160168
dataSanitizer: this.dataSanitizer,
169+
timeoutConfig: this.timeoutConfig,
161170
});
162171
}
163172
/**
@@ -176,6 +185,7 @@ export class NetworkSession {
176185
networkClient: this.networkClient,
177186
retryStrategy: this.retryStrategy,
178187
dataSanitizer: this.dataSanitizer,
188+
timeoutConfig: this.timeoutConfig,
179189
});
180190
}
181191
/**
@@ -194,6 +204,7 @@ export class NetworkSession {
194204
networkClient: networkClient,
195205
retryStrategy: this.retryStrategy,
196206
dataSanitizer: this.dataSanitizer,
207+
timeoutConfig: this.timeoutConfig,
197208
});
198209
}
199210
/**
@@ -212,6 +223,7 @@ export class NetworkSession {
212223
networkClient: this.networkClient,
213224
retryStrategy: retryStrategy,
214225
dataSanitizer: this.dataSanitizer,
226+
timeoutConfig: this.timeoutConfig,
215227
});
216228
}
217229
/**
@@ -231,6 +243,26 @@ export class NetworkSession {
231243
networkClient: this.networkClient,
232244
retryStrategy: this.retryStrategy,
233245
dataSanitizer: dataSanitizer,
246+
timeoutConfig: this.timeoutConfig,
247+
});
248+
}
249+
/**
250+
* Generate a fresh network session by duplicating the existing configuration and network parameters, while also applying timeout config
251+
* @param {TimeoutConfig} timeoutConfig
252+
* @returns {NetworkSession}
253+
*/
254+
withTimeoutConfig(timeoutConfig: TimeoutConfig): NetworkSession {
255+
return new NetworkSession({
256+
additionalHeaders: this.additionalHeaders,
257+
baseUrls: this.baseUrls,
258+
interceptors: this.interceptors,
259+
agent: this.agent,
260+
agentOptions: this.agentOptions,
261+
proxyConfig: this.proxyConfig,
262+
networkClient: this.networkClient,
263+
retryStrategy: this.retryStrategy,
264+
dataSanitizer: this.dataSanitizer,
265+
timeoutConfig: timeoutConfig,
234266
});
235267
}
236268
}
@@ -246,4 +278,5 @@ export interface NetworkSessionInput {
246278
readonly networkClient?: NetworkClient;
247279
readonly retryStrategy?: RetryStrategy;
248280
readonly dataSanitizer?: DataSanitizer;
281+
readonly timeoutConfig?: TimeoutConfig;
249282
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface TimeoutConfig {
2+
readonly timeoutMs?: number;
3+
}

tests/sdk-gen/client.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { FileFull } from '@/schemas/fileFull';
3737
import { ResponseFormat } from '@/networking/fetchOptions';
3838
import { UserFull } from '@/schemas/userFull';
3939
import { CreateUserRequestBody } from '@/managers/users';
40+
import { TimeoutConfig } from '@/networking/timeoutConfig';
4041
import { getUuid } from '@/internal/utils';
4142
import { generateByteStream } from '@/internal/utils';
4243
import { bufferEquals } from '@/internal/utils';
@@ -368,6 +369,22 @@ test('testWithCustomBaseUrls', async function testWithCustomBaseUrls(): Promise<
368369
await customBaseClient.users.getUserMe();
369370
}).rejects.toThrow();
370371
});
372+
test('testWithTimeoutWhenTimeoutOccurs', async function testWithTimeoutWhenTimeoutOccurs(): Promise<any> {
373+
const timeoutMs: number = 1;
374+
const clientWithTimeout: BoxClient = client.withTimeouts({
375+
timeoutMs: timeoutMs,
376+
} satisfies TimeoutConfig);
377+
await expect(async () => {
378+
await clientWithTimeout.users.getUserMe();
379+
}).rejects.toThrow();
380+
});
381+
test('testWithTimeoutWhenTimeoutDoesNotOccur', async function testWithTimeoutWhenTimeoutDoesNotOccur(): Promise<any> {
382+
const timeoutMs: number = 10000;
383+
const clientWithTimeout: BoxClient = client.withTimeouts({
384+
timeoutMs: timeoutMs,
385+
} satisfies TimeoutConfig);
386+
await clientWithTimeout.users.getUserMe();
387+
});
371388
test('testWithInterceptors', async function testWithInterceptors(): Promise<any> {
372389
const user: UserFull = await client.users.getUserMe();
373390
if (!(user.role == void 0)) {

0 commit comments

Comments
 (0)