I'm a little lost with handling Flutter errors.
From what I've seen by now, the majority of the SDKs do not explicitly state the errors that they can throw, unlike other programming languages. It makes handling specific errors extremely difficult.
For example, for a simple disconnected network I've seen random functions sometimes throw SocketError
, PlatformError
, http.ClientException
, in the case of Firebase they can also throw FirebaseException
with error code "unavailable"
, "internal"
, "network-request-failed"
, "unknown"
(with embedded Java exception "SERVICE_UNAVAILABLE"
on android), and a few others. If I wish to simply catch the network errors thrown from a method, I don't know what to catch and it looks like a hot mess, playing whack-a-mole in production - i.e. matching error.toString()
to crashlytics reports.
What is the correct way to know which errors are thrown from a flutter / dart builtin or 3rd package, and from the Firebase SDK in particular? Is there any standardization? Is there any documentation for which errors are thrown from functions, whether in dart builtins or in Firebase?
Edit:
Some concrete examples of the production whack-a-mole:
Firebase remote config: 4 different errors so far thrown on network issues, two different FirebaseExceptions, two PlatformExceptions
try {
await remoteConfig.fetchAndActivate();
} on FirebaseException catch (e) {
switch (e.code) {
case "internal":
// Probably a network error
logger.i('Remote config fetch internal error, skipping');
return;
case "remote-config-server-error":
// Probably a network fetch error.
logger.i('Remote config fetch server error, skipping');
return;
default:
rethrow;
}
} on PlatformException catch (e) {
if (e.toString().contains('The server is unavailable') == true ||
e.toString().contains('Unable to connect to the server') == true) {
logger.i("No internet connection, skipping remote config fetch");
return;
}
rethrow;
}
Firestore query: PlatformException with different message
try{
collection.where(FieldPath.documentId, isEqualTo: uid)
.where("key", arrayContains: value)
.get();
} on PlatformException catch (e) {
if (e.toString().contains("Unable to resolve host")) {
throw NetworkUnavailableError(null, e);
}
rethrow;
}
Firebase messaging: Two different FirebaseException with "unknown" errors
try {
token = await _fcm.getToken();
} catch (e) {
if (e is! FirebaseException) rethrow;
final message = e.message;
if (message == null || e.code != "unknown") rethrow;
if (message.contains("MISSING_INSTANCEID_SERVICE") ||
message.contains("SERVICE_NOT_AVAILABLE")) {
throw NetworkUnavailableError(null, e);
}
rethrow;
}
Google sign-in: Two different PlatformExceptions
try {
googleUser = await GoogleSignIn().signIn();
} on PlatformException catch (e) {
if (e.code == "network_error") {
throw NetworkUnavailableError(null, e);
}
if (e.code == 'channel-error' && e.message?.contains('Unable to establish connection') == true) {
throw NetworkUnavailableError(null, e);
}
rethrow;
}
http: ClientException with specific message:
try {
http.post(...);
} catch (e) {
if (e is http.ClientException &&
e.message.contains("Failed host lookup")) {
throw NetworkUnavailableError("Can't reach servers.", e);
}
}
Needless to say, not a single one of these is documented, and these are just the network errors - for other errors the game of production bugs is even worse.
Unfortunately, those are correct catch examples, depends on the package how sloppy it gets.
Before getting into details, read the example. Unlike the http package that only throws one type of exception, HttpClient
can throw a multitude of exceptions types. You should notice that the two catch statements catch for different kinds of operations, it's the same for a packages (consider federated packages as one package or kind), this usually means that the package would have it's own "base" exception to derive (example) or centralize (example). But the thing is, there are no guidelines on how and what to throw (as far as I know, these are way too generic), it's unclear what to do if a dependency of a package throws (should
the package throw a SocketException
or a MyPackageSocketException
?).
(There is one "guideline" though: 'These are not errors that a caller should expect or catch — if they occur, the program is erroneous, and terminating the program may be the safest response.' - error. In my example I'm avoiding an error by throwing an exception.)
import 'dart:io';
import 'dart:async';
import 'dart:convert';
// SomeonesUserPackage.dart | Start
class UserException extends FormatException {
const UserException([super.message, super.source, super.offset]);
}
class User {
final int id;
final String name;
const User({
required this.id,
required this.name,
});
factory User.from_json(Map json) {
if (json is! Map<String, dynamic> && ['id', 'name'].every((key) => json.keys.contains(key))) {
throw const UserException('Invalid Json content');
}
// This checks for [TypeError]s, converting them if any to a [FormatException]
T _get_value<T>(String key) {
if (json[key] is! T) {
throw UserException(
'Value of $key is type ${json[key].runtimeType}, expected $T',
);
}
return json[key];
}
return User(
id: _get_value<int>('id'),
name: _get_value<String>('name'),
);
}
}
// SomeonesUserPackage.dart | End
// MyServer.dart | End
void serve_local() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 8080);
await for (final request in server) {
switch (request.uri.path) {
case '/bad_json':
request.response.write('{"id": "1", "name": "User"}');
case '/good_json':
request.response.write('{"id": 1, "name": "User"}');
}
request.response.close();
break;
}
server.close();
}
// MyServer.dart | End
// MyApp.dart | Start
// To increase the consistency of string based exceptions you can attach some
// utility functions that centralize common checks.
extension UserExceptionUtils on UserException {
bool get is_invalid_content => message == 'Invalid Json content';
}
Future<User?> get_user(String path) async {
final client = HttpClient();
User? user;
try {
final request = await client.get('localhost', 8080, path);
final response = await request.close();
if (response.statusCode != 200) {
print('Unexpected status code ${response.statusCode}');
return null;
}
final data = await response.transform(utf8.decoder).join();
final json = jsonDecode(data);
user = User.from_json(json);
} on IOException catch (exception) {
// HttpException, SocketException, TlsException, WebSocketException
print('IOException -> ${exception.runtimeType}');
} on FormatException catch (exception) {
// UTF-8 decoding exceptions and [User.from_json] throws
print('FormatException -> ${exception.runtimeType}');
} finally {
client.close();
}
return user;
}
void main() {
serve_local();
get_user('good_json').then(print);
}
// MyApp.dart | End
Additionally, you can inspect those packages, a simple grep for throws will tell you what to expect (PlatformException
is usually thrown returned from native code).
For whatever reason, the google_sigh_in package did not abstract it's plugin errors, this seems intentional because they included the error strings (GoogleSignIn, see constants).
However, firebase packages should only throw FirebaseException
exceptions. Even though fetchAndActivate() can't throw a PlatformException
, the example still checks for it (out of date but working, probably a "lgtm"). I don't use firebase but I do believe you that the exception handling is not great, for instance, this two examples are not identical event though they are lines apart. Oddly enough, the generic catch is the correct catch, the call chain Firebase.initializeApp > FirebaseAppPlatform.initializeApp > FirebaseCoreHostApi.optionsFromResource can trow a PlatformException
(maybe I overlook't something).
Not sure if this inconsistency would warrant using a Zone
(to intercept-replace the exceptions), an extension on the exceptions with some generic functions (i.e, exception.should_retry()
) would be the the simplest/cleanest approach.