Chapters

Hide chapters

Reactive Programming with Kotlin

Second Edition · Android 10 · Kotlin 1.3 · Android Studio 4.0

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section II: Operators & Best Practices

Section 2: 7 chapters
Show chapters Hide chapters

12. Error Handling in Practice
Written by Alex Sullivan & Junior Bontognali

Life would be great if we lived in a perfect world, but unfortunately things frequently don’t go as expected. Even the best RxJava developers can’t avoid encountering errors, so they need to know how to deal with them gracefully and efficiently. In this chapter, you’ll learn how to deal with errors, how to manage error recovery through retries, or just surrender yourself to the universe and let the errors go.

Getting started

The app you’ll be creating for this chapter is a weather app. It will allow a user to type in a city name and see the weather for that city. It will also allow the user to use their current location as the trigger to fetch weather details. To accomplish all of this, you’ll use the OpenWeatherMap API.

Before continuing, make sure you have a valid OpenWeatherMap API Key http://openweathermap.org. If you don’t already have a key, you can sign up for one at https://home.openweathermap.org/users/sign_up.

Once you’ve completed the sign-up process, visit the dedicated page for API keys at https://home.openweathermap.org/api_keys and generate a new one.

Open the starter project in Android Studio. In the starter project, open the WeatherApi.kt file, take the key you generated above and replace the placeholder in the following location:

val apiKey =
    BehaviorSubject.createDefault("INSERT_YOUR_API_KEY_HERE")

Once that’s done, run the app. When prompted, grant the app permission to use the device’s location. After you grant permission, you’ll see the following screen:

Try entering some text into the top EditText box at the top of the screen where it says Current Location. You should see the weather details change. You should also see a nice image in the center of the app indicating what the current weather is. For example, if it’s snowing outside, you’ll see a cloud with some snow underneath. Brrrr!

If you instead see nothing show up, then that might mean you hit an error. Make sure the API key you entered is valid and that the city name you entered is a real city. If you just created your account, make sure you check your email to confirm your email address. You’ll have to re-run the app if it did experience an error when making the initial API call. Not a great user experience, right?

This good news is you’re going to fix that user experience!

Before you start diving into managing errors, it’s a good idea to get acquainted with the code for the app. Open the WeatherViewModel and look around. It takes one argument:

private val lastKnownLocation: Maybe<Location>

lastKnownLocation is a Maybe representing the last known location of the user. If you’re interested in learning about how the app creates a Maybe out of the last known location, take a look at the lastKnownLocation method in the X.kt file.

In addition to the lastKnownLocation constructor parameter, WeatherViewModel exposes two public methods that WeatherActivity uses to notify the ViewModel of clicks on the location button and text change events:

fun locationClicked() = locationClicks.onNext(Unit)

fun cityNameChanged(name: CharSequence) =
  cityNameChanges.onNext(name)

These methods pipe their relevant values into a couple of PublishSubjects that are defined at the top of the file:

private val locationClicks = PublishSubject.create<Unit>()
private val cityNameChanges =
  PublishSubject.create<CharSequence>()

Now you can easily represent users actions as streams. Hooray!

In the init block, WeatherViewModel uses the Observable.merge function to merge the two subjects built from locationClicks and cityNameChanges to create a final Observable that will emit Weather updates to the weatherLiveData object.

Notice that the locationObservable declaration uses the onErrorReturnItem() method to default to an empty instance of the Weather object if the stream emits any errors.

Sure, it’s a nice, compact, single line, but it doesn’t make for a great UX. You can do way better!

Managing errors

Errors are an inevitable part of any app. Unfortunately, no one can guarantee an app will never error out, so you will always need some type of error-handling mechanism.

