Your Flutter app has Crashlytics. Global handlers are set up. Feels safe.
Open the dashboard. How many API errors are recorded? Probably zero.
The problem
Dart's global error handler only catches uncaught Errors. In Dart, Error and Exception are different things. StackOverflowError is an Error — programming mistake. API failure is an Exception — expected, recoverable.
The thing is, API exceptions get caught in try-catch blocks. They never bubble up to the global handler. Crashlytics never sees them. Complete blind spot.
To track Exceptions, you need to record them separately. That's why I refactored my solo app Book Log's network layer from http to Dio.
Before: http package
// auth_repository.dart — repeat this for every request
final token = await _storage.read(key: 'serverToken');
final response = await http.post(
Uri.parse('$_baseUrl/user/fcm-token'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({'fcm_token': fcmToken}),
);
if (response.statusCode != 200) { ... }
// Crashlytics? gotta add it manually here
I had an ApiClient wrapper class. BookRepositoryImpl and SentenceRepositoryImpl used it. But AuthRepository was calling http directly. Crashlytics recording only happened inside ApiClient._execute(). All auth-related errors were in a blind spot.
After: Dio + Interceptors
Two interceptors. That's it.
AuthInterceptor — auto token injection
class _AuthInterceptor extends Interceptor {
_AuthInterceptor(this._storage);
final FlutterSecureStorage _storage;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final authenticated = options.extra['authenticated'] ?? true;
if (authenticated == true) {
final token = await _storage.read(key: _kServerTokenKey);
if (token == null) {
return handler.reject(
DioException(
requestOptions: options,
error: const ApiException(
statusCode: 401, message: 'Not logged in'),
),
);
}
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}
options.extra is a custom metadata map from Dio. Doesn't affect the HTTP request. Not sent to the server. Just a switch for the interceptor to decide: attach token or not.
-
authenticated: true→ token injected (post-login APIs) -
authenticated: false→ no token (Apple login, pre-auth APIs)
CrashlyticsInterceptor — auto error logging
class _CrashlyticsInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
FirebaseCrashlytics.instance.recordError(
err,
err.stackTrace,
reason: '${err.requestOptions.method} ${err.requestOptions.path}',
);
handler.next(err);
}
}
Every HTTP error recorded to Crashlytics. The reason shows POST /user/fcm-token so you know which API failed. Unlike the global handler, this catches all API failures regardless of try-catch.
Result
// Before
final response = await http.post(
Uri.parse('$_baseUrl/user/fcm-token'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({'fcm_token': fcmToken}),
);
// After
await _apiClient.post('/user/fcm-token', {'fcm_token': fcmToken});
Token injection, error handling, Crashlytics logging — all automatic.
Migration scope
Only two files were using http directly.
-
api_client.dart→ replaced with Dio. Kept existing method signatures. -
auth_repository.dart→ now injectsApiClientinstead of callinghttp.
BookRepositoryImpl and SentenceRepositoryImpl already used ApiClient. Zero changes needed. Keeping the get/post/patch signatures the same made this possible.
3 bugs from Claude Code
I delegated this migration to Claude Code. Overall result was solid. But server logs told a different story.
1. Wrong endpoint path
Original code used /validate-token from env variable. After migration it became /auth/validate. Route doesn't exist. 404.
2. Token not attached
validateToken was set to authenticated: false. Token validation API not sending the token. 400.
3. JSON key mismatch
Client sending fcmToken (camelCase). Server expecting fcm_token (snake_case). 400.
Found all three by running gcloud app logs tail. AI-generated code still needs manual verification. Especially env variables and API schemas — AI doesn't track those well.
Summary
| Before (http) | After (Dio) | |
|---|---|---|
| Token injection | manual per request | interceptor auto |
| Crashlytics | only via ApiClient | all APIs auto |
| Error handling | duplicated per file | centralized |
| AuthRepository | direct http calls | via ApiClient |
Set up error tracking infra before going to production. This migration was that work.
Top comments (0)