Skip to content
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
7 changes: 4 additions & 3 deletions .api-reports/api-report-link_retry.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@
```ts

import { ApolloLink } from '@apollo/client/link';
import type { ErrorLike } from '@apollo/client';
import { Observable } from 'rxjs';

// @public (undocumented)
export namespace RetryLink {
// (undocumented)
export type AttemptsFunction = (count: number, operation: ApolloLink.Operation, error: any) => boolean | Promise<boolean>;
export type AttemptsFunction = (count: number, operation: ApolloLink.Operation, error: ErrorLike) => boolean | Promise<boolean>;
// (undocumented)
export interface AttemptsOptions {
max?: number;
retryIf?: (error: any, operation: ApolloLink.Operation) => boolean | Promise<boolean>;
retryIf?: (error: ErrorLike, operation: ApolloLink.Operation) => boolean | Promise<boolean>;
}
// (undocumented)
export type DelayFunction = (count: number, operation: ApolloLink.Operation, error: any) => number;
export type DelayFunction = (count: number, operation: ApolloLink.Operation, error: ErrorLike) => number;
// (undocumented)
export interface DelayOptions {
initial?: number;
Expand Down
5 changes: 5 additions & 0 deletions .changeset/lemon-carrots-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

`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.
5 changes: 5 additions & 0 deletions .changeset/mighty-buckets-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": major
---

Ensure the `error` argument for the `delay` and `attempts` functions on `RetryLink` are an `ErrorLike`.
21 changes: 8 additions & 13 deletions src/link/retry/__tests__/retryFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,28 @@ describe("buildRetryFunction", () => {
const operation = { operationName: "foo" } as ApolloLink.Operation;

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

expect(retryFunction(2, operation, {})).toEqual(true);
expect(retryFunction(3, operation, {})).toEqual(false);
expect(retryFunction(4, operation, {})).toEqual(false);
});

it("skips retries if there was no error, by default", () => {
const retryFunction = buildRetryFunction();

expect(retryFunction(1, operation, undefined)).toEqual(false);
expect(retryFunction(1, operation, {})).toEqual(true);
expect(retryFunction(2, operation, error)).toEqual(true);
expect(retryFunction(3, operation, error)).toEqual(false);
expect(retryFunction(4, operation, error)).toEqual(false);
});

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

expect(retryFunction(2, operation, null)).toEqual(true);
expect(retryFunction(3, operation, null)).toEqual(false);
expect(retryFunction(2, operation, error)).toEqual(true);
expect(retryFunction(3, operation, error)).toEqual(false);
});

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

const error = { message: "bewm" };
const error = new Error("bewm");
void retryFunction(1, operation, error);
expect(stub).toHaveBeenCalledWith(error, operation);
});
Expand Down
42 changes: 42 additions & 0 deletions src/link/retry/__tests__/retryLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { gql } from "graphql-tag";
import { Observable, of, throwError } from "rxjs";

import { CombinedProtocolErrors } from "@apollo/client";
import { PROTOCOL_ERRORS_SYMBOL } from "@apollo/client/errors";
import { ApolloLink } from "@apollo/client/link";
import { RetryLink } from "@apollo/client/link/retry";
import {
Expand Down Expand Up @@ -271,4 +272,45 @@ describe("RetryLink", () => {
])
);
});

it("calls observer.next when not retrying a protocol error", async () => {
const subscription = gql`
subscription MySubscription {
aNewDieWasCreated {
die {
roll
sides
color
}
}
}
`;

const retryLink = new RetryLink({
delay: { initial: 1 },
attempts: {
retryIf: () => false,
},
});

const { httpLink, enqueueProtocolErrors } =
mockMultipartSubscriptionStream();
const link = ApolloLink.from([retryLink, httpLink]);
const stream = new ObservableStream(execute(link, { query: subscription }));

enqueueProtocolErrors([
{ message: "Error field", extensions: { code: "INTERNAL_SERVER_ERROR" } },
]);

await expect(stream).toEmitTypedValue({
extensions: {
[PROTOCOL_ERRORS_SYMBOL]: new CombinedProtocolErrors([
{
message: "Error field",
extensions: { code: "INTERNAL_SERVER_ERROR" },
},
]),
} as any,
});
});
});
32 changes: 20 additions & 12 deletions src/link/retry/retryLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import type { Subscription } from "rxjs";
import type { Observer } from "rxjs";
import { Observable } from "rxjs";

import type { ErrorLike } from "@apollo/client";
import {
graphQLResultHasProtocolErrors,
PROTOCOL_ERRORS_SYMBOL,
toErrorLike,
} from "@apollo/client/errors";
import { ApolloLink } from "@apollo/client/link";

Expand All @@ -15,7 +17,7 @@ export declare namespace RetryLink {
export type DelayFunction = (
count: number,
operation: ApolloLink.Operation,
error: any
error: ErrorLike
) => number;

export interface DelayOptions {
Expand Down Expand Up @@ -54,7 +56,7 @@ export declare namespace RetryLink {
export type AttemptsFunction = (
count: number,
operation: ApolloLink.Operation,
error: any
error: ErrorLike
) => boolean | Promise<boolean>;

export interface AttemptsOptions {
Expand All @@ -78,7 +80,7 @@ export declare namespace RetryLink {
* @defaultValue `() => true`
*/
retryIf?: (
error: any,
error: ErrorLike,
operation: ApolloLink.Operation
) => boolean | Promise<boolean>;
}
Expand All @@ -102,7 +104,7 @@ export declare namespace RetryLink {
class RetryableOperation {
private retryCount: number = 0;
private currentSubscription: Subscription | null = null;
private timerId: number | undefined;
private timerId: ReturnType<typeof setTimeout> | undefined;

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

this.observer.next(result);
},
error: this.onError,
error: (error) => this.onError(error, () => this.observer.error(error)),
complete: this.observer.complete.bind(this.observer),
});
}

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

// Should we retry?
const shouldRetry = await this.retryIf(
this.retryCount,
this.operation,
error
errorLike
);
if (shouldRetry) {
this.scheduleRetry(this.delayFor(this.retryCount, this.operation, error));
this.scheduleRetry(
this.delayFor(this.retryCount, this.operation, errorLike)
);
return;
}

this.observer.error(error);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gooood catch!

onContinue();
};

private scheduleRetry(delay: number) {
Expand All @@ -169,7 +177,7 @@ class RetryableOperation {
this.timerId = setTimeout(() => {
this.timerId = undefined;
this.try();
}, delay) as any as number;
}, delay);
}
}

Expand Down