image

29 Mar 2026

9K

35K

Flutter & Dio: Handling Multiple API Errors and Retry Mechanisms

Developing robust Flutter applications often involves interacting with various backend APIs. While successful API calls are the desired outcome, a professional application must gracefully handle the inevitable: API errors. From network outages to server-side issues and authentication failures, a comprehensive error handling and retry strategy is paramount for a smooth user experience. This article explores how to leverage Dio, a powerful HTTP client for Dart, to implement sophisticated error handling and retry mechanisms in your Flutter applications.

The Challenge of API Errors

API errors come in many forms, each requiring a potentially different response:

  • Network Errors: (e.g., no internet connection, DNS lookup failed). These often manifest as DioErrorType.connectionTimeout, DioErrorType.receiveTimeout, or DioErrorType.sendTimeout.
  • Client-Side Errors: (e.g., 400 Bad Request, 404 Not Found, 422 Unprocessable Entity). These indicate issues with the request sent by the client.
  • Authentication Errors: (e.g., 401 Unauthorized, 403 Forbidden). Token expiry or invalid credentials.
  • Server-Side Errors: (e.g., 500 Internal Server Error, 503 Service Unavailable). Issues on the backend.
  • Rate Limiting: (e.g., 429 Too Many Requests). When a client sends too many requests in a given time frame.

Without a structured approach, developers might end up with scattered try-catch blocks, leading to inconsistent error messages and a poor user experience. Dio's interceptor system provides an elegant solution for centralizing error handling and implementing retry logic.

Dio's Interceptors for Centralized Error Handling

Dio's interceptors allow you to intercept and modify requests, responses, and errors before they are handled by the calling code. This makes them ideal for implementing global error handling and retry logic.

1. Defining Custom Exceptions

To standardize error reporting across your application, it's best to define custom exception classes. This allows your UI and business logic layers to react consistently to different types of API failures.


// lib/core/error/exceptions.dart

abstract class AppException implements Exception {
  final String message;
  final int? statusCode;

  AppException(this.message, {this.statusCode});

  @override
  String toString() {
    return "AppException: $message ${statusCode != null ? '(Status: $statusCode)' : ''}";
  }
}

class NetworkException extends AppException {
  NetworkException(String message) : super(message);
}

class ServerException extends AppException {
  ServerException(String message, {int? statusCode}) : super(message, statusCode: statusCode);
}

class UnauthorizedException extends AppException {
  UnauthorizedException(String message) : super(message, statusCode: 401);
}

class BadRequestException extends AppException {
  BadRequestException(String message, {int? statusCode}) : super(message, statusCode: statusCode);
}

class NotFoundException extends AppException {
  NotFoundException(String message) : super(message, statusCode: 404);
}

class ForbiddenException extends AppException {
  ForbiddenException(String message) : super(message, statusCode: 403);
}

class ConflictException extends AppException {
  ConflictException(String message) : super(message, statusCode: 409);
}

class NoInternetConnectionException extends AppException {
  NoInternetConnectionException(String message) : super(message);
}

class UnknownException extends AppException {
  UnknownException(String message) : super(message);
}

class TooManyRequestsException extends AppException {
  TooManyRequestsException(String message) : super(message, statusCode: 429);
}

2. The Error Interceptor

An error interceptor maps Dio's internal DioException (formerly DioError) to your custom AppException types. This ensures that your application always deals with a consistent exception hierarchy.


// lib/core/network/error_interceptor.dart

import 'package:dio/dio.dart';
import '../error/exceptions.dart';

