From 176f28ec0f427e897a2ca49cffb8320eaefe28db Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 16:21:03 -0600 Subject: [PATCH 01/10] Pass an ErrorLike to delay/attempts in RetryLink --- src/link/retry/retryLink.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/link/retry/retryLink.ts b/src/link/retry/retryLink.ts index e7fd2abd317..dd5db037dd2 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 { @@ -144,17 +146,20 @@ class RetryableOperation { }); } - private onError = async (error: any) => { + private onError = async (error: unknown) => { 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; } From f922778cda558139c96402abf2f71ba2f3d1de2f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 16:22:37 -0600 Subject: [PATCH 02/10] Remove type cast --- src/link/retry/retryLink.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/link/retry/retryLink.ts b/src/link/retry/retryLink.ts index dd5db037dd2..1c37aed2180 100644 --- a/src/link/retry/retryLink.ts +++ b/src/link/retry/retryLink.ts @@ -104,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, @@ -174,7 +174,7 @@ class RetryableOperation { this.timerId = setTimeout(() => { this.timerId = undefined; this.try(); - }, delay) as any as number; + }, delay); } } From 64ac3a79532000b5301626450dccefc750582b56 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 16:24:19 -0600 Subject: [PATCH 03/10] Add changeset --- .changeset/mighty-buckets-hide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mighty-buckets-hide.md 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`. From 116a98ac6645ab1aeb50532f25e849878f085ddb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 16:25:26 -0600 Subject: [PATCH 04/10] Update api report --- .api-reports/api-report-link_retry.api.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.api-reports/api-report-link_retry.api.md b/.api-reports/api-report-link_retry.api.md index 61ef8568bb7..baaf0fe4250 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; } // (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; From 54f22523b2a77133182c247d07532c8b22802880 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 16:56:08 -0600 Subject: [PATCH 05/10] Ensure retryIf is an ErrorLike --- src/link/retry/retryLink.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/link/retry/retryLink.ts b/src/link/retry/retryLink.ts index 1c37aed2180..f095bd943c8 100644 --- a/src/link/retry/retryLink.ts +++ b/src/link/retry/retryLink.ts @@ -80,7 +80,7 @@ export declare namespace RetryLink { * @defaultValue `() => true` */ retryIf?: ( - error: any, + error: ErrorLike, operation: ApolloLink.Operation ) => boolean | Promise; } From 5a6f6ed68429cec4f6e16b8f5fe0f780ccf16595 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 16:56:17 -0600 Subject: [PATCH 06/10] Update retryFunction tests to match type --- src/link/retry/__tests__/retryFunction.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) 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); }); From 82935714db3eb66ee6c37448dc342c153e264c7f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 16:56:33 -0600 Subject: [PATCH 07/10] Remove useless comment --- src/link/retry/retryLink.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/link/retry/retryLink.ts b/src/link/retry/retryLink.ts index f095bd943c8..7e780466bda 100644 --- a/src/link/retry/retryLink.ts +++ b/src/link/retry/retryLink.ts @@ -150,7 +150,6 @@ class RetryableOperation { this.retryCount += 1; const errorLike = toErrorLike(error); - // Should we retry? const shouldRetry = await this.retryIf( this.retryCount, this.operation, From 6251abfc028c665af759338a7320c9b8b74d4e1c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 17:05:49 -0600 Subject: [PATCH 08/10] Ensure protocol errors are emitted as a next event --- src/link/retry/__tests__/retryLink.ts | 42 +++++++++++++++++++++++++++ src/link/retry/retryLink.ts | 12 +++++--- 2 files changed, 50 insertions(+), 4 deletions(-) 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 7e780466bda..7537720b8e2 100644 --- a/src/link/retry/retryLink.ts +++ b/src/link/retry/retryLink.ts @@ -132,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(); @@ -141,12 +145,12 @@ 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: unknown) => { + private onError = async (error: unknown, onContinue: () => void) => { this.retryCount += 1; const errorLike = toErrorLike(error); @@ -162,7 +166,7 @@ class RetryableOperation { return; } - this.observer.error(error); + onContinue(); }; private scheduleRetry(delay: number) { From ff841cab745b7dcbb880725f0353725bf3b7b26e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 17:07:29 -0600 Subject: [PATCH 09/10] Add changeset --- .changeset/lemon-carrots-breathe.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lemon-carrots-breathe.md 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. From 3bb9f56e66001be8a49c8fcf2ce33ac142cbc77b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 4 Aug 2025 17:09:15 -0600 Subject: [PATCH 10/10] Rerun api report --- .api-reports/api-report-link_retry.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.api-reports/api-report-link_retry.api.md b/.api-reports/api-report-link_retry.api.md index baaf0fe4250..f41f5a1cce2 100644 --- a/.api-reports/api-report-link_retry.api.md +++ b/.api-reports/api-report-link_retry.api.md @@ -15,7 +15,7 @@ export namespace RetryLink { // (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: ErrorLike) => number;