Some of the most common errors in apps:

  • No internet connection: This is quite common. If the app needs an internet connection to retrieve and process the data, but the device is offline, you need to be able to detect this and respond appropriately.
  • Invalid input: Sometimes you require a certain form of input, but the user might enter something entirely different. Perhaps you have a phone number field in your app, but the user ignores that requirement and enters letters instead of digits.
  • API error or HTTP error: Errors from an API can vary widely. They can arrive as a standard HTTP error (response code from 400 to 500), or as errors in the response, such as using the status field in a JSON response.

In RxJava, error handling is part of the framework and it handles them in two ways:

  • onError: Return a default value.

  • Retry: Retry for a limited (or unlimited!) number of times.

The starter version of this chapter’s project doesn’t have any real error handling. All the errors are caught with a single onErrorReturnItem() operator that returns a dummy version of the weather. This might sound like a handy solution, but there are better ways to handle this in RxJava. A consistent and informative error-handling approach is expected in any app.

At this point, it’s worth noting that there’s nothing magical about how RxJava propagates errors. For example, if you’re in an operator and you want to signal an error that ends the rest of the Observable chain, all you have to do is throw an error just like you would in normal Kotlin code. That error will then propagate down to the subscriber, who may or may not handle it.

Handling errors with catch

Now that you know about the types of errors you can encounter, it’s time to see how to handle those errors. The most basic way is to use one of the onError. operators. The onError operators works much like the try-catch flow in plain Kotlin.

When an Observable performs, and if something goes wrong, you can return an event that wraps an error. In RxJava there are two main operators to catch errors. The first is onErrorResumeWith().

onErrorResumeWith() allows you to return a different Observable when your Rx chain encounters an error. The chain will then switch to emitting items from the Observable passed to onErrorResumeWith() whenever it encounters an error. Here’s the method signature, written in Java:

public final Observable<T> onErrorResumeWith(
  @NonNull ObservableSource<? extends T> fallback
)

Sometimes you may want to return a different type of Observable depending on the error. In that scenario, you can use onErrorResumeNext(). Instead of directly taking an Observable, onErrorResumeNext() takes in a function. That function is itself called by the RxJava library with an error whenever it encounters an error. You then return an Observable from the function, allowing you to customize what type of Observable you return based off the type of error you encountered.

If you can’t quite see where you’d use this option, think about a caching strategy that returns a previously cached value if the Observable errors out. With this operator, you can then achieve the following flow:

The onErrorResumeNext() in this case returns values that were previously available and that, for some reason, aren’t available anymore.

The second operator is onErrorReturnItem():

public final Observable<T> onErrorReturnItem(final T item)

This operator is how the app is currently handling errors.

onErrorReturnItem() ignores errors and just returns a pre-defined value, as opposed to onErrorResumeWith() which returns a new Observable to switch to. Just like onErrorResumeWith(), there’s a version of onErrorReturnItem() that takes in a function to produce an item given an error.

Avoiding a common pitfall

Errors propagate through the Observable’s chain, so an Observable will forward an error that happens at the beginning of an Observable chain to the final subscription if there aren’t any handling operators in place.

What does this mean exactly? When an Observable errors out, error subscriptions are notified and all subscriptions are then disposed. So when an Observable errors out, the Observable is essentially terminated and any events following the error will be ignored. This is a rule of the Observable contract.

You can see this plotted below on a timeline. Once the network produces an error and the Observable sequences errors out, the subscription updating the UI will stop working, effectively preventing future updates:

To translate this into the actual app, remove the .onErrorReturnItem(Weather.empty) line inside the textObservable in WeatherViewModel. Then update the subscribe() line in the Observable.merge() chain at the bottom of the init block to catch the error:

.subscribeBy(
  onError = {
    Log.e("Weather", "Error: $it")
  },
  onNext = {
    weatherLiveData.postValue(it)
  }
)

Run the app and type in a city that doesn’t exist. Something gibberish like asdf works just fine. You should see something similar to this in the Logcat console:

E/Weather: Error: java.lang.IllegalStateException: Not Found

