Error Handling #

In professional mobile app development, the way you handle errors is one of the main differentiators between amateur apps and successful production-grade apps. When a network disruption occurs, end users don’t care about convoluted technical messages like DioException [connection error]: SocketException: Connection refused. Users only want clear information about what went wrong, an easy option to retry, and a guarantee that the app won’t suddenly force-close (crash) or show a blank white screen.

Managing network errors properly requires you to build a comprehensive error handling system at every architecture layer. You must catch technical errors at the lowest level (HTTP Client layer), map them into meaningful exception objects in the data layer, flow them declaratively in the business layer, and render them user-friendly in the interface (UI) layer.

In this guide, we’ll discuss network error handling strategies in Flutter in depth. We’ll learn error categorization, design custom exception hierarchies, automate error mapping via interceptors, apply the Either Pattern from functional programming, and design robust, friendly UI widgets.

Networking Error Categories #

Before we start writing error handling code, we need to identify and categorize the various types of errors that can potentially occur during network communication:

  1. Network Connection Disruption (Network Offline): Happens when the user’s device completely loses physical internet connectivity (e.g., the user is in a tunnel, has airplane mode enabled, or the data quota is exhausted).
  2. Timeout: Happens when the physical connection is established but the backend server is too slow to respond due to excessive workload (overload) or the user’s internet signal is very slow.
  3. Client Errors (HTTP 4xx): Caused by parameter-sending errors from your app to the server (e.g., wrong email/password input (400/422), expired authentication token (401), no access permission (403), or the searched data has been deleted (404)).
  4. Server Errors (HTTP 5xx): Caused by internal disruptions in your backend server code (like a server database connection failure (500) or the server being down for maintenance (503)).
  5. Data Parsing Failures: Happen when the server successfully replies with a 200 OK status, but the response body content format changes beyond agreement (e.g., the server sends raw HTML documents containing error messages when your app expects JSON format).
  6. Request Cancellation: Happens when a request is deliberately stopped by the app (e.g., the user leaves the page before the download process completes).

Designing a Custom Exception Hierarchy #

Dart provides the built-in Exception class, but that class is too generic to distinguish the various network error types above. You must design your own custom exception class hierarchy based on AppException so you can handle each error type with different visual handling in the UI.

Here’s the recommended custom exception hierarchy class structure for your Flutter project:

// core/errors/exceptions.dart

/// Base class for all exceptions in our app
abstract class AppException implements Exception {
  final String message;
  final String? details;

  const AppException(this.message, {this.details});

  @override
  String toString() => 'AppException: $message${details != null ? ' ($details)' : ''}';
}

/// Error due to no physical internet connection
class NetworkException extends AppException {
  const NetworkException([String message = 'No internet connection. Check your Wi-Fi or data plan again.'])
      : super(message);
}

/// Error due to request timeout
class TimeoutException extends AppException {
  const TimeoutException([String message = 'Connection timed out. Please try again in a moment.'])
      : super(message);
}

/// Base class for all HTTP status code errors (4xx & 5xx)
class HttpException extends AppException {
  final int statusCode;

  const HttpException(String message, this.statusCode, {String? details})
      : super(message, details: details);
}

/// HTTP 401 - Unauthorized access
class UnauthorizedException extends HttpException {
  const UnauthorizedException([String message = 'Your login session has expired. Please sign in again.'])
      : super(message, 401);
}

/// HTTP 403 - Access denied (Forbidden)
class ForbiddenException extends HttpException {
  const ForbiddenException([String message = 'You don\'t have permission to access this section.'])
      : super(message, 403);
}

/// HTTP 404 - Data not found (Not Found)
class NotFoundException extends HttpException {
  const NotFoundException([String message = 'The data you\'re looking for wasn\'t found on the server.'])
      : super(message, 404);
}

/// HTTP 422 - Form Input Validation Failure (Validation Error)
class ValidationException extends HttpException {
  // Stores the error message map for each input field
  final Map<String, List<String>>? fieldErrors;

