Search code examples
darterror-handlingasync-awaittry-catchvoid

Handling errors in void Dart function marked async


I am designing an API and would like to handle a situation when a user marks a void function async by mistake.

The simplified code is listed below:

void test(void Function() run) {
  try {
    for (var i = 0; i < 3; i++) {
      print(i);
      run();
    }
  } catch (e) {
    print(e);
  } finally {
    print('in finally block.');
  }
}

void errorThrower(String message) {
  print('in errorThrower ');
  throw message;
}

void main(List<String> args) {
  test(() async{  // <-------- marked async by API user's mistake
    print('in run ...');
    errorThrower('error thrown in test');
  });
}

If the function is not marked async the program output is as expected and the error is thrown, caught, handled, and the finally block is executed:

$ dart main.dart
0
in run ...
in errorThrower 
error thrown in test
in finally block.

However, if the function is marked async the console output is:

$ dart main.dart 
0
in run ...
in errorThrower 
1
in run ...
in errorThrower 
2
in run ...
in errorThrower 
in finally block.
Unhandled exception:
error thrown in test
#0      errorThrower (file:///home/dan/WORK/DartProjects/benchmark_runner/bin/throws_test.dart:16:3)
#1      main.<anonymous closure> (file:///main.dart:22:5)
#2      test (file:///main.dart:5:10)
#3      main (file:///main.dart:20:3)
#4      _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:294:33)
#5      _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:189:12)

What is going on? I tried a debugger, the program steps into run() then errorThrower and then the loop continues. It looks like an unawaited future error but I don't know how to catch and handle it since a void function cannot be awaited.

Is there any way I can catch and handle the error without changing the signature o f run()?


Solution

  • What is going on?

    Functions marked with async are automatically transformed so that return values and thrown objects are wrapped in Futures. Failed Futures must be handled by registering a callback with Future.catchError, by using using await on the Future within a try block (which is syntactic sugar for registering a Future.catchError callback), or by setting up a Zone error handler (which I'll consider to be outside the scope of this answer). Conceptually you can consider a failed Future as throwing an exception from the Dart event loop, after your synchronous test function has already left its try-catch block.

    Is there any way I can catch and handle the error without changing the signature of run()?

    A T Function() is a subtype of (is substitutable for) void Function(). Therefore at compile-time you cannot prevent a Future<void> Function() from being passed where a void Function() is expected. This is also why things like Iterable.forEach can't prevent asynchronous callbacks (even though it's almost always a bad idea) and would need support from the linter.

    You could add a runtime check:

    void test(void Function() run) {
      if (run is Future<void> Function()) {
        throw ArgumentError("test: Cannot be used with asynchronous callbacks."); 
      }
      ...
    }
    

    Or if you just want to swallow the error:

    void test(void Function() run) {
      ...
      if (run is Future<void> Function()) {
        run().catchError((e) => print(e));
      } else {
        run();
      }
      ...
    }
    

    Note that if you want test to wait for run to complete (successfully or not), then test itself would need to be asynchronous and would need to return a Future. At that point, you might as well always assume that run might be asynchronous and unconditionally await it:

    Future<void> test(FutureOr<void> Function() run) async {
      try {
        ...
        await run();
      ...
    }