Centralized remotely configurable exception handler in Flutter

David Ordine
tarmac
Published in
10 min readSep 8, 2023

In this article you will learn how to implement a centralized exception handler in Flutter, with an option to also configure error messages remotely with Firebase Remote Config. This is a highly personal implementation based on more than 5 different projects which I applied this pattern to and worked really well in production. Below is the architecture design of what we aim to achieve. We will use a default Flutter project to exemplify the implementations. No worries, if you don’t use Firebase in your project, this architecture will work equally since the service you use to analyze user behavior and crashes can be abstracted or changed.

Centralized remotely configurable error handling in Flutter

I’ve implemented an example repository so you can follow the complete code: https://github.com/DavidGrunheidt/flutter-centralized-exception-handler-example.

This tutorial uses some Flutter packages for code generation and Dio for HTTP requests. You can check all the packages used in the pubspec.yaml. We will also abstract the steps for creating a project into Firebase or any other service alike. Also, no Firebase config files will be added to the repository for safety reasons, so if you want to run the example app you will need to create a firebase project and attach it to your local repository clone. Here are the docs on how to do it.

With no more delays, let's start:

1 — Create an enum for all report types your error report service contains. In case of Crashlytics there’s fatal or nonFatal . I also added a third one called dontReport which will be used in case I don't want to report the exception.

enum CrashlyticsErrorStatusEnum {
fatal,
nonFatal,
dontReport,
}

extension CrahslyticsErrorStatusEnumExtension on CrashlyticsErrorStatusEnum {
bool get isFatal => this == CrashlyticsErrorStatusEnum.fatal;
bool get shouldNotReport => this == CrashlyticsErrorStatusEnum.dontReport;
}

2 — Create a class called AppExceptionCode that implements Exception and can be thrown anywhere in code. It will have only one field ( final String code ) which will be used later to parse the exception into a UI message. You can use that in your code to throw an exception with a key instead of an exception already containing the message you want to display to the user. This key is later used to access the actual message stored in Firebase Remote Config. This message can then be changed anytime without the need for new releases. In our case, code field of AppExceptionCode is the key we talked about.

class AppExceptionCode implements Exception {
const AppExceptionCode({
required this.code,
});

final String code;
}

3 — Add a file called ui_error_alerts.json under assets/json . Don't forget to also add this folder into the assets on pubspec.yaml. This file can be later modified by you, adding new codes and new messages. We will use this file to set up the default value for each error messages. Later on you will need to add each key-value of this JSON in your remote config service. The keys on this file may depend also on how the backend returns error codes. You will need to teel your backend team to return the error in a way you can parse it into a message. In our case the error code will come in this form: [ERROR_CODE] .

{
"DIO_TIMEOUT" : "The request took too much time. Please try again later",
"CHECK_INTERNET_CONNECTION" : "Check your internet connection and try again",
"MAPPED_CODE" : "Message parsed correctly",
"GENERIC_FAIL_MESSAGE": "Oh no! Something went really wrong."
}

And here’s the pubspec.yaml with the new asset:

flutter:
uses-material-design: true

# To add assets to your application, add an assets section, like this:
assets:
- assets/json/

4 — Create a class to handle remote config integration logic. In our case this class is called RemoteConfigRepository . We will use GetIt to save it into memory and locate this dependency somewhere else in the code, but feel free to access it the way your project handles dependencies.

import 'dart:convert';

import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_remote_config/firebase_remote_config.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

import '../helpers/app_constants.dart';

class RemoteConfigRepository {
final _remoteConfig = FirebaseRemoteConfig.instance;

Future<void> init() async {
try {
if (!kIsWeb) _remoteConfig.onConfigUpdated.listen(updateConfigs, onError: (_) {});
await _setDefaultConfigs();

await _remoteConfig.setConfigSettings(
RemoteConfigSettings(
fetchTimeout: kDebugMode ? const Duration(minutes: 1) : const Duration(seconds: 10),
minimumFetchInterval: kDebugMode ? const Duration(hours: 4) : const Duration(minutes: 1),
),
);

await _remoteConfig.fetchAndActivate();
} catch (exception, stacktrace) {
await FirebaseCrashlytics.instance.recordError(exception, stacktrace);
} finally {
await updateConfigs(RemoteConfigUpdate({}));
}
}

Future<void> updateConfigs(RemoteConfigUpdate remoteConfigUpdate) => _remoteConfig.activate();

String getString(String key) => _remoteConfig.getString(key);

Future<void> _setDefaultConfigs() async {
final defaultRemoteConfigValues = <String, dynamic>{};

final uiErrorAlertJsonRaw = await rootBundle.loadString(uiErrorAlertsJsonPath);
final uiErrorAlertMap = json.decode(uiErrorAlertJsonRaw) as Map<String, dynamic>;
defaultRemoteConfigValues.addAll(uiErrorAlertMap);

return _remoteConfig.setDefaults(defaultRemoteConfigValues);
}
}

