Skip to content

Commit

Permalink
fix(auth): Catch unexpected errors during initialization (#3343)
Browse files Browse the repository at this point in the history
  • Loading branch information
kitten committed Jul 28, 2023
1 parent 743e5b4 commit 51f67ad
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 58 deletions.
5 changes: 5 additions & 0 deletions .changeset/polite-kangaroos-rhyme.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions exchanges/auth/src/authExchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!]'
);
});
134 changes: 76 additions & 58 deletions exchanges/auth/src/authExchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit 51f67ad

Please sign in to comment.