RxSwift Operators - Error Handling

RxSwift provides 3 ways to let a sequence errors out at any time point of its lifetime as well as 2 strategies to handle these emitted errors.

How To Error Out in RxSwift

Generally there are 3 ways to terminate current sequence with an error.

  1. Create a special observable that emits nothing but an error Observable.error(someError).

    class LocationService {
      enum Error: Swift.Error {
        ...
        case serviceDisabled
        ...
      }
    
      func startLocationUpdating() -> Observable<Location> {
        guard CLLocationManager.locationServiceEnabled else {
          return .error(Error.serviceDisabled)
        }
        ...
      }
    }
  2. Emit an error event to the observer parameter in an Observable<T>.create block parameter observer.onError(someError).

    Observable<T>.create { observer in
      ...
      let someError = ...
      observer.onError(someError)
      ...
      return Disposables...
    }
  3. Throw an error in those operator block parameters that are defined as throws, the sequence would terminates on the error event.

    someSequence.map { value in
      guard ... else {
        let someError = ...
        throw someError
      }
      ...
    }

Error Handling Policies

RxSwift provides 2 handling policies:

  1. Catch error and switch to another sequence

  2. Retry the original sequence

Actually the retry way is just a specialized version of the catch way - it catch errors and then switch to same sequence again.

Strategy #1 - Catch Errors

There are 4 error catching operators I known:

  1. On error switch to another sequence seq.catchError { error -> Observable<T> in .... }

    The block, on error, would return a new observable sequence to switch to.

  2. On error end with a given value seq.catchErrorJustReturn(someValue)

    It is equivalent to seq.catchError { _ in return .just(someValue) }

  3. On error just complete silently seq.catchErrorJustComplete()

    It is equivalent to seq.catchError { _ in return .empty() }, this operator is provided by the RxSwiftExt community project.

  4. On error switch to next observable Observable<T>.catchError(swiftSequenceOfObservables)

The traits Driver and Signal from RxCocoa also provides similar operators when converting from ordinary sequences:

  • seq.asDriver(onErrorJustReturn: someValue)
  • seq.asDriver(onErrorDriveWith: alternativeDriver)
  • seq.asDriver(onErrorRecover: { error -> Driver<T> in ... })
  • seq.asSignal(onErrorJustReturn: someValue)
  • seq.asSignal(onErrorSignalWith: alternativeSignal)
  • seq.asSignal(onErrorRecover: { error -> Signal<T> in ... })

Sequence error catching is really useful for those flatMap scenarios, where the outer sequence would terminate if any of its inner sequence errors out (inner errors would be propagated out and terminate outer sequence).

seq
  .flatMap { value -> Observable<T> in
    return makeInnerSequence()
      .catchError { error in
        jack.error("inner error: \(error)")
        return alternativeSequence
      }
      // or
      .catchErrorJustReturn(someValue)
      // or
      .catchErrorJustComplete()
      // or
      .asDriver(onError...)
  }
  .asDriver(onError...)

Strategy #2 - Retry

RxSwift provides 3 retry operators:

  1. Retry unlimited seq.retry() retry unconditionally, use it with caution.

  2. Retry limited times seq.retry(count) retry at most count times then errors out.

  3. Retry conditionally seq.retryWhen { errorObservable -> TriggerObservable in ... }

    This is the most powerful retry policy. For each stripe of errors & retries, the operator create a new error observable which emits current stripe of consecutive errors and pass it into the block paramter. The block paramter, according to the error sequence, return an appropriate triggering observable, the operator wait only the first element from the trigger observable then retry the source sequence.

    Usage #1 - Retry after incremental backoff delay

    seq.retryWhen { errorObservable -> Observable<T> in
      return errorObservable
        .enumerated()
        .flatMap({ index, error in
          if index < 3 {
            let delay = pow(2, (index + 1))
            return Observable<T>.timer(delay, scheduler: MainScheduler.instance)
          } else {
            throw error
          }
        })
    }

    Usage #2 - Retry after pre-condition met, like waiting till the network gets re-connected, authentication passed, permission granted.

    seq.retryWhen { errorObservable -> Observable<T> in
      return errorObservable
        .flatMap({ error in
          switch error {
          case .network:
            return ReachabilityService.reachByWiFi()
          case .unauthorized:
            return AuthenticationService.authenticate()
          case ...
            ...
          }
        })
    }

Notes:

If the observable is not Single like (i.e. emits more than one .next events), retry would cause duplicated events emitted again and again. Especially when work with startWith or concat operator, apply them after the retry would usually be a better idea.