Skip to content

Commit c86d948

Browse files
authored
Add retry abort handling and abort on resetApiState (#5114)
* Add retry abort signal handling * Abort all promises on resetApiState * Add test for global responseHandler
1 parent 02630d2 commit c86d948

File tree

5 files changed

+568
-11
lines changed

5 files changed

+568
-11
lines changed

packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,15 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
8383

8484
const currentRemovalTimeouts: QueryStateMeta<TimeoutId> = {}
8585

86-
const handler: ApiMiddlewareInternalHandler = (
87-
action,
88-
mwApi,
89-
internalState,
90-
) => {
86+
function abortAllPromises<T extends { abort?: () => void }>(
87+
promiseMap: Map<string, T | undefined>,
88+
): void {
89+
for (const promise of promiseMap.values()) {
90+
promise?.abort?.()
91+
}
92+
}
93+
94+
const handler: ApiMiddlewareInternalHandler = (action, mwApi) => {
9195
const state = mwApi.getState()
9296
const config = selectConfig(state)
9397

@@ -113,6 +117,9 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
113117
if (timeout) clearTimeout(timeout)
114118
delete currentRemovalTimeouts[key]
115119
}
120+
121+
abortAllPromises(internalState.runningQueries)
122+
abortAllPromises(internalState.runningMutations)
116123
}
117124

118125
if (context.hasRehydrationInfo(action)) {

packages/toolkit/src/query/retry.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,34 @@ import { HandledError } from './HandledError'
2323
* @param attempt - Current attempt
2424
* @param maxRetries - Maximum number of retries
2525
*/
26-
async function defaultBackoff(attempt: number = 0, maxRetries: number = 5) {
26+
async function defaultBackoff(
27+
attempt: number = 0,
28+
maxRetries: number = 5,
29+
signal?: AbortSignal,
30+
) {
2731
const attempts = Math.min(attempt, maxRetries)
2832

2933
const timeout = ~~((Math.random() + 0.4) * (300 << attempts)) // Force a positive int in the case we make this an option
30-
await new Promise((resolve) =>
31-
setTimeout((res: any) => resolve(res), timeout),
32-
)
34+
35+
await new Promise<void>((resolve, reject) => {
36+
const timeoutId = setTimeout(() => resolve(), timeout)
37+
38+
// If signal is provided and gets aborted, clear timeout and reject
39+
if (signal) {
40+
const abortHandler = () => {
41+
clearTimeout(timeoutId)
42+
reject(new Error('Aborted'))
43+
}
44+
45+
// Check if already aborted
46+
if (signal.aborted) {
47+
clearTimeout(timeoutId)
48+
reject(new Error('Aborted'))
49+
} else {
50+
signal.addEventListener('abort', abortHandler, { once: true })
51+
}
52+
}
53+
})
3354
}
3455

3556
type RetryConditionFunction = (
@@ -46,7 +67,11 @@ export type RetryOptions = {
4667
/**
4768
* Function used to determine delay between retries
4869
*/
49-
backoff?: (attempt: number, maxRetries: number) => Promise<void>
70+
backoff?: (
71+
attempt: number,
72+
maxRetries: number,
73+
signal?: AbortSignal,
74+
) => Promise<void>
5075
} & (
5176
| {
5277
/**
@@ -74,6 +99,16 @@ function fail<BaseQuery extends BaseQueryFn = BaseQueryFn>(
7499
})
75100
}
76101

102+
/**
103+
* Checks if the abort signal is aborted and fails immediately if so.
104+
* Used to exit retry loops cleanly when a request is aborted.
105+
*/
106+
function failIfAborted(signal: AbortSignal): void {
107+
if (signal.aborted) {
108+
fail({ status: 'CUSTOM_ERROR', error: 'Aborted' })
109+
}
110+
}
111+
77112
const EMPTY_OPTIONS = {}
78113

79114
const retryWithBackoff: BaseQueryEnhancer<
@@ -108,6 +143,9 @@ const retryWithBackoff: BaseQueryEnhancer<
108143
let retry = 0
109144

110145
while (true) {
146+
// Check if aborted before each attempt
147+
failIfAborted(api.signal)
148+
111149
try {
112150
const result = await baseQuery(args, api, extraOptions)
113151
// baseQueries _should_ return an error property, so we should check for that and throw it to continue retrying
@@ -145,7 +183,17 @@ const retryWithBackoff: BaseQueryEnhancer<
145183
}
146184
}
147185

148-
await options.backoff(retry, options.maxRetries)
186+
// Check if aborted before backoff
187+
failIfAborted(api.signal)
188+
189+
try {
190+
await options.backoff(retry, options.maxRetries, api.signal)
191+
} catch (backoffError) {
192+
// If backoff was aborted, exit the retry loop
193+
failIfAborted(api.signal)
194+
// Otherwise, rethrow the backoff error
195+
throw backoffError
196+
}
149197
}
150198
}
151199
}

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,50 @@ describe(`query: await cleanup, keepUnusedDataFor set`, () => {
156156
})
157157
})
158158

159+
describe('resetApiState cleanup', () => {
160+
test('resetApiState aborts multiple running queries and mutations', async () => {
161+
const { store, api } = storeForApi(
162+
createApi({
163+
baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }),
164+
endpoints: (build) => ({
165+
query1: build.query<unknown, string>({
166+
query: () => '/success',
167+
}),
168+
query2: build.query<unknown, string>({
169+
query: () => '/success',
170+
}),
171+
mutation: build.mutation<unknown, string>({
172+
query: () => ({
173+
url: '/success',
174+
method: 'POST',
175+
}),
176+
}),
177+
}),
178+
}),
179+
)
180+
181+
// Start multiple queries and a mutation
182+
const queryPromise1 = store.dispatch(api.endpoints.query1.initiate('arg1'))
183+
const queryPromise2 = store.dispatch(api.endpoints.query2.initiate('arg2'))
184+
const mutationPromise = store.dispatch(
185+
api.endpoints.mutation.initiate('arg'),
186+
)
187+
188+
// Spy on abort methods
189+
queryPromise1.abort = vi.fn(queryPromise1.abort)
190+
queryPromise2.abort = vi.fn(queryPromise2.abort)
191+
mutationPromise.abort = vi.fn(mutationPromise.abort)
192+
193+
// Dispatch resetApiState
194+
store.dispatch(api.util.resetApiState())
195+
196+
// Verify all aborts were called
197+
expect(queryPromise1.abort).toHaveBeenCalled()
198+
expect(queryPromise2.abort).toHaveBeenCalled()
199+
expect(mutationPromise.abort).toHaveBeenCalled()
200+
})
201+
})
202+
159203
function storeForApi<
160204
A extends {
161205
reducerPath: 'api'

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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,146 @@ describe('fetchBaseQuery', () => {
992992
expect(res.data).toEqual(`this is not json!`)
993993
})
994994

995+
test('Global responseHandler: content-type with text response', async () => {
996+
server.use(
997+
http.get(
998+
'https://example.com/success',
999+
() => HttpResponse.text(`this is plain text!`),
1000+
{ once: true },
1001+
),
1002+
)
1003+
1004+
const globalizedBaseQuery = fetchBaseQuery({
1005+
baseUrl,
1006+
fetchFn: fetchFn as any,
1007+
responseHandler: 'content-type',
1008+
})
1009+
1010+
const res = await globalizedBaseQuery(
1011+
{ url: '/success' },
1012+
commonBaseQueryApi,
1013+
{},
1014+
)
1015+
1016+
expect(res.error).toBeUndefined()
1017+
expect(res.data).toEqual(`this is plain text!`)
1018+
expect(res.meta?.response?.headers.get('content-type')).toEqual(
1019+
'text/plain',
1020+
)
1021+
})
1022+
1023+
test('Global responseHandler: content-type with JSON response', async () => {
1024+
server.use(
1025+
http.get(
1026+
'https://example.com/success',
1027+
() => HttpResponse.json({ message: 'this is json!' }),
1028+
{ once: true },
1029+
),
1030+
)
1031+
1032+
const globalizedBaseQuery = fetchBaseQuery({
1033+
baseUrl,
1034+
fetchFn: fetchFn as any,
1035+
responseHandler: 'content-type',
1036+
})
1037+
1038+
const res = await globalizedBaseQuery(
1039+
{ url: '/success' },
1040+
commonBaseQueryApi,
1041+
{},
1042+
)
1043+
1044+
expect(res.error).toBeUndefined()
1045+
expect(res.data).toEqual({ message: 'this is json!' })
1046+
expect(res.meta?.response?.headers.get('content-type')).toEqual(
1047+
'application/json',
1048+
)
1049+
})
1050+
1051+
test('Global responseHandler: content-type can be overridden at endpoint level', async () => {
1052+
server.use(
1053+
http.get(
1054+
'https://example.com/success',
1055+
() => HttpResponse.text(`this is text but will be parsed as json`),
1056+
{ once: true },
1057+
),
1058+
)
1059+
1060+
const globalizedBaseQuery = fetchBaseQuery({
1061+
baseUrl,
1062+
fetchFn: fetchFn as any,
1063+
responseHandler: 'content-type',
1064+
})
1065+
1066+
// Override global content-type handler with explicit text handler
1067+
const res = await globalizedBaseQuery(
1068+
{ url: '/success', responseHandler: 'text' },
1069+
commonBaseQueryApi,
1070+
{},
1071+
)
1072+
1073+
expect(res.error).toBeUndefined()
1074+
expect(res.data).toEqual(`this is text but will be parsed as json`)
1075+
})
1076+
1077+
test('Global responseHandler: content-type with error response (text)', async () => {
1078+
const errorMessage = 'Internal Server Error'
1079+
server.use(
1080+
http.get('https://example.com/error', () =>
1081+
HttpResponse.text(errorMessage, { status: 500 }),
1082+
),
1083+
)
1084+
1085+
const globalizedBaseQuery = fetchBaseQuery({
1086+
baseUrl,
1087+
fetchFn: fetchFn as any,
1088+
responseHandler: 'content-type',
1089+
})
1090+
1091+
const res = await globalizedBaseQuery(
1092+
{ url: '/error' },
1093+
commonBaseQueryApi,
1094+
{},
1095+
)
1096+
1097+
expect(res.error).toEqual({
1098+
status: 500,
1099+
data: errorMessage,
1100+
})
1101+
expect(res.meta?.response?.headers.get('content-type')).toEqual(
1102+
'text/plain',
1103+
)
1104+
})
1105+
1106+
test('Global responseHandler: content-type with error response (JSON)', async () => {
1107+
const errorData = { error: 'Something went wrong', code: 'ERR_500' }
1108+
server.use(
1109+
http.get('https://example.com/error', () =>
1110+
HttpResponse.json(errorData, { status: 500 }),
1111+
),
1112+
)
1113+
1114+
const globalizedBaseQuery = fetchBaseQuery({
1115+
baseUrl,
1116+
fetchFn: fetchFn as any,
1117+
responseHandler: 'content-type',
1118+
})
1119+
1120+
const res = await globalizedBaseQuery(
1121+
{ url: '/error' },
1122+
commonBaseQueryApi,
1123+
{},
1124+
)
1125+
1126+
expect(res.error).toEqual({
1127+
status: 500,
1128+
data: errorData,
1129+
})
1130+
expect(res.meta?.response?.headers.get('content-type')).toEqual(
1131+
'application/json',
1132+
)
1133+
})
1134+
9951135
test('Global validateStatus', async () => {
9961136
const globalizedBaseQuery = fetchBaseQuery({
9971137
baseUrl,

0 commit comments

Comments
 (0)