cyberangles blog

RxJava RetryWhen: How to Catch and Propagate Errors When Retries Are Exhausted

In reactive programming with RxJava, handling errors gracefully is critical to building robust applications. Transient failures—such as network timeouts, temporary server unavailability, or database connection blips—are common in distributed systems. Retrying failed operations can often resolve these issues, improving user experience and system resilience.

RxJava provides several operators for retries, with retryWhen standing out for its flexibility. Unlike the simpler retry(n) operator, which retries a fixed number of times unconditionally, retryWhen lets you define custom retry logic: delays between retries, conditional retries (e.g., only for specific exceptions), and exponential backoff. However, this power comes with complexity—especially when ensuring errors are properly propagated once retries are exhausted.

This blog dives deep into retryWhen, explaining how it works, how to implement basic and advanced retry strategies, and—most importantly—how to catch and propagate errors when retries run out. By the end, you’ll be able to use retryWhen to build resilient, error-aware reactive pipelines.

2025-11

Table of Contents#

  1. Understanding Retry in RxJava
  2. Deep Dive into RetryWhen
  3. Implementing Basic Retry Logic with RetryWhen
  4. Catching and Propagating Errors When Retries Are Exhausted
  5. Advanced Retry Scenarios with RetryWhen
  6. Common Pitfalls and Best Practices
  7. Conclusion
  8. 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, not NullPointerException).
  • 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 a Throwable every 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#

  1. The source Observable emits items normally until it fails (emits onError).
  2. retryWhen captures this error and emits it to the input Observable of the function.
  3. The function processes the error and returns an output Observable.
  4. If the output Observable emits onNext, retryWhen resubscribes to the source (triggers a retry).
  5. If the output Observable emits onError, retryWhen propagates this error downstream (retries exhausted).
  6. If the output Observable emits onComplete, retryWhen completes 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 Throwable each time the source fails.
  • errors.take(3) emits the first 3 errors, then completes.
  • When the output Observable completes, retryWhen stops 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 onNext to trigger a retry.
  • If attempts == max retries: Emit onError with the original Throwable.

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) emits 1, 2, 3 (one per retry attempt).
  • zipWith pairs each error with an attempt number ((error, 1), (error, 2), (error, 3)).
  • For attempt < maxRetries (1 and 2), emit onNext to retry.
  • For attempt == maxRetries (3), throw the original error (wrapped via Exceptions.propagate to avoid checked exceptions), causing the output Observable to emit onError.
  • retryWhen propagates this onError downstream, triggering handleFinalError.

Testing the Flow#

  • Source fails once: attempt=1 → retry.
  • Source fails again: attempt=2 → retry.
  • Source fails a third time: attempt=3 → throw error → handleFinalError is 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#

  1. Always Propagate Errors: Use onError in the output Observable when retries are exhausted.
  2. 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 ...
    })
  3. Use Bounded Retries: Always set a max retry count to prevent infinite loops.
  4. 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:

  • retryWhen uses a function to control retries via input/output Observables.
  • The output Observable must emit onError to propagate errors when retries are exhausted.
  • Combine zipWith, flatMap, and filter for advanced logic like backoff and conditional retries.
  • Avoid silent failures by never letting the output Observable complete prematurely.

8. References#