Reputation: 26951
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.
Upvotes: 2
Views: 159
Reputation: 378
Edit 2:
Given the updated post and the examples, I get what you're about now.
So generally in flutter, we catch specific exception "types", for example:
try {...} on FirebaseException catch(e) {} on http.ClientException catch (e) {}
The last catch (e) {}
block will catch every other exception that you didn't explicitly specify with an on
.
However, in the case of an exception caused by user, maybe a 404 from an api, we get that from the response.statusCode, and in that case we give that error message back to the user (if we trust that the API has messages that follow proper UX principles)...
try {
final response = networkClient.get(...);
if (response.statusCode != 200) {
//...return the message from the API
}
} //... your catch blocks
Othewise for every other exception we "catch", unlike the response ones, we only log them for ourselves (the developer) then we return a generic 500 error for the user, like a "Something went wrong, it's our fault not yours".
For the specific usecase where you need to know whether the user has network issues, you mostly want a middleware that checks for the user's network connection prior to making a request, so if there's no network connection, you alert the user and do not even attempt the request in the first place, if they do have a proper connection but you still catch an exception that says something about unreachable host or server whatever, then it's not the user's to deal with, you only log that for yourself and give them the generic 500 message.... whatever that is for your UX flow.
class AuthRemoteDataSrcImpl implements AuthRemoteDataSrc {
const AuthRemoteDataSrcImpl(this._client);
final http.Client _client;
@override
Future<void> register({
required String name,
required String password,
required String email,
required String phone,
}) async {
try {
final uri = Uri.parse('${NetworkConstants.baseUrl}$REGISTER_ENDPOINT');
final response = await _client.post(
uri,
body: jsonEncode({
'name': name,
'password': password,
'email': email,
'phone': phone,
}),
headers: NetworkConstants.headers,
);
if (response.statusCode != 200 && response.statusCode != 201) {
final payload = jsonDecode(response.body) as DataMap;
log(payload['message'], stackTrace: StackTrace.current);
throw FriendlyException(
message: payload['message'],
statusCode: response.statusCode,
);
}
} on FriendlyException{
rethrow;
} catch (e, s) {
log('Error occurred', error: e, stackTrace: s, level: 1200);
throw FriendlyException(message: 'Something went wrong', statusCode: 500);
}
}
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
const AuthRemoteDataSourceImpl({
required FirebaseAuth auth,
required FirebaseFirestore firestore,
}) : _auth = auth,
_firestore = firestore;
final FirebaseAuth _auth;
final FirebaseFirestore _firestore;
@override
Future<void> initiatePasswordReset(String email) async {
try {
await _auth.sendPasswordResetEmail(email: email);
} on FirebaseException catch (e) {
// you can filter based on the firebase code here
// or just use the message returned by the sdk since it's pretty UX friendly
throw FriendlyException(...);
} on FriendlyException {
rethrow;
} catch (e, s) {
// log error
throw FriendlyException(...);
}
}
}
For user-caused network exceptions, you either check before each request and throw or add a listener to the UI and when the user's network is gone, show a snackbar or something and expose a global network tracker that even your repositories can react to
Future<void> foo() async {
if (Connection.isDead) return;
// In a case where you don't have a listener in the UI that's already alerted the user, then you probably wanna throw an exception as opposed to returning.
try {...}
}
EoU -- End of Update
Well you don't "catch" them, you check the status code of the response for API errors
try {
final response = httpClient.get(...);
// Handle API error
if (response.statusCode != 200) {
// means it's an error, statuscode for success could actually also be 201 for post or put requests, 204 for delete requests and so on, you just gotta know what statuscode the API returns for success for the endpoint you hit
// handle error how you want
}
} catch (e, s) {
// The error you catch here isn't from the API, it's from your own code, maybe you used a wrong method, or some error that isn't caught compile time, so it gets caught runtime...so the catch block isn't for your API errors.
}
If you catch a socket error in your catch block, it's not from the API, it's either from your code or from the user's network connection.
For stuff like FirebaseException
you can catch them because they aren't raw API calls, you're using an SDK, so the API call is happening within that SDK and they are throwing an error as a way to handle the API error or any other error they receive.
Update:
Firebase throws only one exception type and that's FirebaseException
, when you catch that exception in particular then you can check its code
, but my point is you just have to consider the one exception type. If you wanna know the possibile codes for Firebase messaging in particular check here
Upvotes: 1
Reputation: 1478
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.
Upvotes: 1