Skip to content

Errors and retries

When a stream fails, it sends an exception to its subscribers and stops. These helpers decide what happens next: replace the failure with a value, log it, or subscribe again and retry, with or without a delay.

using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Extensions;
using ReactiveUI.Primitives.Signals;

The core operators for this job are on the error handling page. Recover switches to a fallback stream, and Reattempt retries straight away. The helpers here are shorter calls for common cases, and add delays between retries.

A stream that fails twice

The retry examples use this method. It returns a stream that fails on its first two subscriptions and succeeds on the third. Subscribing again runs a cold stream's work again, which is what a retry relies on.

static IObservable<string> FlakyDownload(Func<int> nextAttempt) =>
    Signal.Lazy(() =>
    {
        var attempt = nextAttempt();
        Console.WriteLine($"attempt {attempt}");
        return attempt < 3
            ? Signal.Fail<string>(new TimeoutException($"attempt {attempt} timed out"))
            : Signal.Emit("downloaded");
    });

Signal.Lazy runs its lambda on each subscription, so every retry makes a fresh attempt.

Replacing an error with a value

CatchAndReturn and CatchReturn

CatchAndReturn catches any error. It sends the value you give, then completes. CatchReturn is the same helper under a second name.

Signal.Fail<int>(new InvalidOperationException("no data"))
      .CatchAndReturn(0)
      .Subscribe(static x => Console.WriteLine(x), static () => Console.WriteLine("completed"));

Output:

0
completed

CatchAndReturn for one exception type

Give CatchAndReturn a lambda that takes one exception type, and it catches only that type. The lambda builds the value to send from the exception. Any other exception passes through as a failure.

Name the exception type on the lambda's parameter, and C# works out the rest:

Signal.Fail<int>(new TimeoutException("slow"))
      .CatchAndReturn(static (TimeoutException error) => -1)
      .Subscribe(static x => Console.WriteLine(x), static () => Console.WriteLine("completed"));

Signal.Fail<int>(new InvalidOperationException("broken"))
      .CatchAndReturn(static (TimeoutException error) => -1)
      .Subscribe(static x => Console.WriteLine(x), static error => Console.WriteLine($"failed: {error.Message}"));

Output:

-1
completed
failed: broken

CatchReturnUnit

CatchReturnUnit works on a stream of RxVoid. It is CatchAndReturn(RxVoid.Default): on an error, it sends one RxVoid and completes. It suits a stream that only reports "done".

Signal.Fail<RxVoid>(new InvalidOperationException("save failed"))
      .CatchReturnUnit()
      .Subscribe(static _ => Console.WriteLine("done anyway"));

Output:

done anyway

Ignoring an error

CatchIgnore

CatchIgnore with no arguments catches any error and completes, sending nothing more.

Signal.Fail<int>(new InvalidOperationException("no data"))
      .CatchIgnore()
      .Subscribe(static x => Console.WriteLine(x), static () => Console.WriteLine("completed"));

Output:

completed

CatchIgnore for one exception type

Give CatchIgnore an action that takes one exception type, and it catches only that type. It runs your action with the exception, then completes. Any other exception passes through as a failure.

Signal.Fail<int>(new TimeoutException("slow"))
      .CatchIgnore(static (TimeoutException error) => Console.WriteLine($"ignored: {error.Message}"))
      .Subscribe(static x => Console.WriteLine(x), static () => Console.WriteLine("completed"));

Output:

ignored: slow
completed

If your action throws, that exception becomes the failure instead.

Logging an error

LogErrors

LogErrors runs your action with each error, then passes the error on. The stream still fails, so add a catch or a retry after it if it should keep going.

Signal.Fail<int>(new InvalidOperationException("boom"))
      .LogErrors(static error => Console.WriteLine($"log: {error.Message}"))
      .CatchAndReturn(0)
      .Subscribe(static x => Console.WriteLine(x));

Output:

log: boom
0

Retrying

Every retry helper here subscribes to the source again after a failure. Each one counts retries: the tries after the first run. A count of 2 allows three runs in total, the same as Reattempt(2). When the retries run out, the last error goes to your subscriber.

The delay helpers wait on Sequencer.Default, a background thread, unless you pass a sequencer.

HelperRetriesWait before each retry
OnErrorRetry()Until it succeedsNone
OnErrorRetry(onError, ...)A count you give, or until it succeedsNone, or a fixed delay
RetryWithFixedDelayA count you giveThe same each time
RetryWithBackoffA count you giveGrows each time
RetryWithDelayA count you giveWhatever your lambda returns
RetryForeverWithDelayUntil it succeedsThe same each time

