DEV Community

Artem Goncharov
Artem Goncharov

Posted on

Three things you didn’t know about exception handling in Dart and Flutter

Sorry for the clickbait title of the article, I’m experimenting with different styles… But I’d like to not disappoint you and will provide all those 3 things :)

What is an exception?

There are two types of exception definition:

  • An exception is an inability of an operation to fulfill its contract
  • An exception is an unexpected behavior

It seems that the first definition can include the second one as the unexpected behavior can be one of the reasons why the operation couldn’t fulfill its contract. The other reasons could include more or less expected problems — like connection loss, corruption of the file, and so on.

As an example let’s consider this function:

Future<Money> getRemainingAmount({Account account}) async {
  final response = await backend.getAcccountInfo(account);
  return response.remainingAmount;
}
Enter fullscreen mode Exit fullscreen mode

This function returns the remaining money in our account. In order to do this, the function should hit our backend and request the account balance and this operation can fail because of various reasons — the network is down, the server is down, the contract between frontend and backend was changed, etc… All those reasons lead to an inability of our operation to fulfill its contract. However, all of those problems can be foreseen and we can add them to the contract. The only thing we can’t expect is unexpected behavior :). For instance, we anticipated all the problems except one: when backend in some cases returns text “negative” instead of the balance of our account — so this becomes unexpected for us (it’s a completely artificial example but just to give you an idea).

The more we know about our system the less chance some unexpected behavior occurs.

The next question is what we are going to do in all those exceptional cases. Do we want to react differently for each case or maybe we don’t care much if the server is down or returns some unexpected data? It’s the question of our system design but anyway we need a way to catch those exceptions separately or as a general exceptional situation so that we can respond accordingly.

Exceptions handling in different languages

The above considerations were taken into account differently in different programming languages. Let’s consider three approaches that Go, Rust and Dart use to handle exceptions (I'm not an expert in Go and Rust so feel free to comment if you found a problem with the code).

Dart:

try {
  final remaingAmount = await getRemainingAmount(account: account);
  displayAccountAmount(remaingAmount);
} on SocketException catch (_) {
  displayNoConnectionError();
} catch (e, s) {
  logException(exception: e, stackTrace: s);
  rethrow;
}
Enter fullscreen mode Exit fullscreen mode

Go:

func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("math: square root of negative number")
    }
    // implementation
}

f, err := Sqrt(-1)
if err != nil {
    fmt.Println(err)
}
Enter fullscreen mode Exit fullscreen mode

Rust:

use std::fs::File;
fn main() {
   let f = File::open("main.jpg");   // main.jpg doesn't exist
   match f {
      Ok(f)=> {
         println!("file found {:?}",f);
      },
      Err(e)=> {
         println!("file not found \n{:?}",e);   //handled error
      }
   }
   println!("end of main");
}
Enter fullscreen mode Exit fullscreen mode
  1. The most common approach which was used in C++ and is being used in Dart is to pay attention mainly to the happy path. So the code is rather easy to read and it’s not contaminated with many error branches. On the other side, it’s not that clear which block of code can cause an error and what kind of error it could be. For instance, in the above example, if you look inside the function getRemainingAmount you, most likely, will not be able to say if it would throw an exception or not.
  2. In Go it’s all the way around, the main focus is on error handling. So a function, besides a value, should return an error that has to be handled right after a function call. If the function can’t produce an error it should return a result only, and, it will clearly indicate that function is error-free. There is no way to silently ignore an error in a hope that upstream code will handle it — sure thing you can ignore an error but that error will not be automatically propagated to the upstream, you have to do it explicitly.
  3. In Rust, there is the third way of dealing with exceptions/errors. It looks similar to Go: function can return Either type with value OR error and then you can use pattern matching to understand if it’s value or error and what kind of error was returned. It’s not mandatory but there is no other way to throw an exception (except panic but it’s another story) so that you probably want to return Either type from all the functions that can cause errors. Code looks really readable and you can’t just pass exception/error upstream without handling it (only if the exception type is exactly the same as in an upstream function).

Any of those three approaches can be used in your code in Dart, you can return an object with value AND error, or result with Either value OR error, or use traditional try-catch blocks and other options Dart provides you out of the box.

Let’s explore what exceptions handling options Dart provides us with.