Firebase Remote Config allows realtime updates. This class configures a listener for realtime updates and also defines a minimum fetch interval. on init method it fetches all remote config values and activates them.

getString will be used to search for the error code using the code as a remote config key.

A thing to notice is to always set the default configs on code and create one or multiple JSONs containing mostly all values you have saved on your remote config service. This way, if somehow the first fetch fails, it will have the default values and the app will still be usable. This is done on _setDefaultConfigs() function. bool values can be ignored here since their default values are false , unless you want the default value as true. The previously created JSON asset ( ui_error_alerts.json) was used to set the default error message values.

5 — Add a runZonedGuarded in your main function (the one you call runApp). runZonedGuarded docs says that the first positional parameter is the body , which will be "protected" by an error zone. The second parameter, onErrorwill handle both async and synchronous errors thrown inside body. The important thing here is to not throw any errors on the second parameter of runZonedGuarded , onError , since this will throw an error to outside of runZonedGuarded , making it escape our handler.

void main() {
return runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();

setupRepositoryLocator();

FlutterError.onError = (details) async {
FlutterError.presentError(details);
reportErrorDetails(details);

final errorStatus = getCrashlyticsErrorStatus(details.exception);
if (kIsWeb || errorStatus.shouldNotReport) return;
return FirebaseCrashlytics.instance.recordFlutterError(details, fatal: errorStatus.isFatal);
};

if (!kIsWeb) {
Isolate.current.addErrorListener(RawReceivePort((pair) async {
final List<dynamic> errorAndStacktrace = pair;
final stackTrace = StackTrace.fromString(errorAndStacktrace.last.toString());
return FirebaseCrashlytics.instance.recordError(errorAndStacktrace.first, stackTrace);
}).sendPort);
}

runApp(const MyApp());
}, (error, stackTrace) async {
if (kDebugMode) debugPrint('Unhandled Error: $error StackTrace: $stackTrace');
reportErrorToUI(error, stackTrace);
});
}

Here we are catching errors in three different places: FlutterError.onError , Isolate.current.addErrorListener and onError positional parameter of runZonedGuarded . This is done so we can maximize the error handler reach and to report the maximum of errors we can to our error reporting tool.

6 — Implement getCrashlyticsErrorStatus , reportErrorDetails , reportErrorToUI the way you like it. These will vary depending on how you want to report errors to your reporting tool and to the user, but I'll provide my own implementation to give you some guidance.

  • 6.1 getCrashlyticsErrorStatus : Basically this method will tell you if your exception should be reported to your crashlyticsas fatal , nonFatal or shouldn't be reported at all. You can add other verifications to stop reporting errors that you assess do not need to be reported.
CrashlyticsErrorStatusEnum getCrashlyticsErrorStatus(Object error) {
if (error is AppExceptionCode) return CrashlyticsErrorStatusEnum.dontReport;
if (error is DioException) {
final nonFatalTypes = [DioExceptionType.connectionTimeout, DioExceptionType.connectionError];
final isNonFatal = nonFatalTypes.contains(error.type);
return isNonFatal ? CrashlyticsErrorStatusEnum.nonFatal : CrashlyticsErrorStatusEnum.dontReport;
}

return CrashlyticsErrorStatusEnum.fatal;
}
  • 6.2 reportErrorDetails : This function is used when a FlutterError is captured and basically checks if it was a silent exception or an exception thrown by another library. In the end it will call reportErrorToUI
