DEV Community

koreanDev
koreanDev

Posted on

Why I Switched from http to Dio in Flutter — Centralizing Error Tracking with Interceptors

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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});
Enter fullscreen mode Exit fullscreen mode

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 injects ApiClient instead of calling http.

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)