From 51f67adebd03dbac409e35305a44490e29bab695 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Fri, 28 Jul 2023 14:15:01 +0100 Subject: [PATCH] fix(auth): Catch unexpected errors during initialization (#3343) --- .changeset/polite-kangaroos-rhyme.md | 5 + exchanges/auth/src/authExchange.test.ts | 30 ++++++ exchanges/auth/src/authExchange.ts | 134 ++++++++++++++---------- 3 files changed, 111 insertions(+), 58 deletions(-) create mode 100644 .changeset/polite-kangaroos-rhyme.md diff --git a/.changeset/polite-kangaroos-rhyme.md b/.changeset/polite-kangaroos-rhyme.md new file mode 100644 index 0000000000..336cf70e7c --- /dev/null +++ b/.changeset/polite-kangaroos-rhyme.md @@ -0,0 +1,5 @@ +--- +'@urql/exchange-auth': patch +--- + +`authExchange()` will now block and pass on errors if the initialization function passed to it fails, and will retry indefinitely. It’ll also output a warning for these cases, as the initialization function (i.e. `authExchange(async (utils) => { /*...*/ })`) is not expected to reject/throw. diff --git a/exchanges/auth/src/authExchange.test.ts b/exchanges/auth/src/authExchange.test.ts index e4c8a818ac..15b14a335b 100644 --- a/exchanges/auth/src/authExchange.test.ts +++ b/exchanges/auth/src/authExchange.test.ts @@ -476,3 +476,33 @@ it('passes on failing refreshAuth() errors to results', async () => { expect(res.error).toMatchInlineSnapshot('[CombinedError: [Network] test]'); }); + +it('passes on errors during initialization', async () => { + const { source, next } = makeSubject(); + const { exchangeArgs, result } = makeExchangeArgs(); + const init = vi.fn().mockRejectedValue(new Error('oops!')); + const output = vi.fn(); + + pipe(source, authExchange(init)(exchangeArgs), tap(output), publish); + + expect(result).toHaveBeenCalledTimes(0); + expect(output).toHaveBeenCalledTimes(0); + + next(queryOperation); + await new Promise(resolve => setTimeout(resolve)); + expect(result).toHaveBeenCalledTimes(0); + expect(output).toHaveBeenCalledTimes(1); + expect(init).toHaveBeenCalledTimes(1); + expect(output.mock.calls[0][0].error).toMatchInlineSnapshot( + '[CombinedError: [Network] oops!]' + ); + + next(queryOperation); + await new Promise(resolve => setTimeout(resolve)); + expect(result).toHaveBeenCalledTimes(0); + expect(output).toHaveBeenCalledTimes(2); + expect(init).toHaveBeenCalledTimes(2); + expect(output.mock.calls[1][0].error).toMatchInlineSnapshot( + '[CombinedError: [Network] oops!]' + ); +}); diff --git a/exchanges/auth/src/authExchange.ts b/exchanges/auth/src/authExchange.ts index ba94f485c8..35d7525e7d 100644 --- a/exchanges/auth/src/authExchange.ts +++ b/exchanges/auth/src/authExchange.ts @@ -227,63 +227,79 @@ export function authExchange( let config: AuthConfig | null = null; return operations$ => { - authPromise = Promise.resolve() - .then(() => - init({ - mutate( - query: DocumentInput, - variables: Variables, - context?: Partial - ): Promise> { - const baseOperation = client.createRequestOperation( - 'mutation', - createRequest(query, variables), - context - ); - return pipe( - result$, - onStart(() => { - const operation = addAuthToOperation(baseOperation); - bypassQueue.add( - operation.context._instance as OperationInstance - ); - retries.next(operation); - }), - filter( - result => - result.operation.key === baseOperation.key && - baseOperation.context._instance === - result.operation.context._instance - ), - take(1), - toPromise - ); - }, - appendHeaders( - operation: Operation, - headers: Record - ) { - const fetchOptions = - typeof operation.context.fetchOptions === 'function' - ? operation.context.fetchOptions() - : operation.context.fetchOptions || {}; - return makeOperation(operation.kind, operation, { - ...operation.context, - fetchOptions: { - ...fetchOptions, - headers: { - ...fetchOptions.headers, - ...headers, + function initAuth() { + authPromise = Promise.resolve() + .then(() => + init({ + mutate( + query: DocumentInput, + variables: Variables, + context?: Partial + ): Promise> { + const baseOperation = client.createRequestOperation( + 'mutation', + createRequest(query, variables), + context + ); + return pipe( + result$, + onStart(() => { + const operation = addAuthToOperation(baseOperation); + bypassQueue.add( + operation.context._instance as OperationInstance + ); + retries.next(operation); + }), + filter( + result => + result.operation.key === baseOperation.key && + baseOperation.context._instance === + result.operation.context._instance + ), + take(1), + toPromise + ); + }, + appendHeaders( + operation: Operation, + headers: Record + ) { + const fetchOptions = + typeof operation.context.fetchOptions === 'function' + ? operation.context.fetchOptions() + : operation.context.fetchOptions || {}; + return makeOperation(operation.kind, operation, { + ...operation.context, + fetchOptions: { + ...fetchOptions, + headers: { + ...fetchOptions.headers, + ...headers, + }, }, - }, - }); - }, + }); + }, + }) + ) + .then((_config: AuthConfig) => { + if (_config) config = _config; + flushQueue(); }) - ) - .then((_config: AuthConfig) => { - if (_config) config = _config; - flushQueue(); - }); + .catch((error: Error) => { + if (process.env.NODE_ENV !== 'production') { + console.warn( + 'authExchange()’s initialization function has failed, which is unexpected.\n' + + 'If your initialization function is expected to throw/reject, catch this error and handle it explicitly.\n' + + 'Unless this error is handled it’ll be passed onto any `OperationResult` instantly and authExchange() will block further operations and retry.', + error + ); + } + + errorQueue(error); + }); + } + + initAuth(); function refreshAuth(operation: Operation) { // add to retry queue to try again later @@ -332,13 +348,15 @@ export function authExchange( return operation; } else if (operation.context.authAttempt) { return addAuthToOperation(operation); - } else if (authPromise) { - if (!retryQueue.has(operation.key)) { + } else if (authPromise || !config) { + if (!authPromise) initAuth(); + + if (!retryQueue.has(operation.key)) retryQueue.set( operation.key, addAuthAttemptToOperation(operation, false) ); - } + return null; } else if (willAuthError(operation)) { refreshAuth(operation);