Gather multiple async results in Kotlin Coroutines ignoring the exceptions with timeouts

Viewed 720

I have a Generator class that, basically, generates some data, like:

interface Generator {
    suspend fun generate(): String?
}

There are multiple implementations. Some of them may throw exceptions and some of them may took too long to generate the data:

class Faulty : Generator {
    override suspend fun generate(): String? {
        println("Faulty")
        throw IllegalArgumentException();
    }
}

class Lingering : Generator {
    override suspend fun generate(): String? {
        println("Lingering")
        delay(Duration.ofHours(1))
        return null
    }
}

But some implementations are worthy

class Good : Generator {
    override suspend fun generate(): String {
        println("Good")
        return "Goooood"
    }
}

What I need to do is to gather the data generated by a list of pre-configured generators, giving each of them a timeout for its generate and ignoring the exceptions (but logging them):

fun main() = runBlocking {
    val generators = listOf(Faulty(), Lingering(), Good())
    val results = supervisorScope {
        generators
                .map { generator ->
                    async(CoroutineExceptionHandler { context, exception ->
                        println(exception)
                    }) {
                        withTimeoutOrNull(5000) {
                            generator.generate()
                        }
                    }
                }
                .awaitAll()
                .filterNotNull()
    }

    println(results)
}

The problem is that this code fails with exception:

Faulty
Lingering
Good
Exception in thread "main" java.lang.IllegalArgumentException
 at Faulty.generate (File.kt:12) 
 at FileKt$main$1$results$1$1$2$1.invokeSuspend (File.kt:41) 
 at FileKt$main$1$results$1$1$2$1.invoke (File.kt:-1) 

Why doesn't the supervisorScope catch it? What am I doing wrong?

1 Answers

From the documentation of CoroutineExceptionHandler:

An optional element in the coroutine context to handle uncaught exceptions.

and

A coroutine that was created using async always catches all its exceptions and represents them in the resulting Deferred object, so it cannot result in uncaught exceptions.

so it follows that your async job doesn't emit an uncaught exception. The exception is rethrown by the awaitAll() call that happens later. You have put your uncaught exception handler only within your async context, so it will not be used.

Furthermore, children coroutines do not emit uncaught exceptions anyway. Their exceptions are delegated up to their root ancestor.

As explained here in the last section titled Exceptions in supervised coroutines, children of a supervisor scope must have a root coroutine that uses the handler.

What you can do is wrap the whole task in a launch block that uses the handler. For some reason it doesn't work to install the handler on runBlocking. Maybe that doesn't count as a root job?

fun main() = runBlocking{
    val job = GlobalScope.launch(CoroutineExceptionHandler { context, exception ->
        println(exception)
    }) {
        val generators = listOf(Faulty(), Lingering(), Good())
        val results =
            supervisorScope {
                generators
                    .map { generator ->
                        async {
                            withTimeoutOrNull(5000) {
                                generator.generate()
                            }
                        }
                    }
                    .awaitAll()
                    .filterNotNull()
            }

        println(results)
    }
    job.join()
}

But I think maybe the only reason you introduced the CoroutineExceptionHandler was for ignoring exceptions. That strategy won't work, because the handler only deals with uncaught exceptions, meaning it's too late to recover. The job has already failed at that point. You will have to wrap your generate() call within the async block in a try/catch or runCatching.

Related