Is it reasonable to throw an exception from an asynchronous method?

Viewed 1280

Developing in Java an asynchronous method with a CompletableFuture return type we expect the resulting CF to complete normally or exceptionally depending on whether that method succeeds or fails.

Yet, consider for instance that my method writes to an AsynchronousChannel and got an exception opening that channel. It has not even started writing. So, in this case I am tenting to just let the exception flow to the caller. Is that correct?

Although the caller will have to deal with 2 failure scenarios: 1) exception, or 2) rejected promise.

Or alternatively, should my method catch that exception and return a rejected promise instead?

3 Answers

I think both are valid designs. Datastax actually started their design with first approach, where borrowing a connection was blocking, and switched to fully async model (https://docs.datastax.com/en/developer/java-driver/3.5/upgrade_guide/#3-0-4)

As a user of datastax java driver I was very happy with the fix, as it changed the api to be truly non-blocking (even opening a channel, in your example, has a cost).

But I don't think there are right and wrong here...

IMO, option 1) makes the API harder to use because there will be two different paths for communicating errors:

  1. "Synchronous" exceptions, where the method ends the an exception being thrown.
  2. "Asynchronous" exceptions, where the method returns a CF, which completes with an exception. Note that it is impossible to avoid this case, because there will always be situations where the errors are only found after the asynchronous path has started (e.g. timeouts).

The programmer now has to ensure both these two paths are correctly handled, instead of just one.

It is also interesting to observe that the behaviour for both C# and Javascript is to always report exceptions thrown inside the body of an async function via the returned Task/Promise, even for exceptions thrown before the first await, and never by ending the async function call with an exception.

The same is also true for Kotlin's coroutines, even when using the Unconfined dispatcher

class SynchronousExceptionExamples {
    @Test
    fun example() {
        log.info("before launch")
        val job = GlobalScope.launch(Dispatchers.Unconfined) {
            log.info("before throw")
            throw Exception("an-error")
        }
        log.info("after launch")
        Thread.sleep(1000)
        assertTrue(job.isCancelled)
    }
}

will produce

6 [main] INFO SynchronousExceptionExamples - before launch
73 [main @coroutine#1] INFO SynchronousExceptionExamples - before throw
(...)
90 [main] INFO SynchronousExceptionExamples - after launch

Note as the exception occurs in the main thread, however launch ends with a proper Job.

It doesn't make a big difference from the callers point of view. In either case there will be visibility of the cause of the exception whether it it thrown from the method or from calling get() on the completable future.

I would perhaps argue that an exception thrown by the completable future should be an exception from the async computation and not failing to start that computation.

Related