Skip to content

Commit

Permalink
add timeout option to fetchBaseQuery (#2143)
Browse files Browse the repository at this point in the history
Co-authored-by: Matt Sutkowski <msutkowski@gmail.com>
  • Loading branch information
phryneas and msutkowski authored Jul 8, 2022
1 parent 1e5f34c commit fb2372b
Show file tree
Hide file tree
Showing 8 changed files with 134 additions and 14 deletions.
26 changes: 25 additions & 1 deletion docs/rtk-query/api/fetchBaseQuery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ description: 'RTK Query > API: fetchBaseQuery reference'

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.

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.
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`.

- `baseUrl` _(required)_
- 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.
Expand All @@ -38,6 +38,8 @@ It takes all standard options from fetch's [`RequestInit`](https://developer.moz
- 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.
- `fetchFn` _(optional)_
- 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`.
- `timeout` _(optional)_
- A number in milliseconds that represents the maximum time a request can take before timing out.

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

```ts title="endpoint request options"
interface FetchArgs extends RequestInit {
Expand All @@ -122,6 +125,7 @@ interface FetchArgs extends RequestInit {
body?: any
responseHandler?: 'json' | 'text' | ((response: Response) => Promise<any>)
validateStatus?: (response: Response, body: any) => boolean
timeout?: number
}
const defaultValidateStatus = (response: Response) =>
Expand Down Expand Up @@ -227,3 +231,23 @@ export const customApi = createApi({
}),
})
```

### Adding a custom timeout to requests

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.

```ts title="Setting a timeout value"
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api/', timeout: 10000 }), // Set a default timeout of 10 seconds
endpoints: (builder) => ({
getUsers: builder.query({
query: () => ({
url: `users`,
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.
}),
}),
}),
})
```
2 changes: 2 additions & 0 deletions packages/toolkit/src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type BaseThunkAPI<
extra: E
requestId: string
signal: AbortSignal
abort: (reason?: string) => void
rejectWithValue: IsUnknown<
RejectedMeta,
(value: RejectedValue) => RejectWithValue<RejectedValue, RejectedMeta>,
Expand Down Expand Up @@ -610,6 +611,7 @@ If you want to use the AbortController to react to \`abort\` events, please cons
extra,
requestId,
signal: abortController.signal,
abort,
rejectWithValue: ((
value: RejectedValue,
meta?: RejectedMeta
Expand Down
1 change: 1 addition & 0 deletions packages/toolkit/src/query/baseQueryTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { MaybePromise, UnwrapPromise } from './tsHelpers'

export interface BaseQueryApi {
signal: AbortSignal
abort: (reason?: string) => void
dispatch: ThunkDispatch<any, any, any>
getState: () => unknown
extra: unknown
Expand Down
11 changes: 10 additions & 1 deletion packages/toolkit/src/query/core/buildThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,15 @@ export function buildThunks<
ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
> = async (
arg,
{ signal, rejectWithValue, fulfillWithValue, dispatch, getState, extra }
{
signal,
abort,
rejectWithValue,
fulfillWithValue,
dispatch,
getState,
extra,
}
) => {
const endpointDefinition = endpointDefinitions[arg.endpointName]

Expand All @@ -274,6 +282,7 @@ export function buildThunks<
let result: QueryReturnValue
const baseQueryApi = {
signal,
abort,
dispatch,
getState,
extra,
Expand Down
40 changes: 37 additions & 3 deletions packages/toolkit/src/query/fetchBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface FetchArgs extends CustomRequestInit {
body?: any
responseHandler?: ResponseHandler
validateStatus?: (response: Response, body: any) => boolean
timeout?: number
}

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

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

const handleResponse = async (
response: Response,
Expand Down Expand Up @@ -88,6 +89,15 @@ export type FetchBaseQueryError =
data: string
error: string
}
| {
/**
* * `"TIMEOUT_ERROR"`:
* Request timed out
**/
status: 'TIMEOUT_ERROR'
data?: undefined
error: string
}
| {
/**
* * `"CUSTOM_ERROR"`:
Expand Down Expand Up @@ -136,6 +146,10 @@ export type FetchBaseQueryArgs = {
* Defaults to `application/json`;
*/
jsonContentType?: string
/**
* A number in milliseconds that represents that maximum time a request can take before timing out.
*/
timeout?: number
} & RequestInit

export type FetchBaseQueryMeta = { request: Request; response?: Response }
Expand Down Expand Up @@ -180,6 +194,9 @@ export type FetchBaseQueryMeta = { request: Request; response?: Response }
* An optional predicate function to determine if `JSON.stringify()` should be called on the `body` arg of `FetchArgs`
*
* @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.
*
* @param {number} timeout
* A number in milliseconds that represents the maximum time a request can take before timing out.
*/
export function fetchBaseQuery({
baseUrl,
Expand All @@ -188,6 +205,7 @@ export function fetchBaseQuery({
paramsSerializer,
isJsonContentType = defaultIsJsonContentType,
jsonContentType = 'application/json',
timeout: defaultTimeout,
...baseFetchOptions
}: FetchBaseQueryArgs = {}): BaseQueryFn<
string | FetchArgs,
Expand All @@ -212,6 +230,7 @@ export function fetchBaseQuery({
params = undefined,
responseHandler = 'json' as const,
validateStatus = defaultValidateStatus,
timeout = defaultTimeout,
...rest
} = typeof arg == 'string' ? { url: arg } : arg
let config: RequestInit = {
Expand Down Expand Up @@ -256,11 +275,26 @@ export function fetchBaseQuery({
const requestClone = request.clone()
meta = { request: requestClone }

let response
let response,
timedOut = false,
timeoutId =
timeout &&
setTimeout(() => {
timedOut = true
api.abort()
}, timeout)
try {
response = await fetchFn(request)
} catch (e) {
return { error: { status: 'FETCH_ERROR', error: String(e) }, meta }
return {
error: {
status: timedOut ? 'TIMEOUT_ERROR' : 'FETCH_ERROR',
error: String(e),
},
meta,
}
} finally {
if (timeoutId) clearTimeout(timeoutId)
}
const responseClone = response.clone()

Expand Down
5 changes: 5 additions & 0 deletions packages/toolkit/src/query/tests/createApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ describe('endpoint definition typings', () => {
const commonBaseQueryApi = {
dispatch: expect.any(Function),
endpoint: expect.any(String),
abort: expect.any(Function),
extra: undefined,
forced: expect.any(Boolean),
getState: expect.any(Function),
Expand Down Expand Up @@ -353,6 +354,7 @@ describe('endpoint definition typings', () => {
endpoint: expect.any(String),
getState: expect.any(Function),
signal: expect.any(Object),
abort: expect.any(Function),
forced: expect.any(Boolean),
type: expect.any(String),
},
Expand All @@ -365,6 +367,7 @@ describe('endpoint definition typings', () => {
endpoint: expect.any(String),
getState: expect.any(Function),
signal: expect.any(Object),
abort: expect.any(Function),
forced: expect.any(Boolean),
type: expect.any(String),
},
Expand All @@ -377,6 +380,7 @@ describe('endpoint definition typings', () => {
endpoint: expect.any(String),
getState: expect.any(Function),
signal: expect.any(Object),
abort: expect.any(Function),
// forced: undefined,
type: expect.any(String),
},
Expand All @@ -389,6 +393,7 @@ describe('endpoint definition typings', () => {
endpoint: expect.any(String),
getState: expect.any(Function),
signal: expect.any(Object),
abort: expect.any(Function),
// forced: undefined,
type: expect.any(String),
},
Expand Down
6 changes: 5 additions & 1 deletion packages/toolkit/src/query/tests/errorHandling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ const failQueryOnce = rest.get('/query', (_, req, ctx) =>
describe('fetchBaseQuery', () => {
let commonBaseQueryApiArgs: BaseQueryApi = {} as any
beforeEach(() => {
const abortController = new AbortController()
commonBaseQueryApiArgs = {
signal: new AbortController().signal,
signal: abortController.signal,
abort: (reason) =>
//@ts-ignore
abortController.abort(reason),
dispatch: storeRef.store.dispatch,
getState: storeRef.store.getState,
extra: undefined,
Expand Down
57 changes: 49 additions & 8 deletions packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createSlice } from '@reduxjs/toolkit'
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
import { setupApiStore } from './helpers'
import { setupApiStore, waitMs } from './helpers'
import { server } from './mocks/server'
// @ts-ignore
import nodeFetch from 'node-fetch'
Expand Down Expand Up @@ -76,8 +76,12 @@ type RootState = ReturnType<typeof storeRef.store.getState>

let commonBaseQueryApi: BaseQueryApi = {} as any
beforeEach(() => {
let abortController = new AbortController()
commonBaseQueryApi = {
signal: new AbortController().signal,
signal: abortController.signal,
abort: (reason) =>
// @ts-ignore
abortController.abort(reason),
dispatch: storeRef.store.dispatch,
getState: storeRef.store.getState,
extra: undefined,
Expand Down Expand Up @@ -564,11 +568,15 @@ describe('fetchBaseQuery', () => {
test('prepareHeaders is able to select from a state', async () => {
let request: any

const doRequest = async () =>
baseQuery(
const doRequest = async () => {
const abortController = new AbortController()
return baseQuery(
{ url: '/echo' },
{
signal: new AbortController().signal,
signal: abortController.signal,
abort: (reason) =>
// @ts-ignore
abortController.abort(reason),
dispatch: storeRef.store.dispatch,
getState: storeRef.store.getState,
extra: undefined,
Expand All @@ -577,6 +585,7 @@ describe('fetchBaseQuery', () => {
},
{}
)
}

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

Expand Down Expand Up @@ -614,11 +623,15 @@ describe('fetchBaseQuery', () => {
getTokenSilently: async () => 'fakeToken',
}

const doRequest = async () =>
baseQuery(
const doRequest = async () => {
const abortController = new AbortController()
return baseQuery(
{ url: '/echo' },
{
signal: new AbortController().signal,
signal: abortController.signal,
abort: (reason) =>
// @ts-ignore
abortController.abort(reason),
dispatch: storeRef.store.dispatch,
getState: storeRef.store.getState,
extra: fakeAuth0Client,
Expand All @@ -628,6 +641,7 @@ describe('fetchBaseQuery', () => {
},
{}
)
}

await doRequest()

Expand Down Expand Up @@ -761,3 +775,30 @@ describe('still throws on completely unexpected errors', () => {
await expect(req).rejects.toBe(error)
})
})

describe('timeout', () => {
it('throws a timeout error when a request takes longer than specified timeout duration', async () => {
jest.useFakeTimers('legacy')
let result: any
server.use(
rest.get('https://example.com/empty', (req, res, ctx) =>
res.once(
ctx.delay(3000),
ctx.json({ ...req, headers: req.headers.all() })
)
)
)
Promise.resolve(
baseQuery({ url: '/empty', timeout: 2000 }, commonBaseQueryApi, {})
).then((r) => {
result = r
})
await waitMs()
jest.runAllTimers()
await waitMs()
expect(result?.error).toEqual({
status: 'TIMEOUT_ERROR',
error: 'AbortError: The user aborted a request.',
})
})
})

0 comments on commit fb2372b

Please sign in to comment.