retryWhen
retryWhen
gives you full control over the retry logic based on the errors that occur. Unlike retry(n)
, which just retries immediately or with a simple configured delay, retryWhen
lets you look at the sequence of errors and decide if and when to retry.
It works like this:
- You provide
retryWhen
with a function. - This function receives an Observable (let's call it
errors$
) as input. Thiserrors$
observable emits the actual error objects whenever your source Observable fails. - Your function must return a new Observable (let's call it the "notifier" Observable).
- Retry Trigger: The source Observable (e.g., your HTTP request) is resubscribed to (retried) every time the notifier Observable emits a
next
value. - Stop Retrying: If the notifier Observable emits an
error
orcomplete
notification, the retrying stops, and the main Observable chain will emit that sameerror
orcomplete
notification.
Key Points#
- Error Input: Operates on the stream of errors from the source.
- Notifier Output: You define when to retry by controlling when the notifier emits
next
. - Complex Logic: Allows for sophisticated strategies like exponential backoff, checking external conditions (e.g., network status), or even prompting the user (though UI interaction within operators is generally discouraged).
- Termination: You must ensure your notifier eventually emits an
error
orcomplete
if you want the retries to stop; otherwise, you risk infinite retries. Usually, youthrowError
within the notifier's stream after a certain condition (like max retries exceeded) is met.
Important Note#
While retryWhen
is powerful, it's also known for being complex and somewhat tricky to use correctly. For many common retry scenarios (like retrying a fixed number of times with a delay, or retrying based on error type), the simpler retry
operator with its configuration object (retry({ count: 3, delay: ... })
) is often preferred and easier to understand in modern RxJS. Use retryWhen
when you need highly customized or complex retry logic that retry
cannot handle easily.
Real-World Example#
Let's implement a classic exponential backoff strategy. If an HTTP request fails, we want to retry, but wait longer each time before retrying, and give up after a few attempts.
- Attempt 1: Fails -> Wait 1 second -> Retry
- Attempt 2: Fails -> Wait 2 seconds -> Retry
- Attempt 3: Fails -> Wait 4 seconds -> Retry
-
Attempt 4: Fails -> Give up and propagate the error.
-
Source Observable: An
HttpClient
GET request. - Error Stream: The
errors$
observable passed intoretryWhen
. - Notifier Logic: Calculate increasing delay based on retry count, emit after delay, but throw an error if max retries exceeded.
Angular Code Snippet#
We'll modify the previous UserService
example to use retryWhen
for exponential backoff.
1. User Data Service with retryWhen
import { Injectable, inject } from "@angular/core";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { Observable, throwError, timer, pipe } from "rxjs";
import {
retryWhen,
delayWhen,
scan,
tap,
catchError,
mergeMap,
} from "rxjs/operators";
export interface UserData {
id: number;
name: string;
email: string;
}
@Injectable({
providedIn: "root",
})
export class UserService {
private http = inject(HttpClient);
private apiUrl = "/api/user/profile"; // Example API endpoint
getUserProfileWithRetryWhen(
maxRetries = 3,
initialDelay = 1000
): Observable<UserData> {
console.log("Attempting to fetch user profile (using retryWhen)...");
return this.http.get<UserData>(this.apiUrl).pipe(
tap(() =>
console.log("Fetched user profile successfully on an attempt.")
),
// --- retryWhen Logic ---
retryWhen(
(
errors$ // errors$ is an Observable of the errors
) =>
errors$.pipe(
// Use scan to keep track of the retry attempts
scan((retryCount, error: HttpErrorResponse) => {
// Check if error is retryable and count is within limits
// Here we retry only on 5xx or network errors, similar to before
if (
retryCount >= maxRetries ||
!(error.status >= 500 || error.status === 0)
) {
// If max retries reached or error is not retryable, throw the error
// This will cause the notifier stream to error out, stopping retries.
console.error(
`Attempt ${
retryCount + 1
}: Not retrying. Max retries (${maxRetries}) reached or error not retryable (${
error.status
}).`
);
throw error; // Re-throw the error to be caught by the outer catchError
}
// Otherwise, increment the count for the next potential retry
console.warn(
`Attempt ${retryCount + 1} failed. Error: ${
error.message
}. Will retry.`
);
return retryCount + 1;
}, 0), // Initial value for retryCount is 0
// Calculate delay based on the retry count (exponential backoff)
delayWhen((retryCount) => {
const delay = initialDelay * Math.pow(2, retryCount - 1); // Calculate delay: 1000ms, 2000ms, 4000ms...
console.log(
`Attempt ${retryCount}: Waiting ${delay}ms before next retry...`
);
return timer(delay); // Emit after the calculated delay
})
// Note: If the scan operator above throws an error, this delayWhen
// will not execute, and the error propagates immediately.
)
),
// --- End of retryWhen Logic ---
catchError((error: HttpErrorResponse) => {
// This runs AFTER retryWhen gives up (notifier stream errors out)
console.error(
"Failed to fetch user profile after all retryWhen attempts:",
error
);
return throwError(
() =>
new Error(
"Could not load user profile (retryWhen). Please try again later."
)
);
})
);
}
}
2. User Profile Component
import {
Component,
inject,
signal,
ChangeDetectionStrategy,
OnInit,
DestroyRef,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { UserService, UserData } from "./user.service"; // Adjust path if needed
import { catchError, tap, finalize } from "rxjs/operators";
import { EMPTY } from "rxjs";
@Component({
selector: "app-user-profile-rw", // Changed selector
standalone: true,
imports: [CommonModule],
template: `
<h3>User Profile (retryWhen Example)</h3>
@if (loading()) {
<p>Loading user profile...</p>
} @else if (errorMsg()) {
<p style="color: red;">Error: {{ errorMsg() }}</p>
} @else if (userProfile()) {
<div>
<p><strong>ID:</strong> {{ userProfile()?.id }}</p>
<p><strong>Name:</strong> {{ userProfile()?.name }}</p>
<p><strong>Email:</strong> {{ userProfile()?.email }}</p>
</div>
} @else {
<p>Click the button to load profile.</p>
}
<button (click)="loadProfile()" [disabled]="loading()">
Load Profile (retryWhen)
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserProfileRetryWhenComponent {
// Renamed component
private userService = inject(UserService);
private destroyRef = inject(DestroyRef);
// --- State Signals ---
userProfile = signal<UserData | null>(null);
loading = signal<boolean>(false);
errorMsg = signal<string | null>(null);
loadProfile(): void {
this.loading.set(true);
this.errorMsg.set(null);
this.userProfile.set(null);
// Call the method using retryWhen
this.userService
.getUserProfileWithRetryWhen()
.pipe(
tap((data) => console.log("Component received user data:", data)),
takeUntilDestroyed(this.destroyRef),
catchError((err: Error) => {
console.error("Component caught final error:", err);
this.errorMsg.set(err.message || "An unknown error occurred.");
return EMPTY;
}),
finalize(() => {
this.loading.set(false);
console.log("Finished loading attempt (success or final error).");
})
)
.subscribe({
next: (data) => {
this.userProfile.set(data);
},
});
}
}
Explanation of retryWhen
Logic:
retryWhen(errors$ => ...)
: We receive the stream of errors (errors$
).errors$.pipe(...)
: We process this error stream.scan((retryCount, error) => {...}, 0)
:scan
is likereduce
for Observables. It maintains state across emissions.retryCount
holds the number of retries attempted so far (starts at 0).error
is the current error emitted by the source.- Inside
scan
, we check ifretryCount >= maxRetries
or if theerror
type is one we don't want to retry. - If we should stop retrying, we
throw error;
. This is crucial! It makes thescan
operator (and thus the whole notifier stream) emit an error, which signalsretryWhen
to stop and propagate this error. - If we should retry, we increment the
retryCount
and return it. This incremented count becomes the inputretryCount
for the next error emission and is also passed down the notifier pipe.
delayWhen(retryCount => timer(delay))
: This operator takes theretryCount
passed down fromscan
.- It calculates the exponential delay:
initialDelay * Math.pow(2, retryCount - 1)
. timer(delay)
creates an Observable that emits a single value (0) afterdelay
milliseconds.delayWhen
waits for thistimer
to emit before it emits the value it received (theretryCount
). This emission is what triggersretryWhen
to resubscribe to the originalhttp.get
request.
- It calculates the exponential delay:
Summary#
retryWhen
lets you construct a custom "notifier" Observable based on the errors from the source. When the notifier emits next
, a retry occurs. When the notifier emits error
or complete
, retrying stops. While powerful for complex cases, remember that the modern retry
operator often handles common scenarios more simply.