class ErrorInterceptor extends Interceptor {
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    AppException exception;
    switch (err.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        exception = NetworkException("Connection timed out. Please try again.");
        break;
      case DioExceptionType.badResponse:
        final statusCode = err.response?.statusCode;
        final errorData = err.response?.data;
        String errorMessage = errorData?['message'] ?? "Something went wrong.";

        switch (statusCode) {
          case 400:
            exception = BadRequestException(errorMessage, statusCode: statusCode);
            break;
          case 401:
            exception = UnauthorizedException("Authentication required.");
            break;
          case 403:
            exception = ForbiddenException("Access denied.");
            break;
          case 404:
            exception = NotFoundException("Resource not found.");
            break;
          case 409:
            exception = ConflictException(errorMessage, statusCode: statusCode);
            break;
          case 429:
            exception = TooManyRequestsException("Too many requests. Please wait.");
            break;
          case 500:
          case 502:
          case 503:
            exception = ServerException("Server error. Please try again later.", statusCode: statusCode);
            break;
          default:
            exception = ServerException("Received invalid status code: $statusCode", statusCode: statusCode);
            break;
        }
        break;
      case DioExceptionType.cancel:
        exception = UnknownException("Request cancelled.");
        break;
      case DioExceptionType.unknown:
        if (err.error is SocketException) { // Handle no internet connectivity
          exception = NoInternetConnectionException("No internet connection.");
        } else {
          exception = UnknownException("An unexpected error occurred.");
        }
        break;
      default:
        exception = UnknownException("An unexpected error occurred.");
        break;
    }
    // Pass our custom exception down the chain
    handler.next(err.copyWith(error: exception));
  }
}

Implementing a Retry Mechanism

A retry mechanism automatically re-sends failed requests under certain conditions (e.g., temporary network glitches, rate limiting). This can significantly improve the user experience by transparently handling transient issues.

3. The Retry Interceptor

A custom retry interceptor can analyze failed requests and decide whether to retry them. We'll use a simple approach with a maximum number of retries and a fixed or exponential delay.


// lib/core/network/retry_interceptor.dart

import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; // For checking network status
import 'dart:async'; // For Future.delayed

class RetryInterceptor extends Interceptor {
  final Dio dio;
  final Connectivity connectivity;
  final int retries;
  final Duration retryDelay;

  RetryInterceptor({
    required this.dio,
    required this.connectivity,
    this.retries = 3,
    this.retryDelay = const Duration(seconds: 1),
  });

  @override
  Future onError(DioException err, ErrorInterceptorHandler handler) async {
    final RequestOptions requestOptions = err.requestOptions;
    final int attempt = requestOptions.extra['retry_attempt'] ?? 0;

    bool shouldRetry = false;

    // Condition 1: Check if max retries reached
    if (attempt < retries) {
      // Condition 2: Check error type
      if (err.type == DioExceptionType.connectionTimeout ||
          err.type == DioExceptionType.receiveTimeout ||
          err.type == DioExceptionType.sendTimeout ||
          err.error is SocketException) { // Network related errors
        shouldRetry = true;
      } else if (err.response?.statusCode == 429) { // Rate limit
        shouldRetry = true;
        // Optionally parse 'Retry-After' header and use that delay
      } else if (err.response?.statusCode == 500 || err.response?.statusCode == 502 || err.response?.statusCode == 503 || err.response?.statusCode == 504) {
        // Server errors that might be transient
        shouldRetry = true;
      }
    }

    if (shouldRetry) {
      // Check for internet connectivity before retrying network-related errors
      if (err.error is SocketException) {
        final connectivityResult = await connectivity.checkConnectivity();
        if (connectivityResult == ConnectivityResult.none) {
          handler.next(err); // No internet, don't retry, pass original error
          return;
        }
      }

      await Future.delayed(retryDelay * (attempt + 1)); // Exponential backoff is better
      print("Retrying request ${requestOptions.path} (attempt ${attempt + 1})");

      try {
        // Increment retry attempt in extra map
        requestOptions.extra['retry_attempt'] = attempt + 1;
        // Clone the request and re-send it
        final response = await dio.fetch(requestOptions);
        handler.resolve(response); // If successful, resolve the response
      } on DioException catch (e) {
        handler.next(e); // If retry fails, pass the new error
      }
    } else {
      // Not retrying, continue with the original error
      handler.next(err);
    }
  }
}