  const ValidationException({
    String message = 'The entered data is invalid.',
    this.fieldErrors,
  }) : super(message, 422);
}

/// HTTP 5xx - Internal Server Error
class ServerException extends AppException {
  const ServerException([String message = 'Our server is experiencing internal issues. Please try again later.'])
      : super(message);
}

/// Error due to deliberate request cancellation
class RequestCancelledException extends AppException {
  const RequestCancelledException([String message = 'The request was cancelled by the system.'])
      : super(message);
}

/// Error due to JSON format parsing failure
class ParseException extends AppException {
  const ParseException([String message = 'Failed to parse the data format from the server.'])
      : super(message);
}

Error Interceptor: Centralized Conversion with Dio #

After designing the custom exception classes, the next challenge is: how do you automatically convert the DioException thrown by Dio into your AppException? Writing try-catch blocks in every API class to translate exceptions is very impractical.

The best way is to create a custom Error Interceptor responsible for centrally intercepting all errors at the lowest level, mapping their types, then flowing AppException to the layer above.

Here’s a diagram of how the ErrorInterceptor acts as an error filtering checkpoint in your app:

graph TD
    classDef default stroke:#333,stroke-width:2px;
    
    A["DioException Occurs"] --> B{"DioException Type?"}
    
    B -->|Timeout| C["TimeoutException"]
    B -->|Cancel| D["RequestCancelledException"]
    B -->|ConnectionError| E["NetworkException"]
    B -->|BadResponse| F{"HTTP Status Code?"}
    
    F -->|401| G["UnauthorizedException (Triggers Global Logout)"]
    F -->|403| H["ForbiddenException"]
    F -->|404| I["NotFoundException"]
    F -->|422| J["ValidationException (Contains Field Errors Map)"]
    F -->|5xx| K["ServerException"]
    F -->|Other| L["Generic HttpException"]
    
    C & D & E & G & H & I & J & K & L --> M["Mapped to AppException & Forwarded to UI"]

ErrorInterceptor Code Implementation #

// core/network/error_interceptor.dart
import 'package:dio/dio.dart';
import '../errors/exceptions.dart';

class ErrorInterceptor extends Interceptor {
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    // 1. Convert the DioException into our custom AppException
    final appException = _mapException(err);

    // 2. Forward the new error by wrapping it into a DioException.copyWith object
    handler.next(
      err.copyWith(
        error: appException,
        message: appException.message,
      ),
    );
  }

  AppException _mapException(DioException err) {
    switch (err.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        return const TimeoutException();

      case DioExceptionType.connectionError:
        return const NetworkException();

      case DioExceptionType.cancel:
        return const RequestCancelledException();

      case DioExceptionType.badResponse:
        // Triggered when the server replies with a 4xx or 5xx status
        return _parseHttpError(err.response);

      default:
        return AppException('An unknown network disruption occurred: ${err.message}');
    }
  }

  AppException _parseHttpError(Response? response) {
    if (response == null) return const ServerException();

    final statusCode = response.statusCode ?? 0;
    final dataBody = response.data;

    // Extract the error message sent by the backend server
    final String serverMessage = _extractMessageFromResponseBody(dataBody);

    switch (statusCode) {
      case 400:
        return HttpException(serverMessage.isNotEmpty ? serverMessage : 'Invalid request.', 400);
      case 401:
        return UnauthorizedException(serverMessage.isNotEmpty ? serverMessage : 'Login session ended.');
      case 403:
        return ForbiddenException(serverMessage.isNotEmpty ? serverMessage : 'Access denied.');
      case 404:
        return NotFoundException(serverMessage.isNotEmpty ? serverMessage : 'Data not found.');
      case 422:
        // Parse the specific per-field form validation errors
        final fieldErrors = _extractValidationDetails(dataBody);
        return ValidationException(
          message: serverMessage.isNotEmpty ? serverMessage : 'Form data is invalid.',
          fieldErrors: fieldErrors,
        );
      case >= 500:
        return ServerException(serverMessage.isNotEmpty ? serverMessage : 'The server is having issues.');
      default:
        return HttpException('Network error: $statusCode', statusCode);
    }
  }

  String _extractMessageFromResponseBody(dynamic data) {
    if (data is Map) {
      // Adjust to your backend server's error response JSON structure
      return (data['message'] ?? data['error'] ?? '').toString();
    }
    return '';
  }

  Map<String, List<String>>? _extractValidationDetails(dynamic data) {
    if (data is! Map) return null;
    final errors = data['errors']; // Standard Laravel/Rails error structure: {"errors": {"email": ["wrong format"]}}
    if (errors is! Map) return null;

    return errors.map(
      (key, value) => MapEntry(
        key.toString(),
        (value as List).map((item) => item.toString()).toList(),
      ),
    );
  }
}