Exception handling in Dart and Flutter

What are the situations where we need to use exception handling?

  • Synchronous code
  • Asynchronous code
  • Timers
  • Streams
  • Flutter app as a whole

Synchronous code

The main tool for handing the exceptions in Dart is try-catch-finally construction — the same as in many other languages.

In order to catch all the exceptions in a block of code you wrap the code in try block and use multiple on-catch instructions to handle some specific exceptions, then use catch to handle all other unexpected exceptions, and finally use finally to run the code that should be invoked after the code in try block and in any of the catch blocks. Code in finally will be called even if you return from try or catch blocks or re-throw the exception in catch block.

Instruction rethrow helps you to throw the same exception after you handle it so the upstream code will take care of it.

try {
    final _ = 100 ~/ 0;
  } on IntegerDivisionByZeroException {
    print('Cant divide to zero');
  } finally {
    print('Clean-up done 1');
  }

  print('\n\n');

  try {
    throw FormatException('format is wrong');
  } on IntegerDivisionByZeroException {
    print('Cant divide to zero');
  } on FormatException catch (e) {
    print('Format exceptions $e');
  } finally {
    print('Clean-up done 2');
  }

  print('\n\n');

  try {
    print('Allocate something 3');
    throw 'Other error occurred';
  } on IntegerDivisionByZeroException {
    print('Cant divide to zero');
  } on FormatException catch (e) {
    print('Format exceptions: $e');
  } catch (e, s) {
    print('Unknown exceptions: $e, with stack: \n$s');
    rethrow;
  } finally {
    print('Clean-up something 3');
  }
Enter fullscreen mode Exit fullscreen mode

Output:

Cant divide to zero
Clean-up done 1
Format exceptions FormatException: format is wrong
Clean-up done 2
Allocate something 3
Unknown exceptions: Other error occurred, with stack: 
#0      main (file:///Users/user/Downloads/dart_sample/bin/sync_exceptions.dart:26:5)
#1      _startIsolate.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:299:32)
#2      _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
Clean-up something 3
Unhandled exception:
Other error occurred
#0      main (file:///Users/user/Downloads/dart_sample/bin/sync_exceptions.dart:26:5)
#1      _startIsolate.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:299:32)
#2      _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
Enter fullscreen mode Exit fullscreen mode

We can see that all three try-catch blocks catch the exceptions and in the third block, the exception was re-thrown after clean-up was done.

This is pretty much it for the handling exceptions in synchronous blocks of code. You can check for more details here — https://dart.dev/guides/language/language-tour#catch

Asynchronous code

Let’s consider this example:

try {
    Future.delayed(Duration(seconds: 1), () {
      final _ = 100 ~/ 0;
    });
    print('Everything is fine!');
  } on IntegerDivisionByZeroException {
    print('Cant divide to zero');
  } finally {
    print('Clean-up done');
  }
Enter fullscreen mode Exit fullscreen mode

Output:

Everything is fine!
Clean-up done
Unhandled exception:
IntegerDivisionByZeroException
#0      int.~/ (dart:core-patch/integers.dart:22:7)
#1      main.<anonymous closure> (file:///Users/user/Downloads/dart_sample/bin/async_exceptions.dart:6:21)
#2      new Future.delayed.<anonymous closure> (dart:async/future.dart:325:39)
#3      Timer._createTimer.<anonymous closure> (dart:async-patch/timer_patch.dart:18:15)
#4      _Timer._runTimers (dart:isolate-patch/timer_impl.dart:397:19)
#5      _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:428:5)
#6      _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
Enter fullscreen mode Exit fullscreen mode

We’d like to handle the exception in the Future delayed for one second. It’s natural that this code fails to catch the exception as the code in the try-catch block just schedules the _computation_ (Futures callback/closure) to be invoked one second after try-catch finishes its work.

It would look something like this on a timeline:

Catching exceptions in asynchronous code

How to fix it? There are many ways of fixing it, for instance, by adding await instruction before the Future. It forces computation to be invoked inside the try-catch and thus the try block will be able to catch and handle the exception successfully.

try {
    await Future.delayed(Duration(seconds: 1), () {
      final _ = 100 ~/ 0;
    });
    print('Everything is fine!');
  } on IntegerDivisionByZeroException {
    print('Cant divide to zero');
  } finally {
    print('Clean-up done');
  }
