Repository: urql-graphql/urql
Pull request #3343 (2023-07-28 13:06:09)

fix(auth): Catch unexpected errors during initialization
See: https://github.com/vercel/next.js/issues/49373

## Summary

It's unexpected and undefined behaviour to throw/reject in the initialization function passed to the `authExchange()` and any rejections will be uncaught.
This can make thes situations hard to debug, as the promise will be uncaught.

Instead, errors during initialization will now be caught, queued up `Operation`s will be fulfilled with an `OperationResult` with their error populated to the caught error, and a warning will be output in development that tells users what's going on.

This essentially allows uncaught errors in the initialization function to be discovered and handled properly. At worst, it'll fail all operations coming in and will retry the initialization function if the operations are retried.

## Set of changes

- Catch initialization function and pass error to `errorQueue`
- Retry initialization function if `config` is not initialized
- Add warning for failing initialization function elaborating that they're unexpected
- Add test


Patch:
diff --git a/.changeset/polite-kangaroos-rhyme.md b/.changeset/polite-kangaroos-rhyme.md
new file mode 100644
index 00000000..336cf70e
--- /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.ts b/exchanges/auth/src/authExchange.ts
index ba94f485..35d7525e 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<Data = any, Variables extends AnyVariables = AnyVariables>(
-              query: DocumentInput<Data, Variables>,
-              variables: Variables,
-              context?: Partial<OperationContext>
-            ): Promise<OperationResult<Data>> {
-              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<string, string>
-            ) {
-              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<Data = any, Variables extends AnyVariables = AnyVariables>(
+                query: DocumentInput<Data, Variables>,
+                variables: Variables,
+                context?: Partial<OperationContext>
+              ): Promise<OperationResult<Data>> {
+                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<string, string>
+              ) {
+                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);


Test patch:
diff --git a/exchanges/auth/src/authExchange.test.ts b/exchanges/auth/src/authExchange.test.ts
index e4c8a818..15b14a33 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<any>();
+  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!]'
+  );
+});