void reportErrorDetails(FlutterErrorDetails flutterErrorDetails) {
const errors = <String>['rendering library', 'widgets library'];

final isSilentOnRelease = kReleaseMode && flutterErrorDetails.silent;
final isLibraryOnDebug = !kReleaseMode && errors.contains(flutterErrorDetails.library);
if (isSilentOnRelease || isLibraryOnDebug) {
log(
flutterErrorDetails.exceptionAsString(),
name: 'ReportErrorDetails',
stackTrace: flutterErrorDetails.stack,
error: flutterErrorDetails.exception,
);
}

return reportErrorToUI(flutterErrorDetails.exception, flutterErrorDetails.stack);
}
  • 6.3 reportErrorToUI : Responsible for parsing the exception into a AppExceptionCode which will later be translated into a UI friendly message. In my case I used three other auxiliary functions, handleDioException, handleAppExceptionCode and showErrorMessage to parse the Exception accordingly with its type. As you can see, if the error is not well known, we will parse it into a generic AppExceptionCode .
void reportErrorToUI(Object error, StackTrace? stackTrace) {
if (error is DioException) return handleDioException(error);
if (error is AppExceptionCode) return handleAppExceptionCode(code: error.code);

return handleAppExceptionCode(code: kGenericErrorKey);
}
  • 6.3.1 handleDioException : This handles HTTP errors. For timeouts or unknown error we will parse the error into a predefined AppExceptionCode, which of course can be configurable remotely. For other errors we will check if there's an error code inside the HTTP response. If so, we will use that code to call handleAppExceptionCode. If we have any exception during this flow, we will use the response message if there's one, or use a generic message if no.
void handleDioException(DioException error) {
try {
switch (error.type) {
case DioExceptionType.receiveTimeout:
case DioExceptionType.connectionTimeout:
return handleAppExceptionCode(code: kDioTimeoutErrorKey);
case DioExceptionType.unknown:
return handleAppExceptionCode(code: kCheckInternetConnectionErrorKey);
default:
final errorMsg = error.errorMessageDetail;
final codeRaw = kBracketsContentRegex.allMatches(errorMsg).first.group(0)!;
final code = codeRaw.substring(1, codeRaw.length - 1);

return handleAppExceptionCode(code: code, fallbackMsg: error.errorMessageDetail);
}
} catch (_) {
return showErrorMessage(error.errorMessageDetail);
}
}

errorMessageDetail getter and containsErrorCode are implemented in a custom extension for DioException . This is also highly customizable and will change according to how the API you are working with returns errors.

Why use brackets as delimiters? With the brackets we can limit the search. For example: "UNMAPPED_CODE".contains("MAPPED_CODE")would return true and parse the error into a wrong message. "[UNMAPPED_CODE]".contains("[MAPPED_CODE]")rerturns false, thus solving the error code search problem.

import 'package:dio/dio.dart';

import 'app_constants.dart';

extension DioExceptionUtils on DioException {
bool containsErrorCode(String errorCode) {
try {
final data = response?.data;
return data != null && (data['detail']?['error'] as String).contains(errorCode);
} catch (_) {
return false;
}
}

String get errorMessageDetail {
try {
return response?.data['detail']['error'];
} catch (_) {
return kGenericExceptionMessage;
}
}
}
  • 6.3.2 handleAppExceptionCode : Basically it maps a code to its corresponding error message. The code acts as a key for a Map object where the values are of type String . It also has a fallback message in case the message retrieved from remote config comes as an empty string.
void handleAppExceptionCode({
required String code,
String fallbackMsg = kGenericExceptionMessage,
}) {
try {
final message = repositoryLocator<RemoteConfigRepository>().getString(code);
return showErrorMessage(message.isEmpty ? fallbackMsg : message);
} catch (_) {
return showErrorMessage(fallbackMsg);
}
}
  • 6.3.3 showErrorMessage : Show the error message on UI.
void showErrorMessage(String message) {
final context = getErrorHandlerContext();
return showSnackbar(context: context, content: message);
}

7 — But where do we get the context from? There’s many ways to do it. I did it by saving it into GetIt and retrieving it inside showErrorMessage. I've also created a helper called error_handler_context_locator.dart :

import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';

const errorHandlerContextInstanceName = 'errorHandlerContext';

void registerErrorHandlerContext(BuildContext context) {
GetIt.instance.registerSingleton<BuildContext>(context, instanceName: errorHandlerContextInstanceName);
}

BuildContext getErrorHandlerContext() {
return GetIt.instance<BuildContext>(instanceName: errorHandlerContextInstanceName);
}