Enter fullscreen mode Exit fullscreen mode

Output:

Cant divide to zero
Clean-up done
Enter fullscreen mode Exit fullscreen mode

Now it works fine, we managed to catch the exception and make a clean-up after it.

We can represent the new code like this:

code

What if we have a chain of futures? No problem at all, try-catch will catch exceptions in all of them as long as we insert await before the first of them.

try {
    await Future.delayed(Duration(seconds: 1), () {
      print('Its ok so far');
    }).then((value) {
      final _ = 100 ~/ 0;
    });
    print('Everything is fine!');
  } on IntegerDivisionByZeroException {
    print('Cant divide to zero');
  } finally {
    print('Clean-up done');
  }
Enter fullscreen mode Exit fullscreen mode

Output:

Its ok so far
Cant divide to zero
Clean-up done
Enter fullscreen mode Exit fullscreen mode

If you don’t like the above way of catching the exceptions you can use a dot syntax and catchError function.

await Future.delayed(Duration(seconds: 1), () {
    print('Its ok so far');
  }).then((value) {
    final _ = 100 ~/ 0;
  }).catchError((error) {
    print('Cant divide to zero');
  }).whenComplete(() {
    print('Clean-up done');
  });
Enter fullscreen mode Exit fullscreen mode

Output:

Its ok so far
Cant divide to zero
Clean-up done
Enter fullscreen mode Exit fullscreen mode

In the above code, catchError is an analog of catch block and whenComplete is an analog of finally block. Function catchError catches exceptions in all the code that located above it, so if you suspect that whenComplete can throw an exception you can add one more catchError after it.

await Future.delayed(Duration(seconds: 1), () {
    print('Its ok so far');
  }).then((value) {
    final _ = 100 ~/ 0;
  }).catchError((error) {
    print('Cant divide to zero');
  }).whenComplete(() {
    print('Clean-up done');
    throw 'ss';
  }).catchError((error) {
    print('Oh, error in the finally block');
  });
Enter fullscreen mode Exit fullscreen mode

Output:

Its ok so far
Cant divide to zero
Clean-up done
Oh, error in the finally block
Enter fullscreen mode Exit fullscreen mode

You can use exceptions matching using test parameter of a catchError function.

await Future.delayed(Duration(seconds: 1), () {
    print('Its fie so far');
  }).then((value) {
    print('Then started');
    throw 'random exception';
  }).catchError((error) {
    print('Cant divide to zero');
  }, test: (e) => e is IntegerDivisionByZeroException).catchError((error) {
    print('All other exceptions: $error');
  }, test: (e) => true).whenComplete(() {
    print('Clean-up done');
  });
Enter fullscreen mode Exit fullscreen mode

Output:

Its fie so far
Then started
All other exceptions: random exception
Clean-up done
Enter fullscreen mode Exit fullscreen mode

Looks not super readable, right? That’s why a Dart code style recommends using await (code style guide)and thus try-catch in such cases. It could look like this:

try {
    await Future.delayed(Duration(seconds: 1), () {
      print('Its fie so far');
    });
    await Future(() {
      print('Then started');
      throw 'random exception';
    });
  } on IntegerDivisionByZeroException {
    print('Cant divide to zero');
  } catch (e, s) {
    print('All other exceptions: $e');
  } finally {
    print('Clean-up done');
  }
Enter fullscreen mode Exit fullscreen mode

Output:

Its fie so far
Then started
All other exceptions: random exception
Clean-up done
Enter fullscreen mode Exit fullscreen mode

From my personal point of view, it’s much clearer and more readable.

Additionally, It has one more advantage — it can handle both synchronous and asynchronous code, so if you need to make some calculations between two asynchronous calls you don’t have to create additional synchronous Future (as you would have done in the case of dot syntax), just add this code into try block.

Ooook, this was easy, right? What about this piece of code?

try {
    Timer.run(() {
      print('Timer runs.');
      throw Exception('[Timer] Bad thing happens!');
    });
    print('Everything is fine!');
  } on IntegerDivisionByZeroException {
    print('Cant divide to zero');
  } finally {
    print('Clean-up done');
  }
