Reputation: 426
I have an Angular (v6 still, unfortunately) interceptor that should add API tokens to HTTP calls, and if a call fails with 401, should refresh the token. I also have a service that acquires tokens and caches them.
public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// some business logic to get e.g. clientId (depends on request parameters)
return from(this.getTokenFor(clientId, requestedScope, forceRefresh)).pipe(
flatMap(token => this.handleRequest(token, request, next)),
catchError(error => {
// If this is a cached token, it could be expired/revoked
// Try refreshing and retrying the request below, if this could be the case
if (error.status !== 401 || forceRefresh) {
return throwError(error);
}
return from(this.getTokenFor(clientId, requestedScope, true))
.pipe(flatMap(token => this.handleRequest(token, request, next)));
}));
}
this.handleRequest
adds the Authorization header and forwards the event to next
. How can I achieve, that getTokenFor
(which returns a promise of string) is only called once at a time, even for different HTTP requests? Subsequent calls should wait for a previous to complete before calling the promise again.
Upvotes: 1
Views: 671
Reputation: 23803
I think the safest bet in that case is to have an external service as a singleton, that'll run only one refresh request at a time, and otherwise return the previously fetched token.
@Injectable()
export class Auth {
private requestToken$ = new Subject<{
clientId;
requestedScope;
forceRefresh;
}>();
private token$: Observable<string>;
public getTokenFor(clientId, requestedScope, forceRefresh) {
requestToken$.next({ clientId, requestedScope, forceRefresh });
return this.token$;
}
constructor() {
this.token$ = this.requestToken$.pipe(
exhaustMap(() => {
return concat(
of(null)
// your getTokenFor implementation
);
}),
filter(x => !!x),
shareReplay({ bufferSize: 1, refCount: false })
);
}
}
Some details about the above:
getTokenFor
method only pass the parameters to a subject that is managed outside the method. The reason for this is because if we were to manage the creation of an observable in the method, everytime you'd call it you'd get a new instance which isn't what we wantexhaustMap
is the operator to go with here because if there are attemps to refresh the token while it's still happening, it won't do it. Only 1 instance can run to return the tokenof(null)
in the concat
so that it clears up any previous token we have and then triggers the fetch of the new one (this will put on hold all the calls that are trying to get a token)filter(x => !!x),
because we start by returning null
to signal that a a refresh is happening, we filter this out here and only keep a real result (a token). This will make all the observables down stream wait for the tokenshareReplay({ bufferSize: 1, refCount: false })
is so that if any http call happens after another one, we get the same result as the previous one (either waiting for a token or the token itself). As for the refCount
false, this could be a real issue with a memory leak in a lot of cases but in that specific case, I don't think you'll ever want to shutdown your auth service so it's fine (otherwise add a takeUntil
or something like this)Then you can inject that service in your interceptor and use the getTokenFor
method safely. If one call triggers a token refresh, this call and all the new ones will be put on hold until the refreshed token is here at which point they'll resume
Upvotes: 2
Reputation: 71
You can achieve this by using the retryWhen
rxjs operator in your interceptor:
public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// some business logic to get e.g. clientId (depends on request parameters)
return from(this.getTokenFor(clientId, requestedScope, forceRefresh)).pipe(
flatMap(token => this.handleRequest(token, request, next)),
retryWhen(error =>
// handling the errors from the endpoint
error.pipe(
concatMap((err, count) => {
// handing the 401 issues
if (count <= retryCount) {
// first time execute refresh token logic...
return from(this.getTokenFor(clientId, requestedScope, true)).pipe(flatMap(token => this.handleRequest(token, request, next)));
}
// Error logging after retrying
if (count > retryCount && err?.status === 401) {
}
return throwError({ error: err });
})
)
));
}
On top of the interceptor, you can set the retry limit
const retryCount = 1;
Upvotes: 0