Table of Contents#
- Understanding Retry in RxJava
- Deep Dive into RetryWhen
- Implementing Basic Retry Logic with RetryWhen
- Catching and Propagating Errors When Retries Are Exhausted
- Advanced Retry Scenarios with RetryWhen
- Common Pitfalls and Best Practices
- Conclusion
- References
1. Understanding Retry in RxJava#
Before diving into retryWhen, let’s clarify how retries work in RxJava and why retryWhen is necessary.
What is Retry?#
Retrying an operation means re-subscribing to the "source" Observable when it emits an error. For example, if a network call fails, RxJava can retry the call by resubscribing to the network Observable.
The retry(n) Operator: Simple but Limited#
The retry(n) operator is the simplest way to retry. It takes a number n and retries the source n times before propagating the error. For example:
Observable<Response> networkCall = ...; // Source Observable
networkCall.retry(3) // Retry up to 3 times on failure
.subscribe(
response -> handleSuccess(response),
error -> handleFinalError(error) // Called after 3 retries
);Limitations of retry(n):
- No control over delays between retries (can overwhelm servers with back-to-back requests).
- No conditional retries (e.g., only retry on
IOException, notNullPointerException). - No dynamic retry logic (e.g., exponential backoff based on retry count).
Enter retryWhen: Flexible Retry Logic#
retryWhen addresses these limitations by accepting a function that controls retry behavior. Instead of a fixed count, you define how retries are triggered, delayed, and terminated.
2. Deep Dive into RetryWhen#
To master retryWhen, you need to understand its core mechanics: the function it accepts, and how the input/output Observables control retries.
The retryWhen Function Signature#
retryWhen takes a Function<Observable<Throwable>, Observable<?>> as input. Let’s break this down:
- Input Observable: An
Observable<Throwable>that emits aThrowableevery time the source Observable fails. Think of this as a "failure signal" stream. - Output Observable: The function returns an
Observable<?>that dictates retry behavior:- OnNext: Emitting an item triggers a retry (the item’s value is irrelevant).
- OnError: Emitting an error stops retries and propagates this error downstream.
- OnComplete: Completing stops retries and causes the source to complete successfully (no error).
How retryWhen Works: A Step-by-Step Flow#
- The source Observable emits items normally until it fails (emits
onError). retryWhencaptures this error and emits it to the input Observable of the function.- The function processes the error and returns an output Observable.
- If the output Observable emits
onNext,retryWhenresubscribes to the source (triggers a retry). - If the output Observable emits
onError,retryWhenpropagates this error downstream (retries exhausted). - If the output Observable emits
onComplete,retryWhencompletes the stream without error (no more retries).
3. Implementing Basic Retry Logic with retryWhen#
Let’s start with a simple example: retrying a fixed number of times (e.g., 3 retries) using retryWhen.
Naive Approach: take(n) to Limit Retries#
A common first attempt is to use take(n) on the input Observable to limit retries:
Observable<Response> networkCall = ...;
networkCall.retryWhen(errors -> errors.take(3)) // "Retry 3 times"
.subscribe(
response -> handleSuccess(response),
error -> handleError(error) // Never called!
);What Happens Here?
- The input Observable emits a
Throwableeach time the source fails. errors.take(3)emits the first 3 errors, then completes.- When the output Observable completes,
retryWhenstops retries and completes the stream without emitting an error.
Problem: The handleError callback is never invoked! The stream completes successfully even though the source failed 3 times. This is a silent failure—one of the most dangerous pitfalls of retryWhen.
Why This Fails#
retryWhen interprets an onComplete from the output Observable as "no more retries, and the stream should complete successfully." To propagate the error when retries are exhausted, the output Observable must emit onError, not onComplete.
4. Catching and Propagating Errors When Retries Are Exhausted#
The key to fixing the silent failure is ensuring the output Observable emits an onError when retries are exhausted. To do this, track retry attempts and explicitly propagate the error after the final attempt.
Solution: Track Retry Attempts with zipWith#
Use zipWith to pair errors with a counter (via Observable.range) to track retry attempts. For each attempt:
- If attempts < max retries: Emit
onNextto trigger a retry. - If attempts == max retries: Emit
onErrorwith the originalThrowable.
Example: Retry 3 Times, Then Propagate Error
int maxRetries = 3;
Observable<Response> networkCall = ...;
networkCall.retryWhen(errors ->
// Zip errors with a range [1, maxRetries] to track attempts
errors.zipWith(Observable.range(1, maxRetries), (error, attempt) -> {
if (attempt < maxRetries) {
// Retry: Emit a value to trigger resubscription
return attempt;
} else {
// Exhausted retries: Propagate the original error
throw Exceptions.propagate(error);
}
})
)
.subscribe(
response -> handleSuccess(response),
error -> handleFinalError(error) // Called after 3 retries
);How It Works:
Observable.range(1, maxRetries)emits1, 2, 3(one per retry attempt).zipWithpairs each error with an attempt number ((error, 1),(error, 2),(error, 3)).- For
attempt < maxRetries(1 and 2), emitonNextto retry. - For
attempt == maxRetries(3), throw the original error (wrapped viaExceptions.propagateto avoid checked exceptions), causing the output Observable to emitonError. retryWhenpropagates thisonErrordownstream, triggeringhandleFinalError.
Testing the Flow#
- Source fails once:
attempt=1→ retry. - Source fails again:
attempt=2→ retry. - Source fails a third time:
attempt=3→ throw error →handleFinalErroris called.
5. Advanced Retry Scenarios with retryWhen#
retryWhen shines in advanced scenarios like exponential backoff, jitter, and conditional retries. Let’s explore these.
Exponential Backoff: Delaying Retries#
To avoid overwhelming servers, add delays between retries that grow exponentially (e.g., 1s, 2s, 4s). Use flatMap with Observable.timer to add delays:
int maxRetries = 3;
networkCall.retryWhen(errors ->
errors.zipWith(Observable.range(1, maxRetries), (error, attempt) -> {
if (attempt > maxRetries) {
throw Exceptions.propagate(error); // Final error
}
return attempt; // Pass attempt to flatMap for delay
})
.flatMap(attempt -> {
// Exponential delay: 2^(attempt) seconds
long delaySeconds = (long) Math.pow(2, attempt);
return Observable.timer(delaySeconds, TimeUnit.SECONDS);
})
)
.subscribe(...);Result: Retries occur after 1s, 2s, and 4s (total ~7s before final error).
Jitter: Adding Randomness to Backoff#
Exponential backoff can cause "thundering herds" (many clients retrying at the same time). Add jitter (randomness) to spread out retries:
.flatMap(attempt -> {
long baseDelay = (long) Math.pow(2, attempt);
long jitter = new Random().nextInt(1000); // 0-1000ms
return Observable.timer(baseDelay + jitter, TimeUnit.SECONDS);
})Conditional Retries: Retry Only on Specific Exceptions#
Retry only for transient errors (e.g., IOException), not fatal errors (e.g., AuthException). Filter the input Observable:
networkCall.retryWhen(errors ->
errors
// Only retry on IOException
.filter(error -> error instanceof IOException)
.zipWith(Observable.range(1, maxRetries), (error, attempt) -> {
if (attempt >= maxRetries) {
throw Exceptions.propagate(error);
}
return attempt;
})
.flatMap(attempt -> Observable.timer((long) Math.pow(2, attempt), TimeUnit.SECONDS))
)Result: Non-IOException errors are propagated immediately (no retries).
6. Common Pitfalls and Best Practices#
Pitfall 1: Silent Failures (Output Observable Completes)#
As shown earlier, using take(n) causes the output Observable to complete, leading the stream to complete without error. Always use onError to propagate exhaustion.
Pitfall 2: Infinite Retries#
If the output Observable never emits onError or onComplete, retries will loop infinitely (e.g., errors.map(error -> 1)). Always bound retries with a max count.
Pitfall 3: Blocking the Event Loop#
Avoid blocking operations (e.g., Thread.sleep) in the retryWhen function. Use RxJava’s schedulers (e.g., Observable.timer on Schedulers.io()).
Best Practices#
- Always Propagate Errors: Use
onErrorin the output Observable when retries are exhausted. - Log Retries and Errors: Log attempts, delays, and errors for debugging:
.zipWith(Observable.range(1, maxRetries), (error, attempt) -> { Log.d("RetryWhen", "Attempt " + attempt + " failed: " + error.getMessage()); // ... retry logic ... }) - Use Bounded Retries: Always set a max retry count to prevent infinite loops.
- Prefer Specific Exceptions: Retry only on known transient errors (e.g.,
IOException), not all errors.
7. Conclusion#
retryWhen is a powerful operator for building resilient reactive systems, but its flexibility demands careful handling of error propagation. By tracking retry attempts with zipWith, adding delays with timer, and explicitly emitting onError when retries are exhausted, you can ensure failures are never silent.
Key takeaways:
retryWhenuses a function to control retries via input/output Observables.- The output Observable must emit
onErrorto propagate errors when retries are exhausted. - Combine
zipWith,flatMap, andfilterfor advanced logic like backoff and conditional retries. - Avoid silent failures by never letting the output Observable complete prematurely.