Anti-patterns of error handling in dart

This article will show common pitfalls you can make when handling exceptions and how to do it right. The article will help to avoid several popular misconceptions associated with the interception of errors. These misconceptions are mainly related to the practices of other languages and do not consider Dart's specifics.

Most bugs are related to the loss of the stack trace, and without it, the error logs in the release (for example, Crashlytics or Sentry) are practically useless, and it tends to be very difficult to trace the root of the problem.


Problems and common mistakes

First of all, return null on exception - worst option ever.

try {
  final json = await client.get(id);
  return Item.fromJson(json);
} on Object {
  return null;
}

You lose exception and the stack trace, you can’t respond properly with the user interface, and you won’t know in Crashlytics that something bad happened.

Using unions to catch errors like this:

try {
  final json = await client.get(id);
  final item = Item.fromJson(json);
  return DataOrException.data(item);
} catch (error) {
  return DataOrException.failure(error);
}

Well, now you “rethrow” the exception but lost stack trace again. If you try adding a stack trace, you will have problems combining it together. And without a stack trace, you simply cannot trace the roots of the problem since the stack trace is even more important than the exception itself. This approach doesn't make sense in Dart since the stack trace is separate from the exception.

And the last popular misconception is throwing another exception using "throw"

try {
  final json = await client.get(id);
  return Item.fromJson(json);
} on Object {
  throw ApiException();
}

And again, you lost the existing stack trace, and after that, it is very problematic (almost impossible) to track problems in Crashlytics or Sentry without the source stack trace.


Tips and solutions

Don't be afraid of exceptions.
It's much worse to silence a mistake than to let it pass.
No need to try to foresee everything.
You will learn the mistakes that must be foreseen and properly processed from the Crashlytics or even at the self-test stage.

Just don't catch them where they don't belong.

  final json = await client.get(id);
  return Item.fromJson(json);

also, you can rethrow source exception

try {
  final json = await client.get(id);
  return Item.fromJson(json);
} on Object {
  rethrow;
}

If you need to replace the original error, don't forget the stack trace. Use throwWithStackTrace for this

try {
  final json = await client.get(id);
  return Item.fromJson(json);
} on Object catch (_, stackTrace) {
  Error.throwWithStackTrace(
    ApiException(‘Something goes wrong’),
    stackTrace,
  );
}

If you think you can recover from the panic of a specific exception - try to catch specific types of errors that you can predict

try {
  final json = await client.get(id);
  return Item.fromJson(json);
} on FormatException {
  return const Item.empty();
}

Don't forget that you can catch various actions, and the try-catch-finally syntax is as follows

try {
  ...
} on TimeoutException {
  ...
} on HandshakeException catch (error, stackTrace) {
  ...
  Error.throwWithStackTrace(
    ApiException(‘Something goes wrong’),
    stackTrace,
  );
} on Object {
  ...
  rethrow;
} finally {
  ...
}

Use zones to catch asynchronous errors without awaiting

runZonedGuarded<void>(() async {
    longAsyncOperation();
    runApp(App());
  },
  (error, stackTrace) => ...
);

Future objects have an ignore method if you really don't care if the method succeeds or not.

FirebaseAnalytics.instance.logAppOpen().ignore();
FirebaseCrashlytics.instance.recordError(exception, stackTrace).ignore();

Stacktrace is a lifesaver when investigating bugs. Learn how to interact with it effectively. For example, you can get the current stack trace and add additional information to it.

StackTrace.fromString('${StackTrace.current}\n'
    'Headers: "${jsonEncode(response.headers)}"');

Look at the stack_trace library. It simplifies the interaction with the stack trace and allows you to make it more capacious and beautiful.


Afterword

Don't be afraid to write buggy code. Be aware of unsupported code with floating bugs, silent exceptions, and lost stack traces.
You still can't catch all the bugs at the development stage. Especially if you remember that there are deadlines and there is not always a separate design for displaying errors. Some bugs can only be reproduced on certain devices, manufacturers, and versions. I bet most of the developers won't be able to name half of the exceptions you can get with a normal HTTP request. Just don't worry, let the error happens, and log everything.

Well, also, not every error is a bug, for example, if you try to send an email when the Internet is not available, this is not a bug, but if there are many such cases, it may need to be beaten from the point of view of the interface or implement a new feature of delayed sending. You will definitely want to implement this feature if you see many of these errors. And if you do not throw this error, you will not learn about the user's needs and can't subsequently improve a user experience.

But since a developer cannot predict all exceptions and process them correctly, such a developer simply writes a common handler for all errors. It doesn't matter, it's a problem with the cache, the Internet, the device, the business logic, the operating system, the updated backend, timeout, handshake, the lack of rights, etc.

And since you can't predict a particular problem, you can't handle it correctly either. Incorrect handling of the problem itself becomes an even greater problem.

Consequently, this leads to the "silencing" of exceptions and a serious deterioration in the quality of the application.
The product owners and the developer himself do not know about the problems.