That Not Found message is the tip of a 404 iceberg. You will also notice that the search stops working after that 404! Even if you then enter a valid city name, no new weather data will show. That’s because the Observable has terminated. Not exactly the best user experience, is it?

Even if you use the onErrorReturnItem() operator, the Observable will still end. Instead of calling its observer’s onError() block, it will instead emit the item supplied to onErrorReturnItem() and call the observer’s onComplete() method. One common mistake made by people who are new to RxJava error handling is that they expect the Observable to keep emitting items even if it encounters an error.

Catching errors

Now, revert the changes you just made so that the Observable.merge() call is using a single line subscribe() and the textObservable is again returning an empty instance of Weather if it encounters an error.

You’re going to update the app so that, instead of returning an empty instance of a Weather object when encountering an error, you’ll look for a cached value of that city’s weather to use.

Add the following instance variable below the disposables val:

private val cache = mutableMapOf<String, Weather>()

Your cache will be a simple Map<String, Weather>. The key to the map will be the name of the city and the value will be the last Weather instance the app pulled down.

It’s time to start filling up your cache.

Update the textObservable definition by replacing the existing flatMapSingle() call with the following:

.flatMapSingle { cityName ->
  WeatherApi.getWeather(cityName.toString())
    .doOnSuccess { cache[cityName.toString()] = it }
}

Now, every time you get the weather for a particular city, you’ll store the results of that network request in the cache. Now, how do you actually pull items from the cache?

To return a cached value in the event of an error, you’ll replace the .onErrorReturnItem(Weather.empty) operator in the textObservable declaration with something a bit more robust.

First, create a new function below the init block in WeatherViewModel. It will have a compiler error until you fill in the body in the next step:

private fun getWeatherForLocationName(
    name: String
): Single<Weather> {
}

This function will do the heavy lifting of actually fetching a Weather object for a given city name and will replace the existing flatMapSingle() call.

Now, add the following to the body of getWeatherForLocationName():

return WeatherApi.getWeather(name)
  .doOnSuccess { cache[name] = it }

Just like before, you’re using the doOnNext() operator to update your cache with the latest and great data.

Now, chain the following after the doOnNext() call:

.onErrorReturn {
  cache[name] ?: Weather.empty
}

Here’s where the magic happens. You’re using the onErrorReturn() operator to supply a default item whenever the Observable encounters an error. If you have a cached value of the city’s weather, you’ll use that value. Otherwise, you’ll return the Weather.empty value.

Now that you have the onErrorReturn() operator going in the getWeatherForLocationName() method, you can remove the existing onErrorReturnItem() operator from the textObservable declaration and start using getWeatherForLocationName(). Replace the flatMapSingle() block in the textObservable declaration with the following:

.flatMapSingle { getWeatherForLocationName(it.toString()) }

To test this, run the app and input three or four various cities such as “London,” “Boston,” and “Amsterdam,” and load the weather for these cities. After that, disable your internet connection and perform a search for a different city, such as “Barcelona”; you’ll receive an error and the screen will go blank.

Leave your internet connection disabled and search for one of the cities you just retrieved data for, and the app should return the cached version.

This is a very common usage of onErrorReturn(). You can definitely extend this to make it a general and powerful caching solution.

Retrying on error

Catching an error is just one way you can handle errors in RxJava. You can also handle errors with retry().

When you use a retry() operator and an Observable errors out, the Observable will repeat itself. It’s important to remember that retry() means repeating the entire task inside the Observable.

This is one of the main reasons it’s recommended to avoid side effects that change the user interface inside an Observable, as you can’t control who will retry it!

Retry operators

There are three basic types of retry() operators. The first one is the most basic:

public final Observable<T> retry()

This operator will repeat the Observable an unlimited number of times until it returns successfully. For example, if there’s no internet connection, this would continuously retry until the connection was available.

This might sound like a robust idea, but it’s resource-heavy, and it’s seldom recommended to retry() for an unlimited number of times if there’s no valid reason for doing it.

