DartFlutter

How to Handle API Errors Gracefully in Flutter

How to Handle API Errors Gracefully in Flutter

Introduction

In Flutter apps that rely on external APIs, handling errors properly isn’t just a backend concern—it’s crucial for a smooth and reliable user experience. Network requests fail for countless reasons: slow connections, expired tokens, server outages, validation errors, or simply no internet at all. When these failures happen, your app shouldn’t crash, show cryptic exception messages, or leave users staring at blank screens. Apps from companies like Airbnb, Uber, and Stripe handle errors gracefully, maintaining user trust even when things go wrong. In this comprehensive guide, you’ll learn how to handle API errors gracefully in Flutter, covering error classification, custom exception hierarchies, the Either pattern with Dartz, centralized error handling with Dio interceptors, and UI patterns for displaying errors effectively.

Why Graceful Error Handling Matters

Poor error handling leads to:

Blank screens when API calls fail silently. Confusing technical messages like “SocketException” or “FormatException”. App crashes that destroy user sessions. Users abandoning your app after repeated failures.

Graceful handling provides:

Clear, actionable messages users can understand. Retry options for transient failures. Offline indicators when connectivity is lost. Logged errors for debugging without exposing internals to users.

Classifying API Errors

Different errors require different responses. Classify them systematically:

// lib/core/errors/failures.dart
abstract class Failure {
  final String message;
  final String? code;

  const Failure(this.message, {this.code});
}

// Network-level failures
class NetworkFailure extends Failure {
  const NetworkFailure([String message = 'No internet connection'])
      : super(message);
}

class TimeoutFailure extends Failure {
  const TimeoutFailure([String message = 'Request timed out. Please try again.'])
      : super(message);
}

// HTTP status code failures
class UnauthorizedFailure extends Failure {
  const UnauthorizedFailure([String message = 'Session expired. Please log in again.'])
      : super(message, code: '401');
}

class ForbiddenFailure extends Failure {
  const ForbiddenFailure([String message = 'You don\'t have permission to do this.'])
      : super(message, code: '403');
}

class NotFoundFailure extends Failure {
  const NotFoundFailure([String message = 'The requested resource was not found.'])
      : super(message, code: '404');
}

class ServerFailure extends Failure {
  const ServerFailure([String message = 'Server error. Please try again later.'])
      : super(message, code: '500');
}

// Validation failures
class ValidationFailure extends Failure {
  final Map>? fieldErrors;

  const ValidationFailure(
    String message, {
    this.fieldErrors,
  }) : super(message, code: '422');
}

// Generic API failure
class ApiFailure extends Failure {
  final int? statusCode;

  const ApiFailure(
    String message, {
    this.statusCode,
    String? code,
  }) : super(message, code: code);
}

Custom Exception Hierarchy

// lib/core/errors/exceptions.dart
abstract class AppException implements Exception {
  final String message;
  final int? statusCode;
  final dynamic originalError;

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

  @override
  String toString() => 'AppException: $message (status: $statusCode)';
}

class NetworkException extends AppException {
  const NetworkException([String message = 'Network error occurred'])
      : super(message);
}

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

class UnauthorizedException extends AppException {
  const UnauthorizedException([String message = 'Unauthorized'])
      : super(message, statusCode: 401);
}

class ValidationException extends AppException {
  final Map>? errors;

  const ValidationException(
    String message, {
    this.errors,
  }) : super(message, statusCode: 422);
}

Centralized Error Handling with Dio Interceptors

// lib/core/network/error_interceptor.dart
import 'package:dio/dio.dart';
import 'dart:io';

class ErrorInterceptor extends Interceptor {
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    final exception = _mapDioException(err);
    handler.reject(
      DioException(
        requestOptions: err.requestOptions,
        error: exception,
        type: err.type,
        response: err.response,
      ),
    );
  }

  AppException _mapDioException(DioException err) {
    switch (err.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        return const NetworkException('Connection timed out. Please check your internet.');

      case DioExceptionType.connectionError:
        return const NetworkException('No internet connection.');

      case DioExceptionType.badResponse:
        return _handleBadResponse(err.response);

      case DioExceptionType.cancel:
        return const AppException('Request was cancelled');

      default:
        if (err.error is SocketException) {
          return const NetworkException('Network error. Please check your connection.');
        }
        return ServerException('Unexpected error: ${err.message}');
    }
  }

  AppException _handleBadResponse(Response? response) {
    if (response == null) {
      return const ServerException('No response from server');
    }

    final statusCode = response.statusCode;
    final data = response.data;
    final message = data is Map ? data['message'] ?? data['error'] : null;

    switch (statusCode) {
      case 400:
        return ServerException(message ?? 'Bad request', statusCode: 400);

      case 401:
        return UnauthorizedException(message ?? 'Session expired');

      case 403:
        return ServerException(message ?? 'Access denied', statusCode: 403);

      case 404:
        return ServerException(message ?? 'Resource not found', statusCode: 404);

      case 422:
        final errors = data is Map ? data['errors'] as Map? : null;
        return ValidationException(
          message ?? 'Validation failed',
          errors: errors?.map((k, v) => MapEntry(k, List.from(v))),
        );

      case 429:
        return ServerException('Too many requests. Please slow down.', statusCode: 429);

      case 500:
      case 502:
      case 503:
        return ServerException('Server error. Please try again later.', statusCode: statusCode);

      default:
        return ServerException(message ?? 'Something went wrong', statusCode: statusCode);
    }
  }
}

