Search code examples
angularrxjsobservablerestangular

rxJs & angular 4 & restangular: stack errorInterceptors


In my angular 4 app I'm using ngx-restangular to handle all server calls. It returns observable as result, and this module has hooks to handle errors (like 401 etc).

But from documentation, i can handle 403 (401) so:

  RestangularProvider.addErrorInterceptor((response, subject, responseHandler) => {
    if (response.status === 403) {

      refreshAccesstoken()
      .switchMap(refreshAccesstokenResponse => {
        //If you want to change request or make with it some actions and give the request to the repeatRequest func.
        //Or you can live it empty and request will be the same.

        // update Authorization header
        response.request.headers.set('Authorization', 'Bearer ' + refreshAccesstokenResponse)

        return response.repeatRequest(response.request);
      })
      .subscribe(
        res => responseHandler(res),
        err => subject.error(err)
      );

      return false; // error handled
    }
    return true; // error not handled
  });

and this is good for one request, which has broken with 403 error. how can i stack this calls using rxJs? Becouse now, for example, i have 3 requests, which have 403 and for each this broken request I'm refreshing token - this is not so good, i have to update my token and then repeat all my broken requests. How can I achive this using Observables?

In angular 1 it was pretty easy:

Restangular.setErrorInterceptor(function (response, deferred, responseHandler) {
  if (response.status == 403) {
    // send only one request if multiple errors exist
    if (!refreshingIsInProgress) {
      refreshingIsInProgress = AppApi.refreshAccessToken(); // Returns promise
    }


    $q.when(refreshingIsInProgress, function () {
      refreshingIsInProgress = null;

      setHeaders(response.config.headers);

      // repeat request with error
      $http(response.config).then(responseHandler, deferred);
    }, function () {
      refreshingIsInProgress = null;

      $state.go('auth');
    });

    return false; // stop the promise chain
  }

  return true;
});

And all was working like a charm. But I'm new to rxJs & angular 4 and I don't have any idea how to achive this with observables and angular 4. Maybe somebody have an idea?

upd! here is my refreshAccesstoken method

const refreshAccesstoken = function () {
  const refreshToken = http.post(environment.apiURL + `/token/refresh`,
    {refreshToken: 'someToken'});
  return refreshToken;
};

