diff --git a/.api-reports/api-report-link_retry.api.md b/.api-reports/api-report-link_retry.api.md index 61ef8568bb7..f41f5a1cce2 100644 --- a/.api-reports/api-report-link_retry.api.md +++ b/.api-reports/api-report-link_retry.api.md @@ -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; + export type AttemptsFunction = (count: number, operation: ApolloLink.Operation, error: ErrorLike) => boolean | Promise; // (undocumented) export interface AttemptsOptions { max?: number; - retryIf?: (error: any, operation: ApolloLink.Operation) => boolean | Promise; + retryIf?: (error: ErrorLike, operation: ApolloLink.Operation) => boolean | Promise; } // (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; diff --git a/.changeset/lemon-carrots-breathe.md b/.changeset/lemon-carrots-breathe.md new file mode 100644 index 00000000000..66f62caf4e0 --- /dev/null +++ b/.changeset/lemon-carrots-breathe.md @@ -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. diff --git a/.changeset/mighty-buckets-hide.md b/.changeset/mighty-buckets-hide.md new file mode 100644 index 00000000000..11b5d00be08 --- /dev/null +++ b/.changeset/mighty-buckets-hide.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": major +--- + +Ensure the `error` argument for the `delay` and `attempts` functions on `RetryLink` are an `ErrorLike`. diff --git a/src/link/retry/__tests__/retryFunction.ts b/src/link/retry/__tests__/retryFunction.ts index 89323562c5a..b480eebd65d 100644 --- a/src/link/retry/__tests__/retryFunction.ts +++ b/src/link/retry/__tests__/retryFunction.ts @@ -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); }); diff --git a/src/link/retry/__tests__/retryLink.ts b/src/link/retry/__tests__/retryLink.ts index da82a71fa30..24cf842f573 100644 --- a/src/link/retry/__tests__/retryLink.ts +++ b/src/link/retry/__tests__/retryLink.ts @@ -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 { @@ -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, + }); + }); }); diff --git a/src/link/retry/retryLink.ts b/src/link/retry/retryLink.ts index e7fd2abd317..7537720b8e2 100644 --- a/src/link/retry/retryLink.ts +++ b/src/link/retry/retryLink.ts @@ -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"; @@ -15,7 +17,7 @@ export declare namespace RetryLink { export type DelayFunction = ( count: number, operation: ApolloLink.Operation, - error: any + error: ErrorLike ) => number; export interface DelayOptions { @@ -54,7 +56,7 @@ export declare namespace RetryLink { export type AttemptsFunction = ( count: number, operation: ApolloLink.Operation, - error: any + error: ErrorLike ) => boolean | Promise; export interface AttemptsOptions { @@ -78,7 +80,7 @@ export declare namespace RetryLink { * @defaultValue `() => true` */ retryIf?: ( - error: any, + error: ErrorLike, operation: ApolloLink.Operation ) => boolean | Promise; } @@ -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 | undefined; constructor( private observer: Observer, @@ -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(); @@ -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); + onContinue(); }; private scheduleRetry(delay: number) { @@ -169,7 +177,7 @@ class RetryableOperation { this.timerId = setTimeout(() => { this.timerId = undefined; this.try(); - }, delay) as any as number; + }, delay); } }