To test this operator, comment the complete onErrorReturn() block in the getWeatherForLocationName() method you recently created:

//.onErrorReturn {
//  cache[name] ?: Weather.empty
//}

In its place, insert a retry():

.retry()

Next, run the app, disable the internet connection and try to perform a search. You’ll see a lot of output in Logcat, showing the app is trying to make the requests. After a few seconds, re-enable the internet connection, and you’ll see the result displayed once the app has successfully processed the request.

Note: Remember that retry() will keep retrying a failed call forever. That means that if you accidentally searched for an invalid city, the app will forever be stuck trying to get the weather for that city! If you’re not seeing the results you expect, take a look at the Logcat output. You should see a line that looks something like this GET https://api.openweathermap.org/data/2.5/weather?q=Boston&appid=<appId>&units=metric. Make sure the q=MyCity parameter is what you’d expect!

The second operator lets you vary the number of retries:

public final Observable<T> retry(long times)

With this variation, the Observable is repeated for a specified number of times. To give it a try, do the following:

  • Remove the retry() operator you just added.
  • Uncomment the previously commented code block.
  • Just before onErrorReturn, insert a .retry(3).

The complete getWeatherForLocationName method should now look like this:

private fun getWeatherForLocationName(name: String): Single<Weather> {
  return WeatherApi.getWeather(name)
    .doOnSuccess { cache[name] = it }
    .retry(3)
    .onErrorReturn {
      cache[name] ?: Weather.empty
    }
}

If the Observable is producing errors, it will be retried three times in succession. If it errors a fourth time, that error will not be handled and execution will move on to the onErrorReturn() operator.

Run the app and try searching for the weather with the internet connection disabled again. This time, there should only be four requests made before it stops trying: one initial and three retries.

Advanced retries

The last operator, retryWhen(), is suited for advanced retry situations. This error handling operator is considered one of the most powerful:

public final Observable<T> retryWhen(
    Function<? super Observable<Throwable>,
        ? extends ObservableSource<?>> handler
)

retryWhen() takes in a function that when given an Observable of throwables returns a new Observable. That new Observable acts as a type of “trigger” for retryWhen(). Whenever it emits a value, retryWhen() will retry the original source Observable. Whenever that new trigger Observable calls onComplete() or onError(), retryWhen() will then signal to the original source Observable that the Observable has completed or an error has occurred.

retryWhen() is one of the most complicated operators you will experience in this book, so don’t worry if the above was confusing.

This is the operator you will include in the current application, using a smart trick to retry if the internet connection is not available, or if there’s an error from the API. The goal is to implement an incremental back-off strategy if the original search errors out. The desired result is as follows:

subscription -> error
delay and retry after 1 second

subscription -> error
delay and retry after 2 seconds

subscription -> error
delay and retry after 3 seconds

subscription -> error
delay and retry after 4 seconds

It’s a smart yet complex solution. In regular imperative code, this would imply the creation of some abstractions, perhaps using AsyncTasks with Handler to run a loop and checking if the task failed or not. But with RxJava, it’s a small (albeit complex) block of code.

Before creating the final result, consider what the inner Observable (the trigger Observable) should return. Since retryWhen() only looks at the fact that trigger Observable has emitted and not what it has emitted, the type can be ignored, and the trigger can be of any type.

The goal is to retry four times with a given sequence of delays. First, inside WeatherViewModel, add a new instance variable representing the maximum number of attempts to get the weather the app should make:

private val maxAttempts = 4

After this many retries, the error should be forwarded on.

Now, replace .retry(3) in the getWeatherForLocationName() method with the following. There will be a compiler error until you fill in the lambda:

.retryWhen { errors: Flowable<Throwable> ->

}

You’ll learn more about Flowables in Chapter 14, “Flowables & Back Pressure”, but for now you can think of a Flowable in the exact same way you think of an Observable. Here’s the flow that you want to achieve: Whenever errors emits a value, that means a new error has been emitted from the original source Observable. In this scenario you want to emit some value (it doesn’t matter what value) after one second, then two seconds, then three seconds, and then four seconds.