Enter fullscreen mode Exit fullscreen mode
Everything is fine!
Clean-up done
Timer runs.
Unhandled exception:
Exception: [Timer] Bad thing happens!
#0      main.<anonymous closure> (file:///Users/user/Downloads/dart_sample/bin/async_exceptions.dart:7:7)
#1      Timer._createTimer.<anonymous closure> (dart:async-patch/timer_patch.dart:18:15)
#2      _Timer._runTimers (dart:isolate-patch/timer_impl.dart:397:19)
#3      _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:428:5)
#4      _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
Enter fullscreen mode Exit fullscreen mode

:( (smile with crying). Yeah, bad things happen in timers too. And the above code is not good to deal with those bad things as again timer’s callback will be invoked not in the try-catch block and we can’t fix it with await instruction as we can’t wait on Timer.

An obvious solution is to add try-catch inside the timer callback but if we don’t want to contaminate the callbacks with exceptions handling or there is a much complicated asynchronous logic inside the timer’s callback and we want to handle exceptions from that logic in the place where we run timer?

Zones and runZonedGuarded

runZonedGuarded can help you with this and with more complicated cases too!

Zones (dart help) is a concept in Dart that helps to divide your code into error free zones where any exceptions in any pieces of asynchronous code will be handled in one place: zone’s uncaught error handler.

How does it work? Each callback or computation in timers, microtasks, and Futures is being wrapped into a special handler in the Zone. The handler wraps the callback in try-catch and if catch is being triggered Zone invokes an uncaught error handler where you can handle those errors.

Each piece of code is being run in some Zone, even you don’t create your custom Zone it would be invoked in the Root Zone which means we are safe anyway!

Let’s see how we can fix the problem with the timer:

runZonedGuarded((){
    Timer.run(() {
      print('Timer runs.');
      throw Exception('[Timer] Bad thing happens!');
    });
    print('Everything is fine!');
  }, (e,s) {
    print('Exception handled $e, \n$s');
  });
Enter fullscreen mode Exit fullscreen mode

Output:

Everything is fine!
Timer runs.
Exception handled Exception: [Timer] Bad thing happens!, 
#0      main.<anonymous closure>.<anonymous closure> (file:///Users/user/Downloads/dart_sample/bin/async_exceptions.dart:7:7)
#1      _rootRun (dart:async/zone.dart:1182:47)
#2      _CustomZone.run (dart:async/zone.dart:1093:19)
#3      _CustomZone.runGuarded (dart:async/zone.dart:997:7)
#4      _CustomZone.bindCallbackGuarded.<anonymous closure> (dart:async/zone.dart:1037:23)
#5      _rootRun (dart:async/zone.dart:1190:13)
#6      _CustomZone.run (dart:async/zone.dart:1093:19)
#7      _CustomZone.bindCallback.<anonymous closure> (dart:async/zone.dart:1021:23)
#8      Timer._createTimer.<anonymous closure> (dart:async-patch/timer_patch.dart:18:15)
#9      _Timer._runTimers (dart:isolate-patch/timer_impl.dart:397:19)
#10     _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:428:5)
#11     _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
Enter fullscreen mode Exit fullscreen mode

Easy! Just wrap your code into runZonedGuarded and add an error handler. No exception can squeeze through this construct… until we will not throw the exception in the error handler itself as the error handler is being invoked in the parent zone, so, be careful.

You can notice that the stack trace becomes longer as now async callbacks are wrapped into our _CustomZone handlers.

Zones is a very powerful tool that can help you to isolate some parts of your complex asynchronous logic from the other parts of the app. As we will see later it’s also rather useful when you use streams and for handling unhandled exceptions in Flutter apps.

If we would try to represent handling of exceptions is zones it could look like this:

Handling exceptions with Zones

In the about image we can see that all the asynchronous callbacks are covered by the zone’s exception handling if we schedule those callbacks inside the zone.

Additionally, Zones helps you to intercept the creation of timers and print operators, and, keep some parameters in the zone’s context (it’s a dictionary that you can pass to the zone while you creating it). All those advanced things are being used in tests mainly, as for my knowledge.

This is an example of how we can use nested zones and zone’s context parameters — I added the parameter ZoneName where I write Zone name and display it in prints.

 print('Current zone start in: ${Zone.current.toString()}');
  runZonedGuarded(() {
    print(
        'Current zone inside runZoned: ${Zone.current.toString()} with name ${Zone.current['ZoneName']}');
    Timer.run(() {
      print('Timer runs.');
      throw Exception('[Timer] Bad thing happens!');
    });
    runZonedGuarded(
          () {
        print(
            'Current zone (1) inside runZoned: ${Zone.current.toString()} with name ${Zone.current['ZoneName']}');
        Timer.run(() {
          print('Timer 1 runs. ');
          throw Exception('[Timer 1] Bad thing happens!');
        });
      },
          (e, s) {
        print(
            'Current zone (1) inside catch: ${Zone.current.toString()} with name ${Zone.current['ZoneName']}');
        print('Exception handled (1) $e, \n$s');
      },
      zoneValues: {'ZoneName': 'Second zone'},
    );
    print('Everything is fine!');
  }, (e, s) {
    print(
        'Current zone inside catch: ${Zone.current.toString()} with name ${Zone.current['ZoneName']}');
    print('Exception handled $e, \n$s');
  }, zoneValues: {'ZoneName': 'First zone'});
Enter fullscreen mode Exit fullscreen mode

Output:

Current zone start in: Instance of '_RootZone'
Current zone inside runZoned: Instance of '_CustomZone' with name First zone
Current zone (1) inside runZoned: Instance of '_CustomZone' with name Second zone
Everything is fine!
Timer runs.
Current zone inside catch: Instance of '_RootZone' with name null
Exception handled Exception: [Timer] Bad thing happens!, 
#0      main.<anonymous closure>.<anonymous closure> (file:///Users/user/Downloads/dart_sample/bin/async_exceptions.dart:10:7)
#1      _rootRun (dart:async/zone.dart:1182:47)
#2      _CustomZone.run (dart:async/zone.dart:1093:19)
#3      _CustomZone.runGuarded (dart:async/zone.dart:997:7)
#4      _CustomZone.bindCallbackGuarded.<anonymous closure> (dart:async/zone.dart:1037:23)
#5      _rootRun (dart:async/zone.dart:1190:13)
#6      _CustomZone.run (dart:async/zone.dart:1093:19)
#7      _CustomZone.bindCallback.<anonymous closure> (dart:async/zone.dart:1021:23)
#8      Timer._createTimer.<anonymous closure> (dart:async-patch/timer_patch.dart:18:15)
#9      _Timer._runTimers (dart:isolate-patch/timer_impl.dart:397:19)
#10     _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:428:5)
#11     _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
Timer 1 runs. 
Current zone (1) inside catch: Instance of '_CustomZone' with name First zone
Exception handled (1) Exception: [Timer 1] Bad thing happens!, 
#0      main.<anonymous closure>.<anonymous closure>.<anonymous closure> (file:///Users/user/Downloads/dart_sample/bin/async_exceptions.dart:18:11)
#1      _rootRun (dart:async/zone.dart:1182:47)
#2      _CustomZone.run (dart:async/zone.dart:1093:19)
#3      _CustomZone.runGuarded (dart:async/zone.dart:997:7)
#4      _CustomZone.bindCallbackGuarded.<anonymous closure> (dart:async/zone.dart:1037:23)
#5      _rootRun (dart:async/zone.dart:1190:13)
#6      _CustomZone.run (dart:async/zone.dart:1093:19)
#7      _CustomZone.bindCallback.<anonymous closure> (dart:async/zone.dart:1021:23)
#8      Timer._createTimer.<anonymous closure> (dart:async-patch/timer_patch.dart:18:15)
#9      _Timer._runTimers (dart:isolate-patch/timer_impl.dart:397:19)
#10     _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:428:5)
#11     _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
Enter fullscreen mode Exit fullscreen mode

From the above example, you can see that it’s rather convenient to use nested zones to divide your code into error free zones and, probably, use zone’s context to keep so useful info for logging purposes.

Error handling in streams

I promised to reveal how zones can help with handling exceptions when we use streams.

Let’s consider the simple code where one piece of code emits some events to the stream and another one consumes them.

import 'dart:async';

void main(List<String> arguments) async {
    startListener(getStream());
}

Stream<int> getStream() {
  final intsController = StreamController<int>.broadcast();
  int i = 42;
  Timer.periodic(Duration(seconds: 1), (timer) {
    i += 1;
    if (i == 46) throw '[Stream] Bad thing happens!';
    intsController.add(i);
  });

  return intsController.stream;
}

void startListener(Stream<int> ints) {
  ints.listen((event) {
    print(event);
    if (event == 44) {
      throw '[Listener] Bad thing happens!';
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Output:

43
Unhandled exception:
[Listener] Bad thing happens!
#0      startListener.<anonymous closure> (file:///Users/artemgoncharov/Downloads/dart_sample/bin/streams_exceptions.dart:23:7)
#1      _RootZone.runUnaryGuarded (dart:async/zone.dart:1384:10)
#2      _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:357:11)
#3      _DelayedData.perform (dart:async/stream_impl.dart:611:14)
#4      _StreamImplEvents.handleNext (dart:async/stream_impl.dart:730:11)
#5      _PendingEvents.schedule.<anonymous closure> (dart:async/stream_impl.dart:687:7)
#6      _microtaskLoop (dart:async/schedule_microtask.dart:41:21)
#7      _startMicrotaskLoop (dart:async/schedule_microtask.dart:50:5)
#8      _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:118:13)
#9      _Timer._runTimers (dart:isolate-patch/timer_impl.dart:404:11)
#10     _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:428:5)
#11     _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
44
Enter fullscreen mode Exit fullscreen mode

I know this code is not perfect at all :) but it demonstrate the problem — we would have an unhandled exception in the listener. An obvious solution is to add try-catch inside the listener. And again, it could be hard because there could be multiple listeners and some asynchronous logic in place and we may want to handle all the exceptions from the listeners and the business logic in one place.

We wrap listeners creation into one zone and producers creation in another one, and, now we can handle all the exceptions from both sides and log them and/or recover in one place that is very convenient:

import 'dart:async';

void main(List<String> arguments) async {
  Stream<int> stream;

  runZonedGuarded(() {
    stream = getStream();
  }, (e, s) {
    print('PRODUCER ZONE Exception handled: $e, $s');
  });

  runZonedGuarded(() {
    startListener(stream);
  }, (e, s) {
    print('CONSUMER ZONE Exception handled: $e, $s');
  });
}

Stream<int> getStream() {
  final intsController = StreamController<int>.broadcast();
  int i = 42;
  Timer.periodic(Duration(seconds: 1), (timer) {
    i += 1;
    if (i == 46) throw '[Stream] Bad thing happens!';
    intsController.add(i);
  });

  return intsController.stream;
}

void startListener(Stream<int> ints) {
  ints.listen((event) {
    print(event);
    if (event == 44) {
      throw '[Listener] Bad thing happens!';
    }
  });
Enter fullscreen mode Exit fullscreen mode

Output:

43
44
CONSUMER ZONE Exception handled: [Listener] Bad thing happens!, #0      startListener.<anonymous closure> (file:///Users/user/Downloads/dart_sample/bin/streams_exceptions.dart:35:7)
#1      _rootRunUnary (dart:async/zone.dart:1206:13)
#2      _CustomZone.runUnary (dart:async/zone.dart:1100:19)
#3      _CustomZone.runUnaryGuarded (dart:async/zone.dart:1005:7)
#4      _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:357:11)
#5      _DelayedData.perform (dart:async/stream_impl.dart:611:14)
#6      _StreamImplEvents.handleNext (dart:async/stream_impl.dart:730:11)
#7      _PendingEvents.schedule.<anonymous closure> (dart:async/stream_impl.dart:687:7)
#8      _rootRun (dart:async/zone.dart:1182:47)
#9      _CustomZone.run (dart:async/zone.dart:1093:19)
#10     _CustomZone.runGuarded (dart:async/zone.dart:997:7)
#11     _CustomZone.bindCallbackGuarded.<anonymous closure> (dart:async/zone.dart:1037:23)
#12     _rootRun (dart:async/zone.dart:1190:13)
#13     _CustomZone.run (dart:async/zone.dart:1093:19)
#14     _CustomZone.runGuarded (dart:async/zone.dart:997:7)
#15     _CustomZone.bindCallbackGuarded.<anonymous closure> (dart:async/zone.dart:1037:23)
#16     _microtaskLoop (dart:async/schedule_microtask.dart:41:21)
#17     _startMicrotaskLoop (dart:async/schedule_microtask.dart:50:5)
#18     _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:118:13)
#19     _Timer._runTimers (dart:isolate-patch/timer_impl.dart:404:11)
#20     _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:428:5)
#21     _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
45
PRODUCER ZONE Exception handled: [Stream] Bad thing happens!, #0      getStream.<anonymous closure> (file:///Users/user/Downloads/dart_sample/bin/streams_exceptions.dart:24:18)
#1      _rootRunUnary (dart:async/zone.dart:1198:47)
#2      _CustomZone.runUnary (dart:async/zone.dart:1100:19)
#3      _CustomZone.runUnaryGuarded (dart:async/zone.dart:1005:7)
#4      _CustomZone.bindUnaryCallbackGuarded.<anonymous closure> (dart:async/zone.dart:1042:26)
#5      _rootRunUnary (dart:async/zone.dart:1206:13)
#6      _CustomZone.runUnary (dart:async/zone.dart:1100:19)
#7      _CustomZone.bindUnaryCallback.<anonymous closure> (dart:async/zone.dart:1026:26)
#8      _Timer._runTimers (dart:isolate-patch/timer_impl.dart:397:19)
#9      _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:428:5)
#10     _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
Enter fullscreen mode Exit fullscreen mode

Warning! I’m not saying we have to give up handling errors as close to the place where they can arise, I’m just saying that sometimes it’s very useful to wrap the complex features to zones in order to catch and properly log the errors that sometimes squeeze through all our downstream bastions.

On this optimistic note, I’m going to get into the last topic: Unhandled exceptions.

Unhandled exceptions

As we saw before all the unhandled exceptions are intercepted by the zone the code is being invoked in. So that if you’d like to log the unhandled exceptions in you flutter app you need just wrap your runApp call into the runZoneGuarded and add logging in the error handler. If you do something else in main function — create providers or singletons — you may want to wrap them to the runZonedGuarded as well.

This helps you to be aware of any unhandled exceptions in your app.

You may argue that you can see all of them in the terminal when you debug your app. This is super convenient for debugging but it has two nuances:

  1. If you debug your app directly on a device (not on Simulator) you will not be seeing the exceptions in the asynchronous code (unless you use runZonedGuarded)
  2. In release builds, I’m sure, you want to be aware of all the unhandled exceptions even if it seems that app is working fine. So that you need to add runZonedGuarded and log all the exceptions to your backend or Firebase/Sentry, or whatever system you use for remote logging

However, runZoneGuarded can’t help you with the exceptions which were already handled by the Flutter framework.
All those exceptions can be logged using FlutterError.onError callback. If you add logging to this callback then anytime Flutter catches an error with rendering or anything in widgets it will reach that callback and will be logged by you. Don’t forget to WidgetsFlutterBinding.ensureInitialized() before adding the onError callback.

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  FlutterError.onError = (FlutterErrorDetails errorDetails) {
    print("onError Exception: $errorDetails was caught by Flutter framework - redirect to Sentry or Firebase.");
  };

  runZonedGuarded(() {
    createProviders();
    runApp(MyApp());
  }, (e, s) {
    print(
        "Synchronous or Asynchronous Exception: $e (stack $s) was caught in our custom zone - redirect to Sentry or Firebase.");
  });
}
Enter fullscreen mode Exit fullscreen mode

Wrap up

It seems that brevity is not one my features but I hope the article was still useful and can be of help for you.

Additionally, if you still haven’t forgotten about the 3 things from the title, here they are:

  1. There are many ways of dealing with exceptions in different languages, try-catch is not the only one, and, it depends on your design what exception handling method you want to use — in Dart you can implement many of them
  2. Use runZoneGuarded to catch exceptions in the complex asynchronous code, streams, and in a whole Flutter app too
  3. Unhandled exceptions in asynchronous code can be silently swallowed (not printed in the terminal) if you debug your app on the real device

This article was written after my presentation (YouTube link) in Singapore Flutter Meetup which you can find here: https://www.meetup.com/Singapore-Flutter-Meetup/events/276139032/

List of sources
https://api.flutter.dev
https://dart.dev/get-dart
https://github.com/flutter/flutter

Top comments (0)