registerErrorHandlerContext is called inside MyHomePage initState(), which is the top most widget on your app widget tree, and getErrorhandlerContext is called on 6.3.3.

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});

final String title;

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
@override
void initState() {
super.initState();
registerErrorHandlerContext(context);
}

...

Important: Don’t use this context in other scenarios since it may lead to unexpected behavior

And how do we test it?

Good question! I've implemented some testing scenarios in the same example repository. Basically there are some buttons on the main screen that throw different exceptions.

  • NullPointer throws a NullPointerException which will be parsed into a generic message.
  • DioExceptionTimeout throws an async DioException with a timeout type, which will be parsed into a timeout message.
  • DioExceptionUnknown throws a async DioException with unknown type, that will be parsed into a "check your internet connection" message.
  • DioException400Unparsed throws an async BadRequest with a response error message containing an unmapped error code, [UNMAPPED_CODE]. The random response will be shown unparsed on UI.
  • DioException400Parsed throws an async BadRequest with a response error message containing a mapped code, [MAPPED_CODE]. The code will be used to parse the response into a message that is already configured remotely.
  • CheckInternet throws an AppExceptionCode with a kCheckInternetConnectionErrorKey code which is translated to a message to check your internet connection.
  • Exception throws an Exception which will be parsed into a generic error message.

Here's the implementation for each of these buttons:

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
@override
void initState() {
super.initState();
registerErrorHandlerContext(context);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Error handler'),
),
body: SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(24),
width: double.infinity,
child: Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
ElevatedButton(
child: const Text('NullPointer'),
onPressed: () {
const nullVal = null;
nullVal!.toString();
},
),
ElevatedButton(
child: const Text('DioExceptionTimeout'),
onPressed: () => throw DioException.connectionTimeout(
timeout: const Duration(seconds: 1),
requestOptions: RequestOptions(),
),
),
ElevatedButton(
child: const Text('DioExceptionUnknown'),
onPressed: () => throw DioException(
type: DioExceptionType.unknown,
requestOptions: RequestOptions(),
),
),
ElevatedButton(
child: const Text('DioException400Unparsed'),
onPressed: () => throw DioException(
type: DioExceptionType.badResponse,
requestOptions: RequestOptions(),
response: Response(
requestOptions: RequestOptions(),
data: {
'detail': {'error': '[UNMAPPED_CODE] Unparsed response error message'}
},
),
),
),
ElevatedButton(
child: const Text('DioException400Parsed'),
onPressed: () => throw DioException(
type: DioExceptionType.badResponse,
requestOptions: RequestOptions(),
response: Response(
requestOptions: RequestOptions(),
data: {
'detail': {'error': '[MAPPED_CODE] Crazy XYZ will not show'}
},
)),
),
ElevatedButton(
child: const Text('CheckInternet'),
onPressed: () => throw const AppExceptionCode(code: kCheckInternetConnectionErrorKey),
),
ElevatedButton(
child: const Text('Exception'),
onPressed: () => throw Exception(),
),
],
),
),
),
);
}
}

and here's the final result running the app:

Some questions regarding this implementation:

  • What are the benefits of using this current method? Say you have a new request error, yet non parsed and previously unknown showing to the users, for example, [CODE_123] obj.xyz is null . This is not a friendly message. We can use the initial part, [CODE_123] to parse this error into a friendly message. We can add a new value to our ui_error_alert remote JSON stored on Firebase Remote Config and the new message will automatically be shown to all users experiencing this error, without any need for a new release. This method allows handling almost 100% of the errors thrown in your app, providing feedback to the user which otherwise would be stuck without knowing what happened. Also, this method allows local handling mixed with centralized handling of errors by using AppExceptionCode as a middleman.
  • What is the downside of using this method? I would say medium to big apps should opt for local and decentralized handling of errors, especially because these types of apps will tend to have a completely modular architecture with decentralized logic following SOLID principles and lot’s of people working on it.

That's basically it for configuring a centralized remotely configurable error handler. Let me know if you have any doubts and feel free to contact me for better explanations. Thanks for reading and hope you have awesomes projects ahead 🚀

--

--

David Ordine
tarmac
Writer for

Principal Flutter Engineer at tarmac.io, currently living in Florianópolis, Brazil. Loves to travel, surf and snowboard.