So first things first: You need a way to emit items only after a certain amount of time. Luckily, you learned about Observable.timer() in the previous chapter! In case you need a quick recap, Observable.timer() takes in an amount of time and emits 0L after that amount of time. Then it finishes. Perfect for your needs here!

Add the following code in the currently empty lambda being supplied to the retryWhen() operator:

errors.flatMap { Flowable.timer(1, TimeUnit.SECONDS) }

You need to use a Flowable instead of an Observable here to satisfy the RxJava type system. Again, for now you can think of a Flowable in the same way you think of an Observable. Now, every time the errors Flowable emits, meaning a new error has been encountered, you’ll send a trigger value (in this case 0L) after one second, telling retryWhen() to retry the source Observable.

That’s great and all, but there are two problems:

  1. You’re emitting after one second every time. Remember that the goal is to create a sort of back-off strategy in which you wait longer after each network attempt.
  2. Every time an error is produced, Flowable.timer() will send out an onNext() value, triggering another retry. That means that this code effectively retries the network request infinitely. No good!

So you need a way to signal to Flowable.timer() that it needs to wait longer and that after a certain number of retries it should just give up.

One way you could achieve the first part about waiting longer is by using flatMap() to convert an Observable that emits increasing values into a timer.

Replace the code you just added with the following:

errors
  .scan(1) { count, _ ->
    count + 1
  }
  .flatMap { Flowable.timer(it.toLong(), TimeUnit.SECONDS) }

Recall that the scan() operator works by taking in an initial seed value and a function that, when given an accumulating value and the item emitted by the source Observable, returns a new accumulated value. You can use the scan() operator to begin counting up integers and then use flatMap() to convert those increasing integers into timers by again using Flowable.timer().

In this manner, you’re now waiting longer and longer between network requests.

Last but not least, you need to make sure that you’re only retrying the network request a certain number of times. This can be done when combined with the scan() method you just wrote. Replace the body of the scan() lambda, which currently contains this code:

.scan(1) { count, _ ->
  count + 1
}

With the following:

.scan(1) { count, error ->
  if (count > maxAttempts) {
    throw error
  }
  count + 1
}

Now, once you’ve tried more than maxAttempts times, you’ll throw the error produced by the errors Observable, indicating to retryWhen() that you’re done retrying and it’s time to give up.

Now, build and run. Disable your internet connection and perform a search. If you look at the Logcat logs, you should see OkHttp making network requests after one second, then two seconds, then three seconds and so on until you hit the maximum number of retries you’ve specified with maxAttempts.

Here’s a good visualization of what’s going on:

You’ve only scratched the surface of using retryWhen(). To get even fancier, you can inspect the types of errors that you’re seeing coming through the errors Observable to execute different logic depending on what error you’re seeing. We won’t go that deep into the rabbit hole in this book, but it’s worth exploring on your own!

Errors as objects

As you go deeper into the world of reactive and functional programming, it can become painful to keep dealing with Throwables and exceptions for expected results. It often makes more sense to treat an exception as something that your program could not have imagined, and thus does not know how to handle.

For instance, you know that if you type in an invalid city that the OpenWeatherMap API will return a 404 status code. Should that really be modeled as an exception? You know it may happen, and as a matter of fact you know it will happen.

Everyone mistypes every now and then, so people are bound to type in an invalid city name in the app. It often makes more sense to model behavior that you know it can happen, but may not be the desired path as an object to be handled later on instead of an exception.

Modeling a network error

Open WeatherApi and look at the bottom of the file. You should see two unused sealed classes:

sealed class NetworkResult {
  class Success(val weather: Weather) : NetworkResult()
  class Failure(val error: NetworkError) : NetworkResult()
}