// lib/core/network/api_client.dart
class ApiClient {
  late final Dio _dio;

  ApiClient() {
    _dio = Dio(BaseOptions(
      baseUrl: 'https://api.example.com',
      connectTimeout: const Duration(seconds: 30),
      receiveTimeout: const Duration(seconds: 30),
    ));

    _dio.interceptors.addAll([
      LogInterceptor(requestBody: true, responseBody: true),
      ErrorInterceptor(),
      AuthInterceptor(),
    ]);
  }

  Future> get(String path, {Map? queryParameters}) {
    return _dio.get(path, queryParameters: queryParameters);
  }

  Future> post(String path, {dynamic data}) {
    return _dio.post(path, data: data);
  }
}

The Either Pattern with Dartz

Use functional error handling to make failure states explicit:

// lib/features/users/data/repositories/user_repository_impl.dart
import 'package:dartz/dartz.dart';

class UserRepositoryImpl implements UserRepository {
  final ApiClient _client;

  UserRepositoryImpl(this._client);

  @override
  Future> getUser(String id) async {
    try {
      final response = await _client.get('/users/$id');
      final user = User.fromJson(response.data);
      return Right(user);
    } on UnauthorizedException {
      return const Left(UnauthorizedFailure());
    } on NetworkException catch (e) {
      return Left(NetworkFailure(e.message));
    } on ValidationException catch (e) {
      return Left(ValidationFailure(e.message, fieldErrors: e.errors));
    } on ServerException catch (e) {
      return Left(ApiFailure(e.message, statusCode: e.statusCode));
    } catch (e) {
      return Left(ApiFailure('Unexpected error: $e'));
    }
  }

  @override
  Future> updateUser(String id, Map data) async {
    try {
      final response = await _client.patch('/users/$id', data: data);
      final user = User.fromJson(response.data);
      return Right(user);
    } on ValidationException catch (e) {
      return Left(ValidationFailure(e.message, fieldErrors: e.errors));
    } on AppException catch (e) {
      return Left(ApiFailure(e.message, statusCode: e.statusCode));
    }
  }
}

// Usage in ViewModel/Controller
class UserController extends ChangeNotifier {
  final UserRepository _repository;

  User? _user;
  Failure? _error;
  bool _isLoading = false;

  User? get user => _user;
  Failure? get error => _error;
  bool get isLoading => _isLoading;

  Future loadUser(String id) async {
    _isLoading = true;
    _error = null;
    notifyListeners();

    final result = await _repository.getUser(id);

    result.fold(
      (failure) {
        _error = failure;
        _user = null;
      },
      (user) {
        _user = user;
        _error = null;
      },
    );

    _isLoading = false;
    notifyListeners();
  }
}

UI Patterns for Error Display

// lib/shared/widgets/error_view.dart
class ErrorView extends StatelessWidget {
  final Failure failure;
  final VoidCallback? onRetry;

  const ErrorView({super.key, required this.failure, this.onRetry});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              _getIcon(),
              size: 64,
              color: Theme.of(context).colorScheme.error,
            ),
            const SizedBox(height: 16),
            Text(
              failure.message,
              textAlign: TextAlign.center,
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            if (onRetry != null) ...[
              const SizedBox(height: 24),
              ElevatedButton.icon(
                onPressed: onRetry,
                icon: const Icon(Icons.refresh),
                label: const Text('Try Again'),
              ),
            ],
            if (failure is UnauthorizedFailure) ...[
              const SizedBox(height: 16),
              TextButton(
                onPressed: () => Navigator.pushReplacementNamed(context, '/login'),
                child: const Text('Go to Login'),
              ),
            ],
          ],
        ),
      ),
    );
  }

  IconData _getIcon() {
    if (failure is NetworkFailure) return Icons.wifi_off;
    if (failure is UnauthorizedFailure) return Icons.lock;
    if (failure is ServerFailure) return Icons.cloud_off;
    return Icons.error_outline;
  }
}

// Snackbar helper
void showErrorSnackbar(BuildContext context, Failure failure) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(failure.message),
      backgroundColor: Theme.of(context).colorScheme.error,
      action: failure is NetworkFailure
          ? SnackBarAction(
              label: 'Retry',
              textColor: Colors.white,
              onPressed: () {
                // Trigger retry
              },
            )
          : null,
    ),
  );
}

Common Mistakes to Avoid

Catching generic Exception: Always catch specific exception types first, then fall back to generic handling.

Showing technical messages: Users don’t understand “SocketException” or “FormatException”. Map to friendly messages.

No retry mechanism: Transient network errors should offer retry options, not just error messages.

Handling errors in widgets: Catch errors in repositories and services, not deep in the widget tree.

Ignoring 401 globally: Unauthorized errors should trigger logout and redirect to login, not just show a message.

Conclusion

Handling API errors gracefully in Flutter is essential for user trust, reliability, and app quality. Build a structured error hierarchy that distinguishes between network issues, authentication failures, validation errors, and server problems. Use Dio interceptors for centralized handling and the Either pattern for explicit error states. Display errors with clear messages, appropriate icons, and retry options. Log errors silently for debugging while showing user-friendly messages in the UI. This foundation ensures your app remains usable even when things go wrong. For more on Flutter architecture patterns, check out our guide on Flutter MVVM Architecture. For Dio documentation and advanced features, explore the Dio package documentation.

Leave a Comment