Note: For production, consider using a package like dio_smart_retry which offers more advanced features like exponential backoff and custom retry conditions.

Handling Authentication Refresh

For APIs requiring authentication, expired tokens are a common issue (401 Unauthorized). An interceptor can detect this, refresh the token, and retry the original request.


// lib/core/network/auth_interceptor.dart

import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';

class AuthInterceptor extends Interceptor {
  final Dio _dio; // Use a separate Dio instance for token refresh to avoid circular dependency
  final SharedPreferences _prefs;

  AuthInterceptor(this._dio, this._prefs);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final String? accessToken = _prefs.getString('accessToken');
    if (accessToken != null && options.headers['Authorization'] == null) {
      options.headers['Authorization'] = 'Bearer $accessToken';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      // Check if it's already a refresh token request to avoid infinite loops
      if (err.requestOptions.path == '/auth/refresh') {
        _prefs.remove('accessToken'); // Clear invalid token
        _prefs.remove('refreshToken');
        // Redirect to login or show re-authentication prompt
        // You might want to use a global event bus or navigator key here
        handler.next(err);
        return;
      }

      final String? refreshToken = _prefs.getString('refreshToken');
      if (refreshToken != null) {
        try {
          // Attempt to refresh token
          final Response refreshResponse = await _dio.post(
            '/auth/refresh', // Your refresh token endpoint
            data: {'refreshToken': refreshToken},
          );

          if (refreshResponse.statusCode == 200) {
            final newAccessToken = refreshResponse.data['accessToken'];
            final newRefreshToken = refreshResponse.data['refreshToken'];

            await _prefs.setString('accessToken', newAccessToken);
            await _prefs.setString('refreshToken', newRefreshToken);

            // Retry the original request with the new access token
            final originalRequest = err.requestOptions;
            originalRequest.headers['Authorization'] = 'Bearer $newAccessToken';

            final response = await _dio.fetch(originalRequest);
            handler.resolve(response);
            return;
          }
        } on DioException catch (_) {
          // Refresh token failed
          _prefs.remove('accessToken');
          _prefs.remove('refreshToken');
          // Redirect to login
        }
      }
      // If refresh token is null or refresh failed, pass the 401 error
      // to let the calling code handle re-authentication
    }
    handler.next(err);
  }
}

Putting It All Together: A Comprehensive Dio Client

Now, let's configure our Dio instance with all the interceptors.


// lib/core/network/dio_client.dart

import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'error_interceptor.dart';
import 'retry_interceptor.dart';
import 'auth_interceptor.dart';

class DioClient {
  final Dio _dio;
  final SharedPreferences _prefs;
  final Connectivity _connectivity;

  DioClient(this._prefs, this._connectivity)
      : _dio = Dio(BaseOptions(
          baseUrl: 'https://your-api-base-url.com', // Replace with your API base URL
          connectTimeout: const Duration(seconds: 10),
          receiveTimeout: const Duration(seconds: 10),
          contentType: 'application/json',
        )) {
    // Add interceptors in order
    // Logging should usually be first to log the initial request
    _dio.interceptors.add(LogInterceptor(
      requestBody: true,
      responseBody: true,
      logPrint: (obj) => print(obj), // Custom logger, e.g., using a logger package
    ));
    // Authentication interceptor before error/retry
    _dio.interceptors.add(AuthInterceptor(_dio, _prefs));
    // Error interceptor to map DioErrors to custom AppExceptions
    _dio.interceptors.add(ErrorInterceptor());
    // Retry interceptor to handle transient errors
    _dio.interceptors.add(
      RetryInterceptor(
        dio: _dio,
        connectivity: _connectivity,
        retries: 3,
        retryDelay: const Duration(seconds: 2), // Example: exponential backoff could be implemented here
      ),
    );
  }