sealed class NetworkError : Exception() {
  object ServerFailure : NetworkError()
  object CityNotFound : NetworkError()
}

You’ll soon update the networking portion of the app such that all network requests that successfully get to the server and come back are mapped to a NetworkResult object. Remove the existing weatherResponseObservable() and replace it with the following:

private fun mapWeatherResponse(
    response: Response<WeatherNetworkModel>
): NetworkResult {
  return when (response.code()) {
    // 1
    in 200..300 -> {
      val body = response.body()
      if (body != null) {
        NetworkResult.Success(
            body.toWeather().copy(icon = iconNameToChar(
                body.weather.first().icon)))
      } else {
        NetworkResult.Failure(NetworkError.ServerFailure)
      }
    }
    // 2
    in 400..500 -> NetworkResult.Failure(
        NetworkError.CityNotFound)
    // 3
    else -> NetworkResult.Failure(NetworkError.ServerFailure)
  }
}

That’s a big chunk of code! Here’s a breakdown:

  1. The retrofit interface you’re using specifies a Response object as a return type for your network calls. That Response object has a status code attached to it that you’re inspecting. If the status code is anywhere in the 200–300 range, that means the call was successful. In this scenario you’re attempting to pull out the data from the response and construct a NetworkResult.Success object. If you can’t pull the data out, you’re instead returning a NetworkResult.Failure with a NetworkError of ServerFailure.
  2. You’re interpreting any error in the 400–500 range as meaning that the city couldn’t be found. This isn’t strictly true, but you’ll update it later on to be closer to the truth.
  3. If you see any response code that’s over 500, you’re returning a generic server failure, since that usually means something has gone wrong on the server’s end.

Now update the two getWeather() calls to produce an Observable<NetworkResult> and use the new mapWeatherResponse() method:

fun getWeather(city: String): Single<NetworkResult> {
  return weather.getWeather(city, apiKey.value)
      .map(this::mapWeatherResponse)
}

fun getWeather(location: Location): Single<NetworkResult> {
  return weather.getWeather(
      location.latitude, location.longitude, apiKey.value)
      .map(this::mapWeatherResponse)
}

Nice! You’ve updated your API.

Now, open the WeatherViewModel class. Since you changed the return type of your network Observable from Single<Weather> to Single<NetworkResult>, there are quite a few errors, here.

First off, update the onErrorReturnItem() call in both the locationObservable and textObservable declaration from this:

.onErrorReturnItem(Weather.empty)

To a version that returns a NetworkResult:

.onErrorReturnItem(
    WeatherApi.NetworkResult.Success(Weather.empty))

Next up, change the return type of getWeatherForLocationName() to the following:

Single<WeatherApi.NetworkResult>

Remove the doOnSuccess() operator from getWeatherForLocationName(). You’ll re-implement the caching strategy in a moment.

Now, replace the onErrorReturn() operator with a version that uses the NetworkResult class:

.onErrorReturn {
  val cachedItem = cache[name] ?: Weather.empty
  WeatherApi.NetworkResult.Success(cachedItem)
}

Only one change left! Now that you’re returning a NetworkResult instead of a Weather object, you need to handle that new object in the subscribe block of your merged Observable in the init block at the top of the class.

Add the following method to the WeatherViewModel class:

private fun showNetworkResult(
    networkResult: WeatherApi.NetworkResult
) {
  when (networkResult) {
    // 1
    is WeatherApi.NetworkResult.Success -> {
      cache[networkResult.weather.cityName] =
          networkResult.weather
      weatherLiveData.postValue(networkResult.weather)
    }
    // 2
    is WeatherApi.NetworkResult.Failure -> {
      when (networkResult.error) {
        WeatherApi.NetworkError.ServerFailure ->
            errorLiveData.postValue("Server Failure")
        WeatherApi.NetworkError.CityNotFound ->
            errorLiveData.postValue("City Not Found")
      }
    }
  }
}

