Skip to content

Commit 0506f12

Browse files
authored
Ensure error argument to delay/attempts functions is an ErrorLike (#12824)
1 parent 19e315e commit 0506f12

6 files changed

Lines changed: 84 additions & 28 deletions

File tree

.api-reports/api-report-link_retry.api.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@
55
```ts
66

77
import { ApolloLink } from '@apollo/client/link';
8+
import type { ErrorLike } from '@apollo/client';
89
import { Observable } from 'rxjs';
910

1011
// @public (undocumented)
1112
export namespace RetryLink {
1213
// (undocumented)
13-
export type AttemptsFunction = (count: number, operation: ApolloLink.Operation, error: any) => boolean | Promise<boolean>;
14+
export type AttemptsFunction = (count: number, operation: ApolloLink.Operation, error: ErrorLike) => boolean | Promise<boolean>;
1415
// (undocumented)
1516
export interface AttemptsOptions {
1617
max?: number;
17-
retryIf?: (error: any, operation: ApolloLink.Operation) => boolean | Promise<boolean>;
18+
retryIf?: (error: ErrorLike, operation: ApolloLink.Operation) => boolean | Promise<boolean>;
1819
}
1920
// (undocumented)
20-
export type DelayFunction = (count: number, operation: ApolloLink.Operation, error: any) => number;
21+
export type DelayFunction = (count: number, operation: ApolloLink.Operation, error: ErrorLike) => number;
2122
// (undocumented)
2223
export interface DelayOptions {
2324
initial?: number;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
`RetryLink` now emits a `next` event instead of an `error` event when encountering a protocol errors for multipart subscriptions when the operation is not retried. This ensures the observable notification remains the same as when `RetryLink` is not used.

.changeset/mighty-buckets-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": major
3+
---
4+
5+
Ensure the `error` argument for the `delay` and `attempts` functions on `RetryLink` are an `ErrorLike`.

src/link/retry/__tests__/retryFunction.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,28 @@ describe("buildRetryFunction", () => {
88
const operation = { operationName: "foo" } as ApolloLink.Operation;
99

1010
it("stops after hitting maxTries", () => {
11+
const error = new Error();
1112
const retryFunction = buildRetryFunction({ max: 3 });
1213

13-
expect(retryFunction(2, operation, {})).toEqual(true);
14-
expect(retryFunction(3, operation, {})).toEqual(false);
15-
expect(retryFunction(4, operation, {})).toEqual(false);
16-
});
17-
18-
it("skips retries if there was no error, by default", () => {
19-
const retryFunction = buildRetryFunction();
20-
21-
expect(retryFunction(1, operation, undefined)).toEqual(false);
22-
expect(retryFunction(1, operation, {})).toEqual(true);
14+
expect(retryFunction(2, operation, error)).toEqual(true);
15+
expect(retryFunction(3, operation, error)).toEqual(false);
16+
expect(retryFunction(4, operation, error)).toEqual(false);
2317
});
2418

2519
it("supports custom predicates, but only if max is not exceeded", () => {
20+
const error = new Error();
2621
const stub = jest.fn(() => true);
2722
const retryFunction = buildRetryFunction({ max: 3, retryIf: stub });
2823

29-
expect(retryFunction(2, operation, null)).toEqual(true);
30-
expect(retryFunction(3, operation, null)).toEqual(false);
24+
expect(retryFunction(2, operation, error)).toEqual(true);
25+
expect(retryFunction(3, operation, error)).toEqual(false);
3126
});
3227

3328
it("passes the error and operation through to custom predicates", () => {
3429
const stub = jest.fn(() => true);
3530
const retryFunction = buildRetryFunction({ max: 3, retryIf: stub });
3631

37-
const error = { message: "bewm" };
32+
const error = new Error("bewm");
3833
void retryFunction(1, operation, error);
3934
expect(stub).toHaveBeenCalledWith(error, operation);
4035
});

src/link/retry/__tests__/retryLink.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { gql } from "graphql-tag";
22
import { Observable, of, throwError } from "rxjs";
33

44
import { CombinedProtocolErrors } from "@apollo/client";
5+
import { PROTOCOL_ERRORS_SYMBOL } from "@apollo/client/errors";
56
import { ApolloLink } from "@apollo/client/link";
67
import { RetryLink } from "@apollo/client/link/retry";
78
import {
@@ -271,4 +272,45 @@ describe("RetryLink", () => {
271272
])
272273
);
273274
});
275+
276+
it("calls observer.next when not retrying a protocol error", async () => {
277+
const subscription = gql`
278+
subscription MySubscription {
279+
aNewDieWasCreated {
280+
die {
281+
roll
282+
sides
283+
color
284+
}
285+
}
286+
}
287+
`;
288+
289+
const retryLink = new RetryLink({
290+
delay: { initial: 1 },
291+
attempts: {
292+
retryIf: () => false,
293+
},
294+
});
295+
296+
const { httpLink, enqueueProtocolErrors } =
297+
mockMultipartSubscriptionStream();
298+
const link = ApolloLink.from([retryLink, httpLink]);
299+
const stream = new ObservableStream(execute(link, { query: subscription }));
300+
301+
enqueueProtocolErrors([
302+
{ message: "Error field", extensions: { code: "INTERNAL_SERVER_ERROR" } },
303+
]);
304+
305+
await expect(stream).toEmitTypedValue({
306+
extensions: {
307+
[PROTOCOL_ERRORS_SYMBOL]: new CombinedProtocolErrors([
308+
{
309+
message: "Error field",
310+
extensions: { code: "INTERNAL_SERVER_ERROR" },
311+
},
312+
]),
313+
} as any,
314+
});
315+
});
274316
});

src/link/retry/retryLink.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import type { Subscription } from "rxjs";
22
import type { Observer } from "rxjs";
33
import { Observable } from "rxjs";
44

5+
import type { ErrorLike } from "@apollo/client";
56
import {
67
graphQLResultHasProtocolErrors,
78
PROTOCOL_ERRORS_SYMBOL,
9+
toErrorLike,
810
} from "@apollo/client/errors";
911
import { ApolloLink } from "@apollo/client/link";
1012

@@ -15,7 +17,7 @@ export declare namespace RetryLink {
1517
export type DelayFunction = (
1618
count: number,
1719
operation: ApolloLink.Operation,
18-
error: any
20+
error: ErrorLike
1921
) => number;
2022

2123
export interface DelayOptions {
@@ -54,7 +56,7 @@ export declare namespace RetryLink {
5456
export type AttemptsFunction = (
5557
count: number,
5658
operation: ApolloLink.Operation,
57-
error: any
59+
error: ErrorLike
5860
) => boolean | Promise<boolean>;
5961

6062
export interface AttemptsOptions {
@@ -78,7 +80,7 @@ export declare namespace RetryLink {
7880
* @defaultValue `() => true`
7981
*/
8082
retryIf?: (
81-
error: any,
83+
error: ErrorLike,
8284
operation: ApolloLink.Operation
8385
) => boolean | Promise<boolean>;
8486
}
@@ -102,7 +104,7 @@ export declare namespace RetryLink {
102104
class RetryableOperation {
103105
private retryCount: number = 0;
104106
private currentSubscription: Subscription | null = null;
105-
private timerId: number | undefined;
107+
private timerId: ReturnType<typeof setTimeout> | undefined;
106108

107109
constructor(
108110
private observer: Observer<ApolloLink.Result>,
@@ -130,7 +132,11 @@ class RetryableOperation {
130132
this.currentSubscription = this.forward(this.operation).subscribe({
131133
next: (result) => {
132134
if (graphQLResultHasProtocolErrors(result)) {
133-
this.onError(result.extensions[PROTOCOL_ERRORS_SYMBOL]);
135+
this.onError(result.extensions[PROTOCOL_ERRORS_SYMBOL], () =>
136+
// Pretend like we never encountered this error and move the result
137+
// along for Apollo Client core to handle this error.
138+
this.observer.next(result)
139+
);
134140
// Unsubscribe from the current subscription to prevent the `complete`
135141
// handler to be called as a result of the stream closing.
136142
this.currentSubscription?.unsubscribe();
@@ -139,26 +145,28 @@ class RetryableOperation {
139145

140146
this.observer.next(result);
141147
},
142-
error: this.onError,
148+
error: (error) => this.onError(error, () => this.observer.error(error)),
143149
complete: this.observer.complete.bind(this.observer),
144150
});
145151
}
146152

147-
private onError = async (error: any) => {
153+
private onError = async (error: unknown, onContinue: () => void) => {
148154
this.retryCount += 1;
155+
const errorLike = toErrorLike(error);
149156

150-
// Should we retry?
151157
const shouldRetry = await this.retryIf(
152158
this.retryCount,
153159
this.operation,
154-
error
160+
errorLike
155161
);
156162
if (shouldRetry) {
157-
this.scheduleRetry(this.delayFor(this.retryCount, this.operation, error));
163+
this.scheduleRetry(
164+
this.delayFor(this.retryCount, this.operation, errorLike)
165+
);
158166
return;
159167
}
160168

161-
this.observer.error(error);
169+
onContinue();
162170
};
163171

164172
private scheduleRetry(delay: number) {
@@ -169,7 +177,7 @@ class RetryableOperation {
169177
this.timerId = setTimeout(() => {
170178
this.timerId = undefined;
171179
this.try();
172-
}, delay) as any as number;
180+
}, delay);
173181
}
174182
}
175183

0 commit comments

Comments
 (0)