  Dio get instance => _dio;
}

Example Usage in main.dart or Dependency Injection


// main.dart (or equivalent setup)
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:your_app_name/core/network/dio_client.dart';

final GetIt getIt = GetIt.instance;

Future setupLocator() async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  getIt.registerSingleton(prefs);
  getIt.registerSingleton(Connectivity());
  getIt.registerSingleton(DioClient(getIt(), getIt()));
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await setupLocator();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Dio Error Handling',
      home: HomePage(),
    );
  }
}

Consuming the API Service

Now, when you make API calls, you can catch your custom AppException instances directly.


// lib/data/repositories/user_repository.dart

import 'package:dio/dio.dart';
import 'package:your_app_name/core/network/dio_client.dart';
import 'package:your_app_name/core/error/exceptions.dart'; // Import custom exceptions
import 'package:get_it/get_it.dart';

class UserRepository {
  final Dio _dio = GetIt.instance().instance;

  Future<Map<String, dynamic>> fetchUserProfile(String userId) async {
    try {
      final response = await _dio.get('/users/$userId');
      return response.data;
    } on DioException catch (e) {
      // The ErrorInterceptor would have transformed DioError into AppException
      if (e.error is AppException) {
        throw e.error as AppException;
      }
      // If for some reason it's still a DioException (e.g., interceptor didn't handle it)
      throw UnknownException("Failed to fetch user profile: ${e.message}");
    } catch (e) {
      throw UnknownException("An unexpected error occurred: $e");
    }
  }

  Future<void> createUser(Map<String, dynamic> userData) async {
    try {
      await _dio.post('/users', data: userData);
    } on DioException catch (e) {
      if (e.error is AppException) {
        throw e.error as AppException;
      }
      throw UnknownException("Failed to create user: ${e.message}");
    } catch (e) {
      throw UnknownException("An unexpected error occurred: $e");
    }
  }
}

In Your UI/Business Logic


// lib/presentation/pages/home_page.dart

import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:your_app_name/data/repositories/user_repository.dart';
import 'package:your_app_name/core/error/exceptions.dart';

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State {
  final UserRepository _userRepository = GetIt.instance();
  String _message = "Ready to fetch user data.";

  Future _fetchUser() async {
    setState(() {
      _message = "Fetching user data...";
    });
    try {
      final userData = await _userRepository.fetchUserProfile('123'); // Example user ID
      setState(() {
        _message = "User Data: ${userData['name']}";
      });
    } on NetworkException {
      setState(() {
        _message = "Network Error: Please check your internet connection.";
      });
    } on UnauthorizedException {
      setState(() {
        _message = "Authentication Error: Please log in again.";
      });
      // Navigate to login screen
    } on NotFoundException {
      setState(() {
        _message = "Error: User not found.";
      });
    } on ServerException catch (e) {
      setState(() {
        _message = "Server Error (${e.statusCode}): ${e.message}";
      });
    } on TooManyRequestsException {
       setState(() {
        _message = "Rate Limit Exceeded: Please wait a moment.";
      });
    } on AppException catch (e) {
      setState(() {
        _message = "An application error occurred: ${e.message}";
      });
    } catch (e) {
      setState(() {
        _message = "An unknown error occurred: $e";
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('API Error Handling')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_message, textAlign: TextAlign.center),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: _fetchUser,
              child: Text('Fetch User Profile'),
            ),
          ],
        ),
      ),
    );
  }
}

Conclusion

Implementing a robust error handling and retry mechanism is essential for any professional Flutter application interacting with APIs. By leveraging Dio's powerful interceptor system, you can centralize complex logic for handling various error types, automatically retrying transient failures, and refreshing authentication tokens. This approach leads to cleaner code, a more consistent user experience, and a more resilient application. Remember to define clear custom exceptions, strategically place your interceptors, and gracefully handle these exceptions in your presentation layer to provide meaningful feedback to your users.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is