Retrying forever is only safe for a failure that will clear up on its own. A bug that fails every time retries forever too.

OnErrorRetry

OnErrorRetry() with no arguments subscribes again after every failure, with no delay, until the source completes.

var attempts = 0;

FlakyDownload(() => ++attempts)
    .OnErrorRetry()
    .Subscribe(static result => Console.WriteLine(result));

Output:

attempt 1
attempt 2
attempt 3
downloaded

The other overloads take an action that runs on each failure, which suits logging. Add a retry count, a delay, and a sequencer for the delay, in that order. The action is typed to one exception type, and runs only for that type.

var attempts = 0;

FlakyDownload(() => ++attempts)
    .OnErrorRetry(
        static (TimeoutException error) => Console.WriteLine($"retrying after: {error.Message}"),
        retryCount: 5,
        delay: TimeSpan.FromMilliseconds(100))
    .Subscribe(static result => Console.WriteLine(result));

Output:

attempt 1
retrying after: attempt 1 timed out
attempt 2
retrying after: attempt 2 timed out
attempt 3
downloaded

The action runs for the last failure too, when the retries run out. A failure of another exception type is not retried: it goes straight to your subscriber.

RetryWithFixedDelay

RetryWithFixedDelay waits the same time before each retry.

var attempts = 0;

FlakyDownload(() => ++attempts)
    .RetryWithFixedDelay(retryCount: 5, delay: TimeSpan.FromMilliseconds(100))
    .Subscribe(static result => Console.WriteLine(result));

Output, with 100 ms between the attempts:

attempt 1
attempt 2
attempt 3
downloaded

RetryWithBackoff

RetryWithBackoff doubles the wait after each retry. This is exponential backoff: a busy server gets more room to recover each time, instead of a flood of instant retries.

var attempts = 0;

FlakyDownload(() => ++attempts)
    .RetryWithBackoff(maxRetries: 3, initialDelay: TimeSpan.FromMilliseconds(50))
    .Subscribe(static result => Console.WriteLine(result));

Output:

TimeOutput
0 msattempt 1
50 msattempt 2
150 msattempt 3, then downloaded

The waits were 50 ms, then 100 ms. The longer overload sets the multiplier, a longest wait, and the sequencer:

IObservable<string> patient = FlakyDownload(() => 3).RetryWithBackoff(
    maxRetries: 10,
    initialDelay: TimeSpan.FromMilliseconds(100),
    backoffFactor: 1.5,
    maxDelay: TimeSpan.FromSeconds(5),
    scheduler: null);   // null uses Sequencer.Default

RetryWithDelay

RetryWithDelay asks your lambda how long to wait before each retry. The lambda gets the retry number, starting at 0 for the first retry.

var attempts = 0;

FlakyDownload(() => ++attempts)
    .RetryWithDelay(
        retryCount: 5,
        delaySelector: static retry => TimeSpan.FromMilliseconds(100 * (retry + 1)))
    .Subscribe(static result => Console.WriteLine(result));

Output, with 100 ms before the first retry and 200 ms before the second:

attempt 1
attempt 2
attempt 3
downloaded

RetryForeverWithDelay

RetryForeverWithDelay waits the same time before each retry, and never gives up.

var attempts = 0;

FlakyDownload(() => ++attempts)
    .RetryForeverWithDelay(TimeSpan.FromMilliseconds(100))
    .Subscribe(static result => Console.WriteLine(result));

Output:

attempt 1
attempt 2
attempt 3
downloaded

Every helper on this page at a glance

HelperSecond nameWhat it does
CatchAndReturn(value)CatchReturnOn any error, sends a value and completes.
CatchAndReturn(lambda)On one exception type, sends a value built from it and completes.
CatchReturnUnitOn any error, sends RxVoid and completes.
CatchIgnore()On any error, completes.
CatchIgnore(action)On one exception type, runs an action and completes.
LogErrorsRuns an action with each error, and still fails.
OnErrorRetryRetries, with an optional action, count and delay.
RetryWithFixedDelayRetries a set number of times, with the same wait.
RetryWithBackoffRetries with a wait that grows each time.
RetryWithDelayRetries with a wait your lambda chooses.
RetryForeverWithDelayRetries forever, with the same wait.

The types behind these helpers

CatchReturnObservable<T>, CatchIgnoreEmptyObservable<T>, CatchIgnoreObservable<T, TException>, CatchAndReturnWithFactoryObservable<T, TException>, LogErrorsObservable<T> and RetryForeverObservable<T> are public classes in ReactiveUI.Primitives.Extensions.Operators. Each takes its source through the constructor. The delayed retry helpers build internal types, so call the helper.