The Problem
I’ve been working on a React Native app recently and one of the things I struggled quite a bit with was automatically refreshing OAuth2 access tokens.
The example given in the the Apollo Error Link documentation is a good starting point but assumes that the getNewToken() operation is synchronous.
import { onError } from 'apollo-link-error';
// ...
const errorLink = onError(
({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
// error code is set to UNAUTHENTICATED
// when AuthenticationError thrown in resolver
// modify the operation context with a new token
const oldHeaders = operation.getContext().headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: getNewToken(),
},
});
// retry the request, returning the new observable
return forward(operation);
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
// if you would also like to retry automatically on
// network errors, we recommend that you use
// apollo-link-retry
}
}
);
Initial Solution
We’ll be using the fromPromise utility function from the apollo-link package to transform our Promise to an Observable.
A couple of things to note :
-
The ErrorHandler should either return an Observable or nothing.
-
The forward function returns an Observable.
-
Apollo is using the zen-observable implementation for Observables.
import { onError } from 'apollo-link-error';
import { fromPromise } from 'apollo-link';
// ...
const errorLink = onError(
({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
// error code is set to UNAUTHENTICATED
// when AuthenticationError thrown in resolver
return fromPromise(
getNewToken().catch(error => {
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return;
})
).filter(value => Boolean(value))
.flatMap(accessToken => {
const oldHeaders = operation.getContext().headers;
// modify the operation context with a new token
operation.setContext({
headers: {
...oldHeaders,
authorization: `Bearer ${accessToken}`,
},
});
// retry the request, returning the new observable
return forward(operation);
});
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
// if you would also like to retry automatically on
// network errors, we recommend that you use
// apollo-link-retry
}
}
);
In a real world scenario :
-
You’ll be using a refresh token to get and store a new pair of access and refresh tokens.
-
Your Auth Link will be responsible for setting the Authorization header for the outgoing requests and will be running after your Error Link.
import { onError } from 'apollo-link-error';
import { fromPromise, concat } from 'apollo-link';
// ...
const errorLink = onError(
({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
// error code is set to UNAUTHENTICATED
// when AuthenticationError thrown in resolver
return fromPromise(
getNewToken()
.then(({ accessToken, refreshToken }) => {
// Store the new tokens for your auth link
return accessToken;
})
.catch(error => {
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return;
})
).filter(value => Boolean(value))
.flatMap(() => {
// retry the request, returning the new observable
return forward(operation);
});
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
// if you would also like to retry automatically on
// network errors, we recommend that you use
// apollo-link-retry
}
}
);
const apolloLink = concat(errorLink, concat(authLink, httpLink));
Concurrent Requests
I initially stopped at the above implementation which worked correctly until two or more requests failed concurrently.
A couple of changes were made to tackle that scenario :
-
Only refresh the token once.
-
Keep track of the remaining failed requests and retry them once the token is refreshed.
import { onError } from 'apollo-link-error';
import { fromPromise } from 'apollo-link';
// ...
let isRefreshing = false;
let pendingRequests = [];
const resolvePendingRequests = () => {
pendingRequests.map(callback => callback());
pendingRequests = [];
};
const errorLink = onError(
({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
// error code is set to UNAUTHENTICATED
// when AuthenticationError thrown in resolver
let forward$;
if (!isRefreshing) {
isRefreshing = true;
forward$ = fromPromise(
getNewToken()
.then(({ accessToken, refreshToken }) => {
// Store the new tokens for your auth link
resolvePendingRequests();
return accessToken;
})
.catch(error => {
pendingRequests = [];
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return;
})
.finally(() => {
isRefreshing = false;
})
).filter(value => Boolean(value));
} else {
// Will only emit once the Promise is resolved
forward$ = fromPromise(
new Promise(resolve => {
pendingRequests.push(() => resolve());
})
);
}
return forward$.flatMap(() => forward(operation));
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
// if you would also like to retry automatically on
// network errors, we recommend that you use
// apollo-link-retry
}
}
);
const apolloLink = concat(errorLink, concat(authLink, httpLink));
Even if this solves my problem for now, there is still room for improvement and your feedback is greatly appreciated.
Thanks Soumyajit, your feedback really helps.
Will elaborate more on authentication in a future post.
Welcome to Able, Anas! Looks great for an initial solution.
Would love to see a more detailed post on authentication in GraphQL context. I have seen a lot of people struggle with authentication (both on the client / server) in the GraphQL ecosystem.
Kudos, man!
2023 This help me a lot!. Thanks @AnasT
This solution works, but i ended up not using the pendingRequests, because apollo seems to handle that part very well. Instead returned the same promise instance to all concurrent requests. I posted the approach here. https://stackoverflow.com/a/70324598/14849657
Hi, first of all Thanks for your solution. It is very helpful. The only problem that I have is that I don't understand how the part of the code where we store requests to array works. I have a small experience with rxjs and observables but still do not understand why do we push () => { resolve(); } to an array. Maybe you are able to help me with that? I would really appreciate that.
hi anas,
I read your article and test it. I manage to make it work for refresh the token but it's not continue to refetch my failed query.
return forward$.flatMap(() => { console.log(123); return forward(operation); });
this part not called. I test on react native. I use vue js and it works but why react not work.
Hey there, with the help of your article I managed to get it working for v3. There is just an issue. It only works for the first time. Imagine the Accesstoken expires after 5mins chances are high I need to issue/request multiple refreshed. But according to the link Docs the errorLink handler isnt called a second time from a inline Forward in order to avoid endless loops. How did you work around this?
Hey,
The way this should work is that anytime a request fails due to an invalid access token, the errorLink refresh logic will be triggered and the previously failed request will be retried. But if the retry (.i.e the authorized request) fails, the errorlink won't be triggered as you said, and in that case you should handle the error on your query / mutation results.
Right thats what I expected but it's not happening. As mentioned in my example I've set the expiry time to 5secs and the first time it got called but not again. The interesting part is that no graphqlError but a network error is thrown. So perhaps thats an indicator
Hi, you can check this for reference https://github.com/AnasT/jwt-auth-poc.
The access token is stored in memory and expires in 2s.
Ok I think I figured it out. I've really badly copyied your example and didn't even get to the point of forward(operation). everything undo, back to the workbench :)
Here's an issue I've opened including the sample code https://github.com/apollographql/apollo-client/issues/7483
Thx for the repo link. I figured meanwhile my issue is related to the websocket link used for subscriptions. Since handling refresh tokens there seems only to ne doable by forcing a disconnect there is no point in using an Error Link and instead refreshing by oneself and reconnecting manually. Anyways thanks again for your article, it helped me understand the topic and find a suitable solution.
I'm glad it helped to some extent.
Will check the scenario you described (ws) and update the article according to my findings.
Thanks a lot for your feedback, really appreciate it.
Hello Anas, I am trying the concurrent request one in react-native.I am facing the issue as I can hit API and get new token but after that I am not able to re-hit the fail query due to expire token. I think my issue is :authLink doesn't have the new token, probably because AsyncStorage is set async and 2. filter and flatMap functions do not get called. Any idea on how to solve this
My current code:
let isRefreshing = false; let pendingRequests = [];
const resolvePendingRequests = () => { pendingRequests.map((callback) => callback()); pendingRequests = []; };
const errorLink = onError( ({ graphQLErrors, networkError, operation, forward }) => { if (graphQLErrors) { for (let err of graphQLErrors) { console.log(err, "see my own err");
} } if (networkError) { console.log(
[Network error]: ${networkError}
); // if you would also like to retry automatically on // network errors, we recommend that you use // apollo-link-retry }Hi,
Your getRefreshToken().then(...) doesn't return anything, which means forward$ will never emit.
My suggestion would be to use something like this :
Hello, thansk for the quick reply. I try with the above approach too but still once I get the new token , I am not able to refetch the fail query . I update the code after your suggestion.I am using async storage to add and retrieve value , like getRefresh token for getting refresh token. I don't think such operation can be a reason for my operation failure. I think that when I reach up to "now resolve request ====", my fail query need to refetch again, but it's not happening . any idea on how to fix this issue.
Hello, how do you iterate graphQLErrors? When i iterate errors with forEach, my fail query doesn't retry. But i changed foreach to "for of" and all right
Hi,
It is still the same issue.
If you notice the example I sent you, the calls are chained on the same level, not nested under getRefreshToken().then(..).
Hi Anas, now with the release of Apollo Client 3.0, is there a major change apart from the import statements? Thank you!
Hi, not really, still using the same approach.
Hey Anas, could u pls show me the code for refreshToken function ?
I mean getNewToken()
Hi,
Sorry for the late reply, please check this https://github.com/AnasT/jwt-auth-poc/blob/master/client/src/graphql/index.ts
Hope it helps.
Hey Anas, Awesome article! I've been struggling to get this working (retrying and refreshing tokens) for days now. Ive tried so many things and scoured the net to find any possible solutions. Nothing seems to work yet! :(
I was wondering, would you at all mind helping me get it sorted? Via discord or some chat or something? The docs on this are really missing.
Hey, thanks for your feedback.
Sure thing, can you please share a gist ?
Thank you so much man! Youre so kind 🙏🏼 It's not in a public repo unfortunately, but do you use any chat app then I can send you the code =)
https://join.slack.com/t/anast/shared_invite/zt-gde3e5n8-ajICgoRpd52~XmGvrSsgZQ
This is a great article saved me a ton of time, and thanks for keeping it updated with the feedback that appears in the comments.
Thanks for your feedback, I'm really glad it helps.
I'm trying to recreate this. The only difference is that I keep token and refresh token in local storage. I can get new token and then I store it to local storage, however, after that filter and flatMap doesn't fire, I console log inside of these two functions, but these never get executed.
Any ideas what's going on?
Hey,
That probably means that the observable is not subscribed to (https://repl.it/repls/BadBlaringInterfaces).
apollo-link-error is expecting the ErrorHandler to either not return anything or an Observable it can subscribe to (https://github.com/apollographql/apollo-link/blob/master/packages/apollo-link-error/src/index.ts#L53)
Can you make sure you're returning the forward$.flatMap... ?
Otherwise can you share sample code that illustrates the issue so that I'm able to reproduce it on my end ?
Thanks and Regards
I've solved the problem, I switched from using
map
to using aloop
. Thank you a lot! Can't believe that I needed so much time to figure it out :/, but happy that's finally resolved. Cheers!I was trying to work it out from the third example in the blog post, not the concurrent one. Do you think that I should do the concurrent one?
Here's my current code:
Let me know if you spot an error. I'm not sure why
filter
andflatMap
don't fire up.Based on your comment I'll try to implement the concurrent example and see how that goes.
Thank you for your reply, much appreciated!
Could you show your solution?
Can you check if the condition (token && refreshToken) is satisfied ?
Here's my whole
src/index.js
https://gist.github.com/bzhr/cd047e751a6293756db474695f40bae0I created a StackOberflow question, with the hope that someone can spot the error. https://stackoverflow.com/questions/61736028/apollo-client-async-refresh-token
What I notice is that the new token is received after the retry of the request, "Promise Data" console log, with the data from fetching new token comes after a console log in the auth link.
Here's a gist with the whole code https://gist.github.com/bzhr/531e1c25a4960fcd06ec06d8b21f143b
Yes it is and the new token is fetched successfully. There are two problems 1.
authLink
doesn't have the new token, probably becauselocalstorage.setItem
is set async and 2.filter
andflatMap
functions do not get called.BTW I also tried your concurrent example and the same thing happens...
I am thinking to create a promise that will resolve the two
localstorage.setItem
calls.I am also facing the same issue as filter and flat map are not called. How do you solve this ?
A for..of with a switch case should do it.
So I should use something else instead of the map? Like a switch case?
You're returning the observable in the callback when mapping graphql errors so it is not actually returned from the ErrorHandler and apollo-link is not subscribing to it.
Wow i was struggling with this exact issue and you gave me the perfect solution. It works flawlessly!!!
Thank you so much! You're the boss :)
Thanks a lot, I'm glad it helps.
Thank you Anas for this awesome blog post, it worked like a charm. One small note: in the case of concurrent requests, the first request will not be forwarded again, I fixed the issue by removing the else condition outside
forward$ = fromPromise( new Promise(resolve => { pendingRequests.push(() => resolve()); }) );
Hi Yassir,
Thank you very much for your feedback.
In the case of concurrent requests, the first request blocks further ones and pushes them (to pendingRequests) to be resolved later until it is done refreshing the access token.
Removing the else condition will lead to the first request running twice.
Would be great if you could share a sample where the first request is not retried.
Thanks again.
Really helpful post Anas, thank you!
I also faced a similar issue as op. Essentially once the token had been successfully refreshed, the first query wasn't rerun again - but the others were retried just fine after.
Removing the else condition outside of
forward$ = fromPromise( new Promise(resolve => { pendingRequests.push(() => resolve()); }) );
fixed the issue for me too 🙂Hi Anas, this is looking very good, it's exactly what I was looking for!
Something I've noticed is that, when the token refresh request fails, the first "normal" request that had previously failed is repeated. And it's only the first one, doesn't matter that you have 1, 3, or more concurrent requests.
That repeated request obviously fails silently, so it's not a big issue for this flow, but I can't seem to find out why it is re-triggered even though we do
pendingRequests = []
...Thought I could bring it up with you in case you have any ideas!
Hi Jorge,
Thanks for your comment, I'm glad it helps.
Good catch, I didn't pay attention to that.
Since the token refresh request error is caught, forward$ still emits and therefore the operation is retried.
One way to get around that is to filter out falsy values :
Please let me know if that works for you.
Hi Anas,
Nice one there, now the issue with the refetching is gone!
Hey Jorge,
Great, will reflect that on the post.
Thanks again