Skip to content

Commit fb2372b

Browse files
phryneasmsutkowski
andauthored
add timeout option to fetchBaseQuery (#2143)
Co-authored-by: Matt Sutkowski <[email protected]>
1 parent 1e5f34c commit fb2372b

File tree

8 files changed

+134
-14
lines changed

8 files changed

+134
-14
lines changed

docs/rtk-query/api/fetchBaseQuery.mdx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ description: 'RTK Query > API: fetchBaseQuery reference'
1313

1414
This is a very small wrapper around [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) that aims to simplify requests. It is not a full-blown replacement for `axios`, `superagent`, or any other more heavy-weight library, but it will cover the large majority of your needs.
1515

16-
It takes all standard options from fetch's [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) interface, as well as `baseUrl`, a `prepareHeaders` function, an optional `fetch` function, and a `paramsSerializer` function.
16+
It takes all standard options from fetch's [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) interface, as well as `baseUrl`, a `prepareHeaders` function, an optional `fetch` function, a `paramsSerializer` function, and a `timeout`.
1717

1818
- `baseUrl` _(required)_
1919
- Typically a string like `https://api.your-really-great-app.com/v1/`. If you don't provide a `baseUrl`, it defaults to a relative path from where the request is being made. You should most likely _always_ specify this.
@@ -38,6 +38,8 @@ It takes all standard options from fetch's [`RequestInit`](https://developer.moz
3838
- A function that can be used to apply custom transformations to the data passed into [`params`](#setting-the-query-string). If you don't provide this, `params` will be given directly to `new URLSearchParms()`. With some API integrations, you may need to leverage this to use something like the [`query-string`](https://github.com/sindresorhus/query-string) library to support different array types.
3939
- `fetchFn` _(optional)_
4040
- A fetch function that overrides the default on the window. Can be useful in SSR environments where you may need to leverage `isomorphic-fetch` or `cross-fetch`.
41+
- `timeout` _(optional)_
42+
- A number in milliseconds that represents the maximum time a request can take before timing out.
4143

4244
```ts title="Return types of fetchBaseQuery" no-transpile
4345
Promise<{
@@ -114,6 +116,7 @@ There is more behavior that you can define on a per-request basis that extends t
114116
- [`body`](#setting-the-body)
115117
- [`responseHandler`](#parsing-a-Response)
116118
- [`validateStatus`](#handling-non-standard-response-status-codes)
119+
- [`timeout`](#adding-a-custom-timeout-to-requests)
117120

118121
```ts title="endpoint request options"
119122
interface FetchArgs extends RequestInit {
@@ -122,6 +125,7 @@ interface FetchArgs extends RequestInit {
122125
body?: any
123126
responseHandler?: 'json' | 'text' | ((response: Response) => Promise<any>)
124127
validateStatus?: (response: Response, body: any) => boolean
128+
timeout?: number
125129
}
126130

127131
const defaultValidateStatus = (response: Response) =>
@@ -227,3 +231,23 @@ export const customApi = createApi({
227231
}),
228232
})
229233
```
234+
235+
### Adding a custom timeout to requests
236+
237+
By default, `fetchBaseQuery` has no default timeout value set, meaning your requests will stay pending until your api resolves the request(s) or it reaches the browser's default timeout (normally 5 minutes). Most of the time, this isn't what you'll want. When using `fetchBaseQuery`, you have the ability to set a `timeout` on the `baseQuery` or on individual endpoints. When specifying both options, the endpoint value will take priority.
238+
239+
```ts title="Setting a timeout value"
240+
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
241+
242+
export const api = createApi({
243+
baseQuery: fetchBaseQuery({ baseUrl: '/api/', timeout: 10000 }), // Set a default timeout of 10 seconds
244+
endpoints: (builder) => ({
245+
getUsers: builder.query({
246+
query: () => ({
247+
url: `users`,
248+
timeout: 1000, // We know the users endpoint is _really fast_ because it's always cached. We can assume if its over > 1000ms, something is wrong and we should abort the request.
249+
}),
250+
}),
251+
}),
252+
})
253+
```

packages/toolkit/src/createAsyncThunk.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type BaseThunkAPI<
2424
extra: E
2525
requestId: string
2626
signal: AbortSignal
27+
abort: (reason?: string) => void
2728
rejectWithValue: IsUnknown<
2829
RejectedMeta,
2930
(value: RejectedValue) => RejectWithValue<RejectedValue, RejectedMeta>,
@@ -610,6 +611,7 @@ If you want to use the AbortController to react to \`abort\` events, please cons
610611
extra,
611612
requestId,
612613
signal: abortController.signal,
614+
abort,
613615
rejectWithValue: ((
614616
value: RejectedValue,
615617
meta?: RejectedMeta

packages/toolkit/src/query/baseQueryTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { MaybePromise, UnwrapPromise } from './tsHelpers'
33

44
export interface BaseQueryApi {
55
signal: AbortSignal
6+
abort: (reason?: string) => void
67
dispatch: ThunkDispatch<any, any, any>
78
getState: () => unknown
89
extra: unknown

packages/toolkit/src/query/core/buildThunks.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,15 @@ export function buildThunks<
261261
ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
262262
> = async (
263263
arg,
264-
{ signal, rejectWithValue, fulfillWithValue, dispatch, getState, extra }
264+
{
265+
signal,
266+
abort,
267+
rejectWithValue,
268+
fulfillWithValue,
269+
dispatch,
270+
getState,
271+
extra,
272+
}
265273
) => {
266274
const endpointDefinition = endpointDefinitions[arg.endpointName]
267275

@@ -274,6 +282,7 @@ export function buildThunks<
274282
let result: QueryReturnValue
275283
const baseQueryApi = {
276284
signal,
285+
abort,
277286
dispatch,
278287
getState,
279288
extra,

packages/toolkit/src/query/fetchBaseQuery.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface FetchArgs extends CustomRequestInit {
2525
body?: any
2626
responseHandler?: ResponseHandler
2727
validateStatus?: (response: Response, body: any) => boolean
28+
timeout?: number
2829
}
2930

3031
/**
@@ -38,7 +39,7 @@ const defaultValidateStatus = (response: Response) =>
3839
response.status >= 200 && response.status <= 299
3940

4041
const defaultIsJsonContentType = (headers: Headers) =>
41-
/*applicat*//ion\/(vnd\.api\+)?json/.test(headers.get('content-type') || '')
42+
/*applicat*/ /ion\/(vnd\.api\+)?json/.test(headers.get('content-type') || '')
4243

4344
const handleResponse = async (
4445
response: Response,
@@ -88,6 +89,15 @@ export type FetchBaseQueryError =
8889
data: string
8990
error: string
9091
}
92+
| {
93+
/**
94+
* * `"TIMEOUT_ERROR"`:
95+
* Request timed out
96+
**/
97+
status: 'TIMEOUT_ERROR'
98+
data?: undefined
99+
error: string
100+
}
91101
| {
92102
/**
93103
* * `"CUSTOM_ERROR"`:
@@ -136,6 +146,10 @@ export type FetchBaseQueryArgs = {
136146
* Defaults to `application/json`;
137147
*/
138148
jsonContentType?: string
149+
/**
150+
* A number in milliseconds that represents that maximum time a request can take before timing out.
151+
*/
152+
timeout?: number
139153
} & RequestInit
140154

141155
export type FetchBaseQueryMeta = { request: Request; response?: Response }
@@ -180,6 +194,9 @@ export type FetchBaseQueryMeta = { request: Request; response?: Response }
180194
* An optional predicate function to determine if `JSON.stringify()` should be called on the `body` arg of `FetchArgs`
181195
*
182196
* @param {string} jsonContentType Defaults to `application/json`. Used when automatically setting the content-type header for a request with a jsonifiable body that does not have an explicit content-type header.
197+
*
198+
* @param {number} timeout
199+
* A number in milliseconds that represents the maximum time a request can take before timing out.
183200
*/
184201
export function fetchBaseQuery({
185202
baseUrl,
@@ -188,6 +205,7 @@ export function fetchBaseQuery({
188205
paramsSerializer,
189206
isJsonContentType = defaultIsJsonContentType,
190207
jsonContentType = 'application/json',
208+
timeout: defaultTimeout,
191209
...baseFetchOptions
192210
}: FetchBaseQueryArgs = {}): BaseQueryFn<
193211
string | FetchArgs,
@@ -212,6 +230,7 @@ export function fetchBaseQuery({
212230
params = undefined,
213231
responseHandler = 'json' as const,
214232
validateStatus = defaultValidateStatus,
233+
timeout = defaultTimeout,
215234
...rest
216235
} = typeof arg == 'string' ? { url: arg } : arg
217236
let config: RequestInit = {
@@ -256,11 +275,26 @@ export function fetchBaseQuery({
256275
const requestClone = request.clone()
257276
meta = { request: requestClone }
258277

259-
let response
278+
let response,
279+
timedOut = false,
280+
timeoutId =
281+
timeout &&
282+
setTimeout(() => {
283+
timedOut = true
284+
api.abort()
285+
}, timeout)
260286
try {
261287
response = await fetchFn(request)
262288
} catch (e) {
263-
return { error: { status: 'FETCH_ERROR', error: String(e) }, meta }
289+
return {
290+
error: {
291+
status: timedOut ? 'TIMEOUT_ERROR' : 'FETCH_ERROR',
292+
error: String(e),
293+
},
294+
meta,
295+
}
296+
} finally {
297+
if (timeoutId) clearTimeout(timeoutId)
264298
}
265299
const responseClone = response.clone()
266300

packages/toolkit/src/query/tests/createApi.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ describe('endpoint definition typings', () => {
310310
const commonBaseQueryApi = {
311311
dispatch: expect.any(Function),
312312
endpoint: expect.any(String),
313+
abort: expect.any(Function),
313314
extra: undefined,
314315
forced: expect.any(Boolean),
315316
getState: expect.any(Function),
@@ -353,6 +354,7 @@ describe('endpoint definition typings', () => {
353354
endpoint: expect.any(String),
354355
getState: expect.any(Function),
355356
signal: expect.any(Object),
357+
abort: expect.any(Function),
356358
forced: expect.any(Boolean),
357359
type: expect.any(String),
358360
},
@@ -365,6 +367,7 @@ describe('endpoint definition typings', () => {
365367
endpoint: expect.any(String),
366368
getState: expect.any(Function),
367369
signal: expect.any(Object),
370+
abort: expect.any(Function),
368371
forced: expect.any(Boolean),
369372
type: expect.any(String),
370373
},
@@ -377,6 +380,7 @@ describe('endpoint definition typings', () => {
377380
endpoint: expect.any(String),
378381
getState: expect.any(Function),
379382
signal: expect.any(Object),
383+
abort: expect.any(Function),
380384
// forced: undefined,
381385
type: expect.any(String),
382386
},
@@ -389,6 +393,7 @@ describe('endpoint definition typings', () => {
389393
endpoint: expect.any(String),
390394
getState: expect.any(Function),
391395
signal: expect.any(Object),
396+
abort: expect.any(Function),
392397
// forced: undefined,
393398
type: expect.any(String),
394399
},

packages/toolkit/src/query/tests/errorHandling.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@ const failQueryOnce = rest.get('/query', (_, req, ctx) =>
3434
describe('fetchBaseQuery', () => {
3535
let commonBaseQueryApiArgs: BaseQueryApi = {} as any
3636
beforeEach(() => {
37+
const abortController = new AbortController()
3738
commonBaseQueryApiArgs = {
38-
signal: new AbortController().signal,
39+
signal: abortController.signal,
40+
abort: (reason) =>
41+
//@ts-ignore
42+
abortController.abort(reason),
3943
dispatch: storeRef.store.dispatch,
4044
getState: storeRef.store.getState,
4145
extra: undefined,

packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createSlice } from '@reduxjs/toolkit'
22
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
3-
import { setupApiStore } from './helpers'
3+
import { setupApiStore, waitMs } from './helpers'
44
import { server } from './mocks/server'
55
// @ts-ignore
66
import nodeFetch from 'node-fetch'
@@ -76,8 +76,12 @@ type RootState = ReturnType<typeof storeRef.store.getState>
7676

7777
let commonBaseQueryApi: BaseQueryApi = {} as any
7878
beforeEach(() => {
79+
let abortController = new AbortController()
7980
commonBaseQueryApi = {
80-
signal: new AbortController().signal,
81+
signal: abortController.signal,
82+
abort: (reason) =>
83+
// @ts-ignore
84+
abortController.abort(reason),
8185
dispatch: storeRef.store.dispatch,
8286
getState: storeRef.store.getState,
8387
extra: undefined,
@@ -564,11 +568,15 @@ describe('fetchBaseQuery', () => {
564568
test('prepareHeaders is able to select from a state', async () => {
565569
let request: any
566570

567-
const doRequest = async () =>
568-
baseQuery(
571+
const doRequest = async () => {
572+
const abortController = new AbortController()
573+
return baseQuery(
569574
{ url: '/echo' },
570575
{
571-
signal: new AbortController().signal,
576+
signal: abortController.signal,
577+
abort: (reason) =>
578+
// @ts-ignore
579+
abortController.abort(reason),
572580
dispatch: storeRef.store.dispatch,
573581
getState: storeRef.store.getState,
574582
extra: undefined,
@@ -577,6 +585,7 @@ describe('fetchBaseQuery', () => {
577585
},
578586
{}
579587
)
588+
}
580589

581590
;({ data: request } = await doRequest())
582591

@@ -614,11 +623,15 @@ describe('fetchBaseQuery', () => {
614623
getTokenSilently: async () => 'fakeToken',
615624
}
616625

617-
const doRequest = async () =>
618-
baseQuery(
626+
const doRequest = async () => {
627+
const abortController = new AbortController()
628+
return baseQuery(
619629
{ url: '/echo' },
620630
{
621-
signal: new AbortController().signal,
631+
signal: abortController.signal,
632+
abort: (reason) =>
633+
// @ts-ignore
634+
abortController.abort(reason),
622635
dispatch: storeRef.store.dispatch,
623636
getState: storeRef.store.getState,
624637
extra: fakeAuth0Client,
@@ -628,6 +641,7 @@ describe('fetchBaseQuery', () => {
628641
},
629642
{}
630643
)
644+
}
631645

632646
await doRequest()
633647

@@ -761,3 +775,30 @@ describe('still throws on completely unexpected errors', () => {
761775
await expect(req).rejects.toBe(error)
762776
})
763777
})
778+
779+
describe('timeout', () => {
780+
it('throws a timeout error when a request takes longer than specified timeout duration', async () => {
781+
jest.useFakeTimers('legacy')
782+
let result: any
783+
server.use(
784+
rest.get('https://example.com/empty', (req, res, ctx) =>
785+
res.once(
786+
ctx.delay(3000),
787+
ctx.json({ ...req, headers: req.headers.all() })
788+
)
789+
)
790+
)
791+
Promise.resolve(
792+
baseQuery({ url: '/empty', timeout: 2000 }, commonBaseQueryApi, {})
793+
).then((r) => {
794+
result = r
795+
})
796+
await waitMs()
797+
jest.runAllTimers()
798+
await waitMs()
799+
expect(result?.error).toEqual({
800+
status: 'TIMEOUT_ERROR',
801+
error: 'AbortError: The user aborted a request.',
802+
})
803+
})
804+
})

0 commit comments

Comments
 (0)