The Either Pattern: Handling Errors Declaratively #

Once exceptions are centrally mapped by the interceptor, how do you flow them to the business logic layer? The habit of throwing exceptions directly using the throw keyword requires you to always wrap every function call with a try-catch block at the controller level. If you forget to wrap it, your app risks runtime crashes.

To avoid repetitive try-catch usage, modern architecture applies the Either Pattern from the functional programming paradigm. Using the fpdart package, functions return a single object of type Either<L, R>:

  • Left (L): Stores the error object (AppException) if the process fails.
  • Right (R): Stores the success data object (T) if the process succeeds.

Add the fpdart dependency to your pubspec.yaml file:

dependencies:
  fpdart: ^1.1.0

Implementing the Either Pattern in the Repository #

Let’s see how Either is used cleanly in your app’s Repository layer:

import 'package:fpdart/fpdart.dart';
import '../errors/exceptions.dart';
import '../entities/product.dart';

abstract class ProductRepository {
  // The function returns Either: Left is AppException, Right is the product list
  Future<Either<AppException, List<Product>>> fetchProductList();
}

class ProductRepositoryImpl implements ProductRepository {
  final ProductRemoteDataSource _remote;
  ProductRepositoryImpl(this._remote);

  @override
  Future<Either<AppException, List<Product>>> fetchProductList() async {
    try {
      final dtos = await _remote.getProducts();
      final entityList = dtos.map((dto) => dto.toDomain()).toList();
      
      // Return the success status wrapped in Right
      return Right(entityList);
    } on DioException catch (e) {
      // Catching errors already converted by our ErrorInterceptor
      final appException = e.error is AppException 
          ? e.error as AppException 
          : AppException(e.message ?? 'Unknown error');
          
      // Return the error status wrapped in Left
      return Left(appException);
    } catch (e) {
      return Left(AppException('Failed to process data: $e'));
    }
  }
}

Consuming Either in the State Management Layer (Notifier) #

To process the Either result, you use the fold() method which forces you to explicitly handle both sides (left and right):

class ProductNotifier extends AutoDisposeAsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() async {
    final repository = ref.watch(productRepositoryProvider);
    final eitherResult = await repository.fetchProductList();

    return eitherResult.fold(
      // Left side: Convert into a throw so it's read by AsyncValue.error in the UI
      (appException) => throw appException,
      
      // Right side: Return the success data
      (productList) => productList,
    );
  }
}

Retry Logic with Exponential Backoff #

Sometimes network errors are only temporary (transient errors), like losing a Wi-Fi signal for 1 second or a momentarily overloaded backend server. Rather than immediately showing an error screen to the user, it’s better if your app automatically retries sending that request in the background.

You can create a custom Retry Interceptor with the Exponential Backoff technique. The waiting time between attempts increases exponentially (e.g., attempt 1 after 1 second, attempt 2 after 2 seconds, attempt 3 after 4 seconds) to avoid overloading your busy backend server.

class RetryInterceptor extends Interceptor {
  final Dio dio;
  final int maxAttempts;
  final Duration initialDelay;

