Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add timeout option to fetchBaseQuery #2143

Merged
merged 4 commits into from
Jul 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.',
})
})
})