The above code may seem beefy, but it’s not too bad when broken down:

  1. You’re checking what the actual type of your networkResult is. If it’s a successful call to get the weather, you’re updating your cache with the new weather and emitting it in your weatherLiveData.
  2. If it’s a failure, you’re checking what the type of failure is and sending a message in your errorLiveData to notify the user of the issue.

Last but not least, replace the subscribe() block on your merged Observable at the bottom of the init block with the following:

.subscribe(this::showNetworkResult)

You’re now ready to rock! Run the app and enter an invalid city name. You should see a snackbar appear above the keyboard indicating the city name was invalid.

Challenges

Challenge 1: Reacting to an invalid API key

Recall that, earlier in the chapter, you started using the NetworkResult object to encapsulate both success and errors from the network. You’re currently interpreting values between 400 and 500 as “city not found” errors, but that’s not actually the case.

A 401 error means that the auth token that you’re using is invalid. Since this project comes with an invalid API key by default, it would be wise to handle this case specifically and let the user know. For this:

  1. Update the project so that there’s one more possible NetworkError called InvalidKey.
  2. mapWeatherResponse() in WeatherApi.kt should return a NetworkFailure with the InvalidKey error if it encounters a 401 status code.
  3. Update showNetworkResult() in WeatherViewModel to handle the new error type.

To test your implementation, try hitting the key icon in the bottom-right corner of the app and entering an invalid API key. Then search for a city and see if your new error shows up.

Challenge 2: Use retryWhen on restored connectivity

In this challenge you need to handle the condition of an unavailable internet connection.

To start, take a look at connectivityStream() in the X.kt file. Given a Context, it will return an Observable<NetowrkState> indicating that the network is connected or disconnected. For this:

  1. You’ll need to pass an instance of this connectivity stream into the WeatherViewModel class.
  2. You’ll need to update WeatherViewModel to take a new argument of type Observable<NetworkState>.
  3. You can then pass in an Observable in the WeatherActivity ViewModelProviderFactory code by using the connectivityStream() function.

Once these things are done, extend the retryWhen() handler to handle the connectivity situation. Remember that when the internet connection is up, you have to fire a retry.

To achieve this:

  1. Update the lambda in the retryWhen() block in WeatherViewModel.kt.
  2. You’ll want to use the flatMap() method on the errors Observable. In the flatMap() block you’ll want to check what type of error is being emitted. If the error is an UnknownHostException you know the error is being caused by a lack of internet.
  3. In that case, you’ll want to return the connectivityStream Observable but filtered so that it only emits when the network state changes to CONNECTED. Otherwise, you’ll want to use the existing logic to slowly back off repeated retries.

The final goal is to have the system automatically retry once the internet is back, if the previous error was due to the device being offline.

As always, you can peek into the challenges folder and see the solution provided.

Key points

  • Errors are an inevitable part of any app. You will always need some type of error-handling mechanism.
  • No internet connection is a common error. If the app needs an internet connection to retrieve and process the data, but the device is offline, you need to be able to detect this and respond appropriately.
  • Invalid input is a common error. Sometimes you require a certain form of input, but the user might enter something entirely different. Perhaps you have a phone number field in your app, but the user ignores that requirement and enters letters instead of digits.
  • API error or HTTP error is a common error. Errors from an API can vary widely. They can arrive as a standard HTTP error (response code from 400 to 500), or as errors in the response, such as using the status field in a JSON response.
  • In RxJava, error handling is part of the framework and can be handled in two ways: onError (return a default value) and retry (Retry for a limited or unlimited number of times).

Where to go from here?

In this chapter, you were introduced to error handling using retry() and onErrorReturn(). The way you handle errors in your app really depends on what kind of project you’re building. When handling errors, design and architecture come in play, and creating the wrong handling strategy might compromise your project and result in re-writing portions of your code.

You should spend some time playing with retryWhen(). It’s a non-trivial operator, so the more you play with it, the more you’ll feel comfortable using it in your applications.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.