  RetryInterceptor({
    required this.dio,
    this.maxAttempts = 3,
    this.initialDelay = const Duration(seconds: 1),
  });

  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    final options = err.requestOptions;
    
    // Check the current attempt count from the request options extra Map
    final currentAttempt = options.extra['retry_count'] as int? ?? 0;

    // We only retry requests for transient connection errors
    if (_isTransientError(err) && currentAttempt < maxAttempts) {
      final nextAttempt = currentAttempt + 1;
      
      // Calculate the delay using the exponential backoff technique (delay * 2^attempt)
      final delayDuration = initialDelay * (nextAttempt * 2);
      
      print('Connection failed. Retrying attempt #$nextAttempt after ${delayDuration.inSeconds} seconds...');
      await Future.delayed(delayDuration);

      try {
        // Resend the request including the new attempt index
        final response = await dio.fetch(
          options.copyWith(
            extra: {
              ...options.extra,
              'retry_count': nextAttempt,
            },
          ),
        );
        // If successful on retry, resolve with the success response
        handler.resolve(response);
        return;
      } on DioException catch (e) {
        // If it fails again, let the next on_error loop handle it
        err = e;
      }
    }

    // If the attempt limit is exceeded or it's not a transient error, forward the error upward
    handler.next(err);
  }

  bool _isTransientError(DioException err) {
    return err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.receiveTimeout ||
        err.type == DioExceptionType.connectionError ||
        // 503 Service Unavailable status is worth retrying
        (err.response?.statusCode == 503);
  }
}

Designing an Error-Resilient User Interface (UI) #

On the UI screen, you must provide informative visual feedback for users. It’s highly recommended to create one reusable error display widget called ErrorView.

This widget will automatically adjust its icon based on the error type (e.g., a Wi-Fi-off image if offline) and provide a retry button.

Implementing the ErrorView Widget #

// presentation/widgets/error_view.dart
import 'package:flutter/material.dart';
import '../../core/errors/exceptions.dart';

class ErrorView extends StatelessWidget {
  final String errorTitle;
  final String? detailDescription;
  final IconData visualIcon;
  final VoidCallback? retryAction;

  const ErrorView({
    super.key,
    required this.errorTitle,
    this.detailDescription,
    this.visualIcon = Icons.error_outline_rounded,
    this.retryAction,
  });

