Skip to content

Commit 85f379b

Browse files
authored
Add timeout to http client requests (#1351)
## Description `fetch` does not have a default timeout, so there is a non-zero chance of our HTTP requests to WorkOS servers hanging indefinitely. This PR introduces `timeout` as an optional parameter while initializing WorkOS client to terminate the request if its taking longer than `timeout` milliseconds. ## Documentation Does this require changes to the WorkOS Docs? E.g. the [API Reference](https://workos.com/docs/reference) or code snippets need updates. ``` [ ] Yes ``` If yes, link a related docs PR and add a docs maintainer as a reviewer. Their approval is required.
1 parent a221189 commit 85f379b

File tree

5 files changed

+187
-38
lines changed

5 files changed

+187
-38
lines changed

src/common/interfaces/workos-options.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export interface WorkOSOptions {
88
appInfo?: AppInfo;
99
fetchFn?: typeof fetch;
1010
clientId?: string;
11+
timeout?: number; // Timeout in milliseconds
1112
}

src/common/net/fetch-client.spec.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fetch from 'jest-fetch-mock';
22
import { fetchOnce, fetchURL } from '../../common/utils/test-utils';
33
import { FetchHttpClient } from './fetch-client';
4+
import { HttpClientError } from './http-client';
45
import { ParseError } from '../exceptions/parse-error';
56

67
const fetchClient = new FetchHttpClient('https://test.workos.com', {
@@ -304,3 +305,102 @@ describe('Fetch client', () => {
304305
});
305306
});
306307
});
308+
309+
describe('FetchHttpClient with timeout', () => {
310+
let client: FetchHttpClient;
311+
let mockFetch: jest.Mock;
312+
313+
beforeEach(() => {
314+
mockFetch = jest.fn();
315+
client = new FetchHttpClient(
316+
'https://api.example.com',
317+
{ timeout: 100 },
318+
mockFetch,
319+
);
320+
});
321+
322+
it('should timeout requests that take too long', async () => {
323+
// Mock a fetch that respects AbortController
324+
mockFetch.mockImplementation((_, options) => {
325+
return new Promise((_, reject) => {
326+
if (options.signal) {
327+
options.signal.addEventListener('abort', () => {
328+
const error = new Error('AbortError');
329+
error.name = 'AbortError';
330+
reject(error);
331+
});
332+
}
333+
// Never resolve - let the timeout trigger
334+
});
335+
});
336+
337+
await expect(client.post('/test', { data: 'test' }, {})).rejects.toThrow(
338+
HttpClientError,
339+
);
340+
341+
// Reset the mock for the second test
342+
mockFetch.mockClear();
343+
mockFetch.mockImplementation((_, options) => {
344+
return new Promise((_, reject) => {
345+
if (options.signal) {
346+
options.signal.addEventListener('abort', () => {
347+
const error = new Error('AbortError');
348+
error.name = 'AbortError';
349+
reject(error);
350+
});
351+
}
352+
// Never resolve - let the timeout trigger
353+
});
354+
});
355+
356+
await expect(
357+
client.post('/test', { data: 'test' }, {}),
358+
).rejects.toMatchObject({
359+
message: 'Request timeout after 100ms',
360+
response: {
361+
status: 408,
362+
data: { error: 'Request timeout' },
363+
},
364+
});
365+
});
366+
367+
it('should not timeout requests that complete quickly', async () => {
368+
const mockResponse = {
369+
ok: true,
370+
status: 200,
371+
headers: new Map(),
372+
json: () => Promise.resolve({ success: true }),
373+
text: () => Promise.resolve('{"success": true}'),
374+
};
375+
376+
mockFetch.mockResolvedValue(mockResponse);
377+
378+
const result = await client.post('/test', { data: 'test' }, {});
379+
expect(result).toBeDefined();
380+
});
381+
382+
it('should work without timeout configured', async () => {
383+
const clientWithoutTimeout = new FetchHttpClient(
384+
'https://api.example.com',
385+
{},
386+
mockFetch,
387+
);
388+
389+
const mockResponse = {
390+
ok: true,
391+
status: 200,
392+
headers: new Map(),
393+
json: () => Promise.resolve({ success: true }),
394+
text: () => Promise.resolve('{"success": true}'),
395+
};
396+
397+
mockFetch.mockResolvedValue(mockResponse);
398+
399+
const result = await clientWithoutTimeout.post(
400+
'/test',
401+
{ data: 'test' },
402+
{},
403+
);
404+
expect(result).toBeDefined();
405+
});
406+
});

src/common/net/fetch-client.ts