Solution

  • One way I can see of doing this using ngx-restangular is to use the share operator. This way you don't have to implement complicated queueing logic. The idea is that if you have 3 requests all of the with a 403 response, they will all hit your interceptor and call your observable. If you share that observable, you will have only one token request for the 3 requests with a broken token.

    You just have to use the share operator in your code like so:

    refreshAccesstoken()
      .share()
      .switchMap(refreshAccesstokenResponse => {
        //If you want to change request or make with it some actions and give the request to the repeatRequest func.
        //Or you can live it empty and request will be the same.
    
        // update Authorization header
        response.request.headers.set('Authorization', 'Bearer ' + refreshAccesstokenResponse)
    
        return response.repeatRequest(response.request);
      })
      .subscribe(
        res => responseHandler(res),
        err => subject.error(err)
      );
    

    I haven't checked that the code actually works but I have used this approach before for the same use case, but instead of interceptors, I was using the angular HTTP service.

    EDIT to change refreshAccessToken:

    You need to wrap your refreshAccessToken method in a deferred Observable and share it. This way you will reuse the same observable every time.

    In the constructor:

    this.source = Observable.defer(() => {
            return this.refreshAccesstoken();
        }).share();
    

    Create another method that will invoke that observable:

    refreshToken(): Observable<any> {
        return this.source
            .do((data) => {
                this.resolved(data);
            }, error => {
                this.resolved(error);
            });
    }
    

    EDIT2

    I have created a git repository which uses angular2 with restangular. The scenario is the following:

    1. In my app.component I'm making 3 concurrent requests to get a list of orders. When the request has finished, I will log "Orders received".
    2. The orders endpoint requires an auth token. If one isn't provided it will return a 401.
    3. In my app.module I set the base URL to my API only. Because I am not setting the authorization token as well, all of my requests will fail with a 401.
    4. When the interceptor code is executed it will set the refresh token, which in my case is hard coded on the request and repeated the request.
    5. The observable that returns the token logs "Getting token" each time it is executed.

    Here is what I can see in my console: console logs and requests

    If I remove the share operator I will get the following logs: enter image description here meaning that the observable will be created every time.

    In order for this to work, it is important that the source is declared and created in the RestangularConfigFactory. It will essentially become a singleton object and that's what allows the Share operator to work.

    NOTE:

    I created a simple web API hosted locally for this project just because it was faster for me.

    EDIT3:Update to include code to refresh the token:

    @Injectable()
    export class TokenRefreshService {
        source: Observable<any>;
        pausedObservable: Observable<any>;
        constructor(
            private authenthicationStore: AuthenticationStore,
            private router: Router,
            private authenticationDataService: AuthenticationDataService,
            private http: ObservableHttpService) {
            this.source = Observable.defer(() => {
                return this.postRequest();
            }).share();
        }
    
        refreshToken(): Observable<any> {
            return this.source
                .do((data) => {
                    this.resolved(data);
                }, error => {
                    this.resolved(error);
                });
        }
    
        public shouldRefresh(): boolean {
            if (this.getTime() < 0) {
                return true;
            }
            return false;
        }
    
        private postRequest(): Observable<any> {
            let authData = this.authenticationDataService.getAuthenticationData();
            if (authData == null) {
                return Observable.empty();
            }
            let data: URLSearchParams = new URLSearchParams();
            data.append('grant_type', 'refresh_token');
    
            let obs = this.http.postWithHeaders(
                'token', data, { 'Content-Type': 'application/x-www-form-urlencoded' })
                .map((response) => {
                    return this.parseResult(true, response, 'authenticateUserResult');
                })
                .catch((error) => {
                    let errorMessage = this.rejected(error);
                    return Observable.throw(errorMessage);
                });
            return obs;
        }
    
        private rejected(failure) {
            let authenticateUserResult;
            let response = failure;
            let data = response.json();
    
            if (response &&
                response.status === 400 &&
                data &&
                data.error &&
                data.error === 'invalid_grant') {
    
                authenticateUserResult = this.parseResult(false, data, 'error_description');
    
                return authenticateUserResult;
            } else {
                return failure;
            }
        }
    
        private parseResult(success, data, resultPropertyName) {
    
            let authenticateResultParts = data[resultPropertyName].split(':');
    
            data.result = {
                success: success,
                id: authenticateResultParts[0],
                serverDescription: authenticateResultParts[1]
            };
    
            return data;
        }
    
        private resolved(data): void {
            let authenticationResult = data.result;
            if (authenticationResult && authenticationResult.success) {
                let authenticationData = this.createAuthenticationData(data);
                this.authenthicationStore.setUserData(authenticationData);
            } else {
                this.authenthicationStore.clearAll();
                this.router.navigate(['/authenticate/login']);
            }
        }
    
        private createAuthenticationData(data: any): AuthenticationData {
            let authenticationData = new AuthenticationData();
            authenticationData.access_token = data.access_token;
            authenticationData.token_type = data.token_type;
            authenticationData.username = data.username;
            authenticationData.friendlyName = data.friendlyName;
           
            return authenticationData;
        }
    
        private getTime(): number {
            return this.getNumberOfSecondsBeforeTokenExpires(this.getTicksUntilExpiration());
        }
    
        private getTicksUntilExpiration(): number {
            let authData = this.authenticationDataService.getAuthenticationData();
            if (authData) {
                return authData.expire_time;
            }
            return 0;
        }
    
        private getNumberOfSecondsBeforeTokenExpires(ticksWhenTokenExpires: number): number {
            let a;
            if (ticksWhenTokenExpires === 0) {
                a = new Date(new Date().getTime() + 1 * 60000);
            } else {
                a = new Date((ticksWhenTokenExpires) * 1000);
            }
    
            let b = new Date();
            let utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate(), a.getHours(), a.getMinutes(), a.getSeconds());
            let utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate(), b.getHours(), b.getMinutes(), b.getSeconds());
    
            let timeInSeconds = Math.floor((utc1 - utc2) / 1000);
            return timeInSeconds - 5;
        }
    }