Merging RxJava Observables considered harmful — Part I
The hidden cause of UndeliverableExceptions
Modern Android apps are frequently built atop RxJava (unless you’re ahead of the curve and have already migrated to Kotlin Flow). Among the many benefits of the reactive approach to programming is the ability to combine multiple, asynchronous processes, using patterns such as Combine, Zip, and Merge.
However, soon after you start introducing these patterns, I bet you will start to see UndeliverableExceptions in your Crashlytics logs, even though all your streams have an explicit error handler. What’s going on?
Quiz time
Let’s start with a short quiz. Can you guess the output of the following code?
a) “success”
b) “error 1”
That looks pretty straightforward, since our Completable
signals an error, so the correct answer is “error 1”.
Now a slightly more complex example — can you guess what the following code will print?
a) “success”
b) “error 1”
c) “error 2”
It looks like we are merging two streams that run on the IO scheduler and both signal an error. Which of them will be sent to the error handler?
The best answer on the question we asked would be “I don’t know”. And why is that? Simply because we’re now observing a non-deterministic behavior, which means that for the same input, the output can vary on different runs.
If you try running that code, you’ll sometimes see “error 1” printed while some others you’ll see “error 2”.
But more importantly, you’ll crash with an UndeliverableException
.
Let’s figure out what’s happening
First of all, what is an UndeliverableException
? The description of the error is pretty self-explanatory:
The exception could not be delivered to the consumer because it has already canceled/disposed the flow or the exception has nowhere to go to begin with.
This can happen in one of the following cases:
- The stream doesn’t have an associated error handler
- The stream has already succeeded
- The stream has already received an error
In our example, we explicitly define an error handler so it can’t be the first case. The stream also cannot succeed because both Completables signal an error, so it can’t be the second case either.
That means that the culprit has to be the third case in which the stream has already received an error and has been already terminated. Which kind of makes sense, since one of the errors we throw will dispose the stream. There is a key question to be answered though:
Why does the stream continue to run even though the first error it received should terminate it?
Can you take a closer look at the code and see if you can find the answer?
The answers lie in the scheduler! 💡
The IO scheduler is a pool of threads that run in parallel. In our case, we merge two streams that run in parallel and they both throw an error at the same time — the key here is the timing. The first error that is thrown will be caught by the error handler and the downstream will be disposed. The second stream will still throw an error because it won’t have been notified yet that it needs to be terminated, since the streams run in different threads. However, as the downstream is disposed, there’s no error handler to catch the error so we’ll crash with an UndeliverableException
.
Why is this important?
Our humble example reveals an important hidden flaw. We cannot safely merge streams that we want to run in parallel since the application will crash if they both signal an error at the same time. And how can they both signal an error simultaneously? Well, the most common scenario would be network issues, a.k.a. TimeoutException
s.
A real-world scenario
Imagine you’re working on a chat application — you’ll probably have a stream that gets the user’s latest profile and another stream that gets their messages. You’ll be calling each from different parts of the app, however, on startup you might want to call both so that you can pre-fetch the data and have the streams run in parallel to save time:
Completable.mergeArray(getProfile(), getMessages()).subscribe({
// Handle success
}, {
// Handle errors
})
There it is, we just created a 🐞! And a pretty hard to debug one. This call will crash with an UndeliverableException
if both streams signal an error at the same time, with the most common scenario being launching the app without an Internet connection enabled. Also, this issue doesn’t affect only Completables. The exact same crash will occur if we merge Singles using the Single.zip(…)
operator.
Up Next
Learn the approaches we followed to resolve the issue using TDD in the second part of the series.
More in this series
- Part I: The hidden cause of UndeliverableExceptions in RxJava ← you are here
- Part II: How to merge RxJava streams safely following TDD
- Part III: Implementing and verifying safeMergeArray
About the author
Stelios Frantzeskakis is a Senior Software Engineer for Perry Street Software, publisher of the LGBTQ+ dating apps SCRUFF and Jack’d, with more than 20M members worldwide.