Lines changed: 84 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ import {
88
import { HttpClient, HttpClientError, HttpClientResponse } from './http-client';
99
import { ParseError } from '../exceptions/parse-error';
1010

11+
interface FetchHttpClientOptions extends RequestInit {
12+
timeout?: number;
13+
}
14+
15+
const DEFAULT_FETCH_TIMEOUT = 60_000; // 60 seconds
1116
export class FetchHttpClient extends HttpClient implements HttpClientInterface {
1217
private readonly _fetchFn;
1318

1419
constructor(
1520
readonly baseURL: string,
16-
readonly options?: RequestInit,
21+
readonly options?: FetchHttpClientOptions,
1722
fetchFn?: typeof fetch,
1823
) {
1924
super(baseURL, options);
@@ -167,50 +172,91 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface {
167172

168173
const requestBody = body || (methodHasPayload ? '' : undefined);
169174

170-
const { 'User-Agent': userAgent } = this.options?.headers as RequestHeaders;
175+
const { 'User-Agent': userAgent } = (this.options?.headers ||
176+
{}) as RequestHeaders;
177+
178+
// Create AbortController for timeout if configured
179+
let abortController: AbortController | undefined;
180+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
181+
182+
// Access timeout from the options with default of 60 seconds
183+
const timeout = this.options?.timeout ?? DEFAULT_FETCH_TIMEOUT; // Default 60 seconds
184+
abortController = new AbortController();
185+
timeoutId = setTimeout(() => {
186+
abortController?.abort();
187+
}, timeout);
188+
189+
try {
190+
const res = await this._fetchFn(url, {
191+
method,
192+
headers: {
193+
Accept: 'application/json, text/plain, */*',
194+
'Content-Type': 'application/json',
195+
...this.options?.headers,
196+
...headers,
197+
'User-Agent': this.addClientToUserAgent(
198+
(userAgent || 'workos-node').toString(),
199+
),
200+
},
201+
body: requestBody,
202+
signal: abortController?.signal,
203+
});
171204

172-
const res = await this._fetchFn(url, {
173-
method,
174-
headers: {
175-
Accept: 'application/json, text/plain, */*',
176-
'Content-Type': 'application/json',
177-
...this.options?.headers,
178-
...headers,
179-
'User-Agent': this.addClientToUserAgent(userAgent.toString()),
180-
},
181-
body: requestBody,
182-
});
205+
// Clear timeout if request completed successfully
206+
if (timeoutId) {
207+
clearTimeout(timeoutId);
208+
}
183209

184-
if (!res.ok) {
185-
const requestID = res.headers.get('X-Request-ID') ?? '';
186-
const rawBody = await res.text();
210+
if (!res.ok) {
211+
const requestID = res.headers.get('X-Request-ID') ?? '';
212+
const rawBody = await res.text();
213+
214+
let responseJson: any;
215+
216+
try {
217+
responseJson = JSON.parse(rawBody);
218+
} catch (error) {
219+
if (error instanceof SyntaxError) {
220+
throw new ParseError({
221+
message: error.message,
222+
rawBody,
223+
requestID,
224+
rawStatus: res.status,
225+
});
226+
}
227+
throw error;
228+
}
187229

188-
let responseJson: any;
230+
throw new HttpClientError({
231+
message: res.statusText,
232+
response: {
233+
status: res.status,
234+
headers: res.headers,
235+
data: responseJson,
236+
},
237+
});
238+
}
239+
return new FetchHttpClientResponse(res);
240+
} catch (error) {
241+
// Clear timeout if request failed
242+
if (timeoutId) {
243+
clearTimeout(timeoutId);
244+
}
189245

190-
try {
191-
responseJson = JSON.parse(rawBody);
192-
} catch (error) {
193-
if (error instanceof SyntaxError) {
194-
throw new ParseError({
195-
message: error.message,
196-
rawBody,
197-
requestID,
198-
rawStatus: res.status,
199-
});
200-
}
201-
throw error;
246+
// Handle timeout errors
247+
if (error instanceof Error && error.name === 'AbortError') {
248+
throw new HttpClientError({
249+
message: `Request timeout after ${timeout}ms`,
250+
response: {
251+
status: 408,
252+
headers: {},
253+
data: { error: 'Request timeout' },
254+
},
255+
});
202256
}
203257

204-
throw new HttpClientError({
205-
message: res.statusText,
206-
response: {
207-
status: res.status,
208-
headers: res.headers,
209-
data: responseJson,
210-
},
211-
});
258+
throw error;
212259
}
213-
return new FetchHttpClientResponse(res);
214260
}
215261

216262
private async fetchRequestWithRetry(

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class WorkOSNode extends WorkOS {
3535
createHttpClient(options: WorkOSOptions, userAgent: string): HttpClient {
3636
const opts = {
3737
...options.config,
38+
timeout: options.timeout, // Pass through the timeout option
3839
headers: {
3940
...options.config?.headers,
4041
Authorization: `Bearer ${this.key}`,

src/workos.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export class WorkOS {
135135
createHttpClient(options: WorkOSOptions, userAgent: string) {
136136
return new FetchHttpClient(this.baseURL, {
137137
...options.config,
138+
timeout: options.timeout,
138139
headers: {
139140
...options.config?.headers,
140141
Authorization: `Bearer ${this.key}`,

0 commit comments

Comments
 (0)