  // Factory constructor to make initialization easy directly from our AppException
  factory ErrorView.fromException(AppException exception, {VoidCallback? retry}) {
    return ErrorView(
      errorTitle: exception.message,
      detailDescription: exception.details,
      visualIcon: exception is NetworkException 
          ? Icons.wifi_off_rounded 
          : exception is TimeoutException 
              ? Icons.timer_off_outlined 
              : Icons.error_outline_rounded,
      retryAction: retry,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(visualIcon, size: 72, color: Theme.of(context).colorScheme.error),
            const SizedBox(height: 20),
            Text(
              errorTitle,
              style: Theme.of(context).textTheme.titleMedium?.copyWith(
                    fontWeight: FontWeight.bold,
                  ),
              textAlign: TextAlign.center,
            ),
            if (detailDescription != null) ...[
              const SizedBox(height: 8),
              Text(
                detailDescription!,
                style: Theme.of(context).textTheme.bodySmall,
                textAlign: TextAlign.center,
              ),
            ],
            if (retryAction != null) ...[
              const SizedBox(height: 28),
              ElevatedButton.icon(
                onPressed: retryAction,
                icon: const Icon(Icons.refresh_rounded),
                label: const Text('Retry'),
                style: ElevatedButton.styleFrom(
                  padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

Dynamic Form Validation Based on API Errors #

When the backend server rejects your registration request because of invalid input data (HTTP 422), the server usually returns detailed error messages for each field (e.g., “email is already registered”, “password is too short”).

You must catch this ValidationException and display those error texts right below each relevant form input widget dynamically.

class RegistrationScreen extends ConsumerStatefulWidget {
  const RegistrationScreen({super.key});

  @override
  ConsumerState<RegistrationScreen> createState() => _RegistrationScreenState();
}

class _RegistrationScreenState extends ConsumerState<RegistrationScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    // Reading the registration state from the notifier
    final registrationState = ref.watch(registrationProvider);

    // Check whether the error is a ValidationException type
    final fieldErrors = registrationState.error is ValidationException
        ? (registrationState.error as ValidationException).fieldErrors
        : null;

    return Scaffold(
      appBar: AppBar(title: const Text('Account Registration')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextFormField(
              controller: _emailController,
              decoration: InputDecoration(
                labelText: 'Email',
                // Display the specific email field error message from the API
                errorText: fieldErrors?['email']?.first,
              ),
            ),
            const SizedBox(height: 16),
            TextFormField(
              controller: _passwordController,
              obscureText: true,
              decoration: InputDecoration(
                labelText: 'Password',
                // Display the specific password field error message from the API
                errorText: fieldErrors?['password']?.first,
              ),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: registrationState.isLoading
                  ? null
                  : () {
                      ref.read(registrationProvider.notifier).register(
                            _emailController.text,
                            _passwordController.text,
                          );
                    },
              child: const Text('Register'),
            ),
          ],
        ),
      ),
    );
  }
}

Global Error Handling #

There are several network error types whose handling must be done globally outside the current UI screen. The classic example is the HTTP 401 Unauthorized status code.

If the server returns a 401 status on any endpoint (because the token expired or was deleted by an admin), the app must immediately stop the current activity, remove the token from secure local storage, and forcibly redirect the user back to the login page.

You implement this forced logout flow globally using coordination between the Dio Interceptor and your app’s router state listener:

// 1. Initialize a Global Navigator Key for BuildContext-Free Navigation
final rootNavigatorKey = GlobalKey<NavigatorState>();

class AuthErrorInterceptor extends Interceptor {
  final ProviderContainer _container;

  AuthErrorInterceptor(this._container);

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    if (err.response?.statusCode == 401) {
      print('Global 401 error occurred. Forcing the user to log out.');
      
      // Trigger the forced logout action on State Management centrally
      _container.read(authNotifierProvider.notifier).forceLogout();
    }
    super.onError(err, handler);
  }
}

// 2. Connect the authentication status at our root MaterialApp widget level
class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Monitor authentication status changes globally
    ref.listen<AuthState>(authNotifierProvider, (previousState, currentState) {
      if (currentState is AuthStateUnauthenticated) {
        // Forcibly redirect the user to the login screen using the global Navigator Key
        rootNavigatorKey.currentState?.pushAndRemoveUntil(
          MaterialPageRoute(builder: (_) => const LoginScreen()),
          (route) => false,
        );
      }
    });

    return MaterialApp(
      navigatorKey: rootNavigatorKey, // Register the global Navigator Key
      home: const HomeScreen(),
    );
  }
}

With this architectural flow, expired token handling is managed centrally in the background without dirtying each page’s visual logic files.

Summary #

  • Designing a Custom Exception Hierarchy based on AppException makes grouping network errors easier (Timeout, Offline, Server Error) so the UI can display the right feedback.
  • Error Interceptors catch the technical DioException and automatically translate it into your custom exception classes centrally.
  • The Either Pattern (Either<AppException, T>) channels error status (Left) or success (Right) as regular function return values, eliminating try-catch block writing at the controller level.
  • Automatic Retry Logic with Exponential Backoff helps retry requests for transient connection errors to increase app stability.
  • Reusable ErrorView Widgets must always include a retry button and easy-to-understand user-friendly messages.
  • Dynamic Validation Error Parsing (422) maps form input failure messages directly to each relevant input column’s error text in the UI.
  • Global Error Handling coordinates security failure status (401) at the interceptor level to reset the login session and forcibly redirect users to the login page from anywhere.

← Previous: Repository Pattern   Next: Authentication →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact