···11+---
22+'@urql/exchange-retry': minor
33+---
44+55+Reset `retryExchange`’s previous attempts and delay if an operation succeeds. This prevents the exchange from keeping its old retry count and delay if the operation delivered a result in the meantime. This is important for it to help recover from failing subscriptions.
+75-3
exchanges/retry/src/retryExchange.test.ts
···11-import { pipe, map, makeSubject, publish, tap } from 'wonka';
11+import {
22+ Source,
33+ pipe,
44+ map,
55+ makeSubject,
66+ mergeMap,
77+ fromValue,
88+ fromArray,
99+ publish,
1010+ tap,
1111+} from 'wonka';
212import { vi, expect, it, beforeEach, afterEach } from 'vitest';
313414import {
···6777 ({ source: ops$, next } = makeSubject<Operation>());
6878});
69797070-it(`retries if it hits an error and works for multiple concurrent operations`, () => {
8080+it('retries if it hits an error and works for multiple concurrent operations', () => {
7181 const queryTwo = gql`
7282 {
7383 films {
···149159 // @ts-ignore
150160 return {
151161 operation: forwardOp,
152152- ...(forwardOp.context.retryCount! >= numberRetriesBeforeSuccess
162162+ ...(forwardOp.context.retry?.count >= numberRetriesBeforeSuccess
153163 ? { data: queryOneData }
154164 : { error: queryOneError }),
155165 };
···184194 // one for original source, one for retry
185195 expect(response).toHaveBeenCalledTimes(1 + numberRetriesBeforeSuccess);
186196 expect(result).toHaveBeenCalledTimes(1);
197197+});
198198+199199+it('should reset the retry counter if an operation succeeded first', () => {
200200+ let call = 0;
201201+ const response = vi.fn((forwardOp: Operation): Source<any> => {
202202+ expect(forwardOp.key).toBe(op.key);
203203+ if (call === 0) {
204204+ call++;
205205+ return fromValue({
206206+ operation: forwardOp,
207207+ error: queryOneError,
208208+ } as any);
209209+ } else if (call === 1) {
210210+ call++;
211211+ return fromArray([
212212+ {
213213+ operation: forwardOp,
214214+ data: queryOneData,
215215+ } as any,
216216+ {
217217+ operation: forwardOp,
218218+ error: queryOneError,
219219+ } as any,
220220+ ]);
221221+ } else {
222222+ expect(forwardOp.context.retry).toEqual({ count: 1, delay: null });
223223+224224+ return fromValue({
225225+ operation: forwardOp,
226226+ data: queryOneData,
227227+ } as any);
228228+ }
229229+ });
230230+231231+ const result = vi.fn();
232232+ const forward: ExchangeIO = ops$ => {
233233+ return pipe(ops$, mergeMap(response));
234234+ };
235235+236236+ const mockRetryIf = vi.fn(() => true);
237237+238238+ pipe(
239239+ retryExchange({
240240+ ...mockOptions,
241241+ retryIf: mockRetryIf,
242242+ })({
243243+ forward,
244244+ client,
245245+ dispatchDebug,
246246+ })(ops$),
247247+ tap(result),
248248+ publish
249249+ );
250250+251251+ next(op);
252252+ vi.runAllTimers();
253253+254254+ expect(mockRetryIf).toHaveBeenCalledTimes(2);
255255+ expect(mockRetryIf).toHaveBeenCalledWith(queryOneError as any, op);
256256+257257+ expect(response).toHaveBeenCalledTimes(3);
258258+ expect(result).toHaveBeenCalledTimes(2);
187259});
188260189261it(`should still retry if retryIf undefined but there is a networkError`, () => {
+25-12
exchanges/retry/src/retryExchange.ts
···7878 ): Operation | null | undefined;
7979}
80808181+interface RetryState {
8282+ count: number;
8383+ delay: number | null;
8484+}
8585+8186/** Exchange factory that retries failed operations.
8287 *
8388 * @param options - A {@link RetriesExchangeOptions} configuration object.
···117122118123 const retryWithBackoff$ = pipe(
119124 retry$,
120120- mergeMap((op: Operation) => {
121121- const { key, context } = op;
122122- const retryCount = (context.retryCount || 0) + 1;
123123- let delayAmount = context.retryDelay || MIN_DELAY;
125125+ mergeMap((operation: Operation) => {
126126+ const retry: RetryState = operation.context.retry || {
127127+ count: 0,
128128+ delay: null,
129129+ };
130130+131131+ const retryCount = ++retry.count;
132132+ let delayAmount = retry.delay || MIN_DELAY;
124133125134 const backoffFactor = Math.random() + 1.5;
126135 // if randomDelay is enabled and it won't exceed the max delay, apply a random
···137146 filter(op => {
138147 return (
139148 (op.kind === 'query' || op.kind === 'teardown') &&
140140- op.key === key
149149+ op.key === operation.key
141150 );
142151 })
143152 );
···145154 dispatchDebug({
146155 type: 'retryAttempt',
147156 message: `The operation has failed and a retry has been triggered (${retryCount} / ${MAX_ATTEMPTS})`,
148148- operation: op,
157157+ operation,
149158 data: {
150159 retryCount,
151160 },
···154163 // Add new retryDelay and retryCount to operation
155164 return pipe(
156165 fromValue(
157157- makeOperation(op.kind, op, {
158158- ...op.context,
159159- retryDelay: delayAmount,
160160- retryCount,
166166+ makeOperation(operation.kind, operation, {
167167+ ...operation.context,
168168+ retry,
161169 })
162170 ),
163171 debounce(() => delayAmount),
···171179 merge([operations$, retryWithBackoff$]),
172180 forward,
173181 filter(res => {
182182+ const retry = res.operation.context.retry as RetryState | undefined;
174183 // Only retry if the error passes the conditional retryIf function (if passed)
175184 // or if the error contains a networkError
176185 if (
···179188 ? !retryIf(res.error, res.operation)
180189 : !retryWith && !res.error.networkError)
181190 ) {
191191+ // Reset the delay state for a successful operation
192192+ if (retry) {
193193+ retry.count = 0;
194194+ retry.delay = null;
195195+ }
182196 return true;
183197 }
184198185199 const maxNumberAttemptsExceeded =
186186- (res.operation.context.retryCount || 0) >= MAX_ATTEMPTS - 1;
187187-200200+ ((retry && retry.count) || 0) >= MAX_ATTEMPTS - 1;
188201 if (!maxNumberAttemptsExceeded) {
189202 const operation = retryWith
190203 ? retryWith(res.error, res.operation)