alsami
alsami

Reputation: 9815

NGRX: http retry interceptor causing failure action not to be fired

I am using NGRX and I want to have simple GET requests to an API to be retried five times. Reason for this is that I am using Azure Cosmos-DB and I am being throttled sometimes. (free-tier).

I've created an http-interceptor for this which is pretty straight forward and looks like that

@Injectable()
export class HttpRetryInterceptor implements HttpInterceptor {
  public intercept(
    request: HttpRequest<any>,
    httpHandler: HttpHandler
  ): Observable<HttpEvent<any>> {
    const nextRequest = request.clone();

    // here the NGRX failure action is not being triggered after five failing requests
    return httpHandler.handle(nextRequest).pipe(
      retryWhen(errors => errors.pipe(delay(1000), take(5))),
      last()
    );
  }
}

This works just fine and each failing http-request is retried five times with a delay of 1000ms.

Problem now is, that the failure action in the effect is not being triggered when the requests actually failed for five times.

load$ = createEffect(() =>
  this.actions$.pipe(
    ofType(globalStatsActions.load),
    mergeMap(() =>
      this.globalStatsService.load().pipe(
        map(stats =>
          globalStatsActions.loaded({
            latestStats: stats
          })
        ),
        catchError(() => of(globalStatsActions.loadFailed())) // not being called using the http-interceptor
      )
    )
  )
);

What is weird is, that when the http-interceptor uses the operator retry rather than retryWhen it works just fine. Sadly, with that operator you cannot define a delay, which is required in my case.

Another interesting fact is, that when using the very same retry logic on the service used in the effect, it works just fine.

// here the failure action is being triggered after five failing requests
public load(): Observable<GlobalStats> {
  return this.http.get<GlobalStats>(`${this.baseUrl}stats`)
  .pipe(
    retryWhen(errors => errors.pipe(delay(1000), take(5))),
    last()
  );

While this is not much code and could be copied onto each http-service that I am using, I would love to use an interceptor for it instead.

I couldn't seem to find the cause for that and I hope, that anyone out there knows why this is not working.

Upvotes: 4

Views: 1574

Answers (1)

Andrei Gătej
Andrei Gătej

Reputation: 11979

Another interesting fact is, that when using the very same retry logic on the service used in the effect, it works just fine.

retry(n) will simply pass along the error notification when n attempts are reached. When attempts < n, it will unsubscribe from the source, then it will re-subscribe to it.

retryWhen(fn) maintains an inner subscription, which is the result of the observable provided with the function fn. The fn's single argument is an errors subject that will push values every time an error occurs.

So, if you have

retryWhen(subjectErrors => subjectErrors.pipe(a(), b()))

is essentially the same as:

const subjectErrors = new Subject();
subjectErrors.pipe(a(), b()).subscriber(innerSubscriber);

An innerSubscriber is used inside retryWhen too, and its main role is to inform when a value(next notification) arrives.

When this happens, the source will be re-subscribed. In this case, the source is an observable that makes an http request, and when an error occurs, it will send an error notification; if the request is successful, it will emit a next notification, followed by a complete notification.

take(5) from retryWhen(errors => errors.pipe(delay(1000), take(5))) means that when 5 attempts are reached, the innerSubscriber(that one from above) will emit a complete notification.

Then you have last, which has no default value. This means that when it receives a complete notification, without receiving any next notifications before, it will emit an error(default behavior).

Then the error is caught by catchError() in your effect. So this should explain why this approach works.

What is weird is, that when the http-interceptor uses the operator retry rather than retryWhen it works just fine.

This is something I couldn't explain why because, using the information from the previous section, it should work fine, the created streams should be identical.

If you have

load () {
 return this.http.get(...);
}

and assuming that interceptor is the only one you're using, in

mergeMap(() =>
  this.globalStatsService.load().pipe(
    map(stats =>
      globalStatsActions.loaded({
        latestStats: stats
      })
    ),
    catchError(() => of(globalStatsActions.loadFailed())) // not being called using the http-interceptor
  )
)

this.globalStatsService.load() should be the same as

of(request).pipe(
  concatMap(
    req => new Observable(s => { /* making the request.. */ }).pipe(
      retryWhen(errors => errors.pipe(delay(1000), take(5))),
      last(),
    )
  )
)

which should be the same as with what happens in the previous section.

So, if I'm not missing anything, the catchError should be reached.

If not, it could mean that you either have some other catchError or last has received a value and thus it does not throw an error.

Does it log something in this case?

    // here the NGRX failure action is not being triggered after five failing requests
    return httpHandler.handle(nextRequest).pipe(
      retryWhen(errors => errors.pipe(delay(1000), take(5))),
      tap(console.log), 
      last()
    );

Edit

As can be seen in the source code, the observable in which the request happens will send through its subscriber an event which indicates that the request has been dispatched.

With this in mind, you could use the filter operator:

return httpHandler.handle(nextRequest).pipe(
  filter(ev => ev.type !== 0),
  retryWhen(errors => errors.pipe(delay(1000), take(5))),
  last()
);

Upvotes: 4

Related Questions