Say I'm producing a finite stream of messages as a Flow<Message>
. I loop over some source of messages, and, when there are no more messages, break out of the loop to finish the flow normally.
val messages = flow {
while (source.hasNext()) {
emit(source.next())
}
println("Finished sending all the messages")
}
The consumer simply calls collect
to receive each message in turn until the flow is finished.
messages.collect { doSomethingWith(it) }
println("All the messages have been processed")
But what happens when I need to tell the caller why the flow stopped? For example, let's say the message producer requires authentication, and I have to deal with token expiry. Now there are two reasons the flow might be terminated, and so my producer looks like this:
val messages = flow {
while(source.hasNext()) {
if (authentication.isExpired) {
break
}
emit(source.next())
}
println("Finished sending all the messages")
}
The problem with this is that the consumer doesn't know why the flow finished. Did the messages simply run out, or do they need to re-authenticate and continue fetching messages? Now, I can think of two possible ways to handle it, neither of which I like.
Exceptions
An easy solution would be to signal the 'abnormal' flow termination with an exception, like this:
val messages = flow {
while (source.hasNext()) {
if (authentication.isExpired) {
throw AuthenticationExpiredException()
}
emit(source.next())
}
println("Finished sending all the messages")
}
That's great, because now the consumer can use a try
/catch
to determine why the flow was terminated:
try {
messages.collect { doSomethingWith(it) }
println("There are no more messages")
} catch (e: AuthenticationExpiredException) {
println("The auth token expired")
}
But using exceptions like this is absolutely not something I want to do. In his article on Kotlin and Exceptions, Kotlin libraries lead Roman Elizarov explicitly tells us:
don’t use exceptions to return a result value, [and] avoid
try
/catch
in general application code.
It's a great article and there are many reasons I agree with it, but one critical reason for wanting to not use exceptions is that it introduces behaviour that isn't type checked. If I use my AuthenticationExpiredException
, then without reading the code, the caller of the flow doesn't know that they have to catch and handle it. The code will compile just fine without the try
/catch
, and will fail at runtime. That's an intentional language design choice in Kotlin: exceptions aren't checked at compile time, because they're simply not intended to be used in this way.
Sealed classes
A great way to return different types of results elsewhere in Kotlin is to use sealed classes. So I might write something like this:
sealed class FlowResult<out T> {
data class Value<T>(val value: T): FlowResult<T>
object: NoMoreMessages: FlowResult<Nothing>()
object: AuthenticationExpired: FlowResult<Nothing>()
}
val messages = flow {
while(source.hasNext()) {
if (authentication.isExpired) {
emit(AuthenticationExpired)
break
}
emit(Value(source.next()))
}
emit(NoMoreMessages)
}
This is much nicer, because now the two possible close reasons are treated equally. The consumer can use a when
block to handle the different results, and will get a compilation error if they don't include a branch that handles AuthenticationExpired
.
The problem I have with this approach is that I can't enforce the rule that the "failure" results must always be the final item in the flow. For example, say I made a mistake and accidentally missed out the break
from my loop.
val messages = flow {
while(source.hasNext()) {
if (authentication.isExpired) {
emit(AuthenticationExpired)
}
emit(Value(source.next()))
}
emit(NoMoreMessages)
}
Now I'll end up emitting the AuthenticationExpired
value without actually terminating the flow. It'll just continue emitting messages. This could lead to all manner of bugs and security problems, but it will compile just fine.
It feels like what I'm really looking for is a mechanism whereby collect
could return a value, like this:
val closeReason = messages.collect { doSomethingWith(it) }
when (closeReason) {
...
}
Is there a way I can achieve something like that? Or is there another approach that gets around the problems I've described?