Mastering HTTP Error Handling in Flutter: A Complete Guide

HTTP Error Handling in Flutter
HTTP Error Handling in Flutter

HTTP requests are the backbone of most modern mobile applications, but things don’t always go as planned. Network issues, server errors, and unexpected responses are common challenges that can make or break your app’s user experience. In this comprehensive guide, we’ll explore everything you need to know about handling HTTP errors in Flutter, from basic concepts to advanced patterns.

Understanding HTTP Errors

Before diving into Flutter-specific implementations, let’s understand the types of errors you’ll encounter:

Client Errors (4xx)

  • 400 Bad Request – Invalid request format
  • 401 Unauthorized – Authentication required
  • 403 Forbidden – Access denied
  • 404 Not Found – Resource doesn’t exist
  • 429 Too Many Requests – Rate limit exceeded

Server Errors (5xx)

  • 500 Internal Server Error – Server-side issues
  • 502 Bad Gateway – Gateway/proxy issues
  • 503 Service Unavailable – Server temporarily down
  • 504 Gateway Timeout – Request timeout

Network Errors

  • Connection timeouts
  • DNS resolution failures
  • No internet connectivity
  • SSL/Certificate errors

Basic HTTP Error Handling

Let’s start with a simple example using Flutter’s built-in http package:

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;

class ApiService {
  static const String baseUrl = 'https://jsonplaceholder.typicode.com';

  Future<Map<String, dynamic>> getUser(int userId) async {
    try {
      final response = await http.get(
        Uri.parse('$baseUrl/users/$userId'),
        headers: {'Content-Type': 'application/json'},
      );

      if (response.statusCode == 200) {
        return json.decode(response.body);
      } else {
        throw HttpException('Failed to load user: ${response.statusCode}');
      }
    } on SocketException {
      throw Exception('No internet connection');
    } on HttpException catch (e) {
      throw Exception('HTTP error: ${e.message}');
    } on FormatException {
      throw Exception('Invalid response format');
    } catch (e) {
      throw Exception('Unexpected error: $e');
    }
  }
}

Creating a Robust HTTP Client

For production applications, you’ll want a more sophisticated approach. Here’s a comprehensive HTTP client with proper error handling:

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;

class ApiException implements Exception {
  final String message;
  final int? statusCode;
  final dynamic response;

  ApiException(this.message, {this.statusCode, this.response});

  @override
  String toString() => 'ApiException: $message (Status: $statusCode)';
}

class NetworkException implements Exception {
  final String message;
  NetworkException(this.message);

  @override
  String toString() => 'NetworkException: $message';
}

class HttpClient {
  static const Duration _timeout = Duration(seconds: 30);
  final String baseUrl;
  final Map<String, String> _defaultHeaders;

  HttpClient(this.baseUrl) : _defaultHeaders = {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  };

  Future<T> get<T>(
    String endpoint, {
    Map<String, String>? headers,
    T Function(dynamic)? parser,
  }) async {
    return _makeRequest<T>(
      'GET',
      endpoint,
      headers: headers,
      parser: parser,
    );
  }

  Future<T> post<T>(
    String endpoint, {
    Map<String, dynamic>? body,
    Map<String, String>? headers,
    T Function(dynamic)? parser,
  }) async {
    return _makeRequest<T>(
      'POST',
      endpoint,
      body: body,
      headers: headers,
      parser: parser,
    );
  }

  Future<T> _makeRequest<T>(
    String method,
    String endpoint, {
    Map<String, dynamic>? body,
    Map<String, String>? headers,
    T Function(dynamic)? parser,
  }) async {
    final uri = Uri.parse('$baseUrl$endpoint');
    final requestHeaders = {..._defaultHeaders, ...?headers};

    try {
      http.Response response;

      switch (method) {
        case 'GET':
          response = await http.get(uri, headers: requestHeaders)
              .timeout(_timeout);
          break;
        case 'POST':
          response = await http.post(
            uri,
            headers: requestHeaders,
            body: body != null ? json.encode(body) : null,
          ).timeout(_timeout);
          break;
        default:
          throw ArgumentError('Unsupported HTTP method: $method');
      }

      return _handleResponse<T>(response, parser);
    } on SocketException {
      throw NetworkException('No internet connection');
    } on TimeoutException {
      throw NetworkException('Request timeout');
    } on HandshakeException {
      throw NetworkException('SSL/Certificate error');
    } catch (e) {
      throw NetworkException('Network error: $e');
    }
  }

  T _handleResponse<T>(http.Response response, T Function(dynamic)? parser) {
    final statusCode = response.statusCode;
    
    try {
      final responseBody = json.decode(response.body);
      
      if (statusCode >= 200 && statusCode < 300) {
        if (parser != null) {
          return parser(responseBody);
        }
        return responseBody as T;
      } else {
        _handleHttpError(statusCode, responseBody);
      }
    } on FormatException {
      if (statusCode >= 200 && statusCode < 300) {
        // Handle non-JSON success responses
        return response.body as T;
      } else {
        _handleHttpError(statusCode, response.body);
      }
    }

    throw ApiException('Unexpected response format');
  }

  void _handleHttpError(int statusCode, dynamic responseBody) {
    String message;
    
    switch (statusCode) {
      case 400:
        message = 'Bad request - ${_extractErrorMessage(responseBody)}';
        break;
      case 401:
        message = 'Unauthorized - Please login again';
        break;
      case 403:
        message = 'Forbidden - Access denied';
        break;
      case 404:
        message = 'Resource not found';
        break;
      case 422:
        message = 'Validation failed - ${_extractErrorMessage(responseBody)}';
        break;
      case 429:
        message = 'Too many requests - Please try again later';
        break;
      case 500:
        message = 'Internal server error';
        break;
      case 502:
        message = 'Bad gateway';
        break;
      case 503:
        message = 'Service unavailable';
        break;
      case 504:
        message = 'Gateway timeout';
        break;
      default:
        message = 'HTTP error: $statusCode';
    }

    throw ApiException(
      message,
      statusCode: statusCode,
      response: responseBody,
    );
  }

  String _extractErrorMessage(dynamic responseBody) {
    if (responseBody is Map<String, dynamic>) {
      return responseBody['message'] ?? 
             responseBody['error'] ?? 
             responseBody['detail'] ?? 
             'Unknown error';
    }
    return responseBody.toString();
  }
}

Repository Pattern with Error Handling

Implementing the repository pattern helps separate your data layer from your UI and provides a clean interface for error handling:

abstract class UserRepository {
  Future<User> getUser(int id);
  Future<List<User>> getUsers();
  Future<User> createUser(User user);
}

class ApiUserRepository implements UserRepository {
  final HttpClient _httpClient;

  ApiUserRepository(this._httpClient);

  @override
  Future<User> getUser(int id) async {
    try {
      final userData = await _httpClient.get<Map<String, dynamic>>(
        '/users/$id',
        parser: (data) => data as Map<String, dynamic>,
      );
      return User.fromJson(userData);
    } on ApiException catch (e) {
      if (e.statusCode == 404) {
        throw UserNotFoundException('User with id $id not found');
      }
      rethrow;
    }
  }

  @override
  Future<List<User>> getUsers() async {
    final usersData = await _httpClient.get<List<dynamic>>(
      '/users',
      parser: (data) => data as List<dynamic>,
    );
    return usersData.map((userData) => User.fromJson(userData)).toList();
  }

  @override
  Future<User> createUser(User user) async {
    final userData = await _httpClient.post<Map<String, dynamic>>(
      '/users',
      body: user.toJson(),
      parser: (data) => data as Map<String, dynamic>,
    );
    return User.fromJson(userData);
  }
}

class UserNotFoundException implements Exception {
  final String message;
  UserNotFoundException(this.message);
}

UI Error Handling with State Management

Here’s how to handle errors in your UI using a state management approach:

// Error state classes
abstract class ApiState<T> {
  const ApiState();
}

class ApiLoading<T> extends ApiState<T> {
  const ApiLoading();
}

class ApiSuccess<T> extends ApiState<T> {
  final T data;
  const ApiSuccess(this.data);
}

class ApiError<T> extends ApiState<T> {
  final String message;
  final Exception exception;
  
  const ApiError(this.message, this.exception);
}

// State notifier for user management
class UserNotifier extends StateNotifier<ApiState<List<User>>> {
  final UserRepository _repository;

  UserNotifier(this._repository) : super(const ApiLoading());

  Future<void> loadUsers() async {
    state = const ApiLoading();
    
    try {
      final users = await _repository.getUsers();
      state = ApiSuccess(users);
    } on NetworkException catch (e) {
      state = ApiError('Network error: Please check your connection', e);
    } on ApiException catch (e) {
      state = ApiError('Server error: ${e.message}', e);
    } catch (e) {
      state = ApiError('An unexpected error occurred', e as Exception);
    }
  }

  Future<void> createUser(User user) async {
    try {
      await _repository.createUser(user);
      await loadUsers(); // Refresh the list
    } on NetworkException catch (e) {
      state = ApiError('Network error: Cannot create user', e);
    } on ApiException catch (e) {
      if (e.statusCode == 422) {
        state = ApiError('Invalid user data: ${e.message}', e);
      } else {
        state = ApiError('Failed to create user: ${e.message}', e);
      }
    }
  }
}

Error Display Widget

Create a reusable widget for displaying errors consistently across your app:

class ErrorDisplay extends StatelessWidget {
  final String message;
  final Exception? exception;
  final VoidCallback? onRetry;
  final bool showDetails;

  const ErrorDisplay({
    Key? key,
    required this.message,
    this.exception,
    this.onRetry,
    this.showDetails = false,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              _getErrorIcon(),
              size: 64,
              color: Colors.red.shade300,
            ),
            const SizedBox(height: 16),
            Text(
              message,
              style: Theme.of(context).textTheme.titleMedium,
              textAlign: TextAlign.center,
            ),
            if (showDetails && exception != null) ...[
              const SizedBox(height: 8),
              Text(
                exception.toString(),
                style: Theme.of(context).textTheme.bodySmall,
                textAlign: TextAlign.center,
              ),
            ],
            if (onRetry != null) ...[
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: onRetry,
                child: const Text('Retry'),
              ),
            ],
          ],
        ),
      ),
    );
  }

  IconData _getErrorIcon() {
    if (exception is NetworkException) {
      return Icons.wifi_off;
    } else if (exception is ApiException) {
      final apiException = exception as ApiException;
      if (apiException.statusCode == 404) {
        return Icons.search_off;
      } else if (apiException.statusCode == 401) {
        return Icons.lock;
      }
      return Icons.error_outline;
    }
    return Icons.error;
  }
}

Using the Error Handling System

Here’s how to use all these components together in a widget:

class UserListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userState = ref.watch(userNotifierProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Users')),
      body: userState.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        success: (users) => ListView.builder(
          itemCount: users.length,
          itemBuilder: (context, index) => ListTile(
            title: Text(users[index].name),
            subtitle: Text(users[index].email),
          ),
        ),
        error: (message, exception) => ErrorDisplay(
          message: message,
          exception: exception,
          onRetry: () => ref.read(userNotifierProvider.notifier).loadUsers(),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showCreateUserDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showCreateUserDialog(BuildContext context, WidgetRef ref) {
    // Dialog implementation for creating users
  }
}

Advanced Error Handling Strategies

Retry Logic with Exponential Backoff

class RetryPolicy {
  final int maxAttempts;
  final Duration initialDelay;
  final double backoffMultiplier;

  const RetryPolicy({
    this.maxAttempts = 3,
    this.initialDelay = const Duration(seconds: 1),
    this.backoffMultiplier = 2.0,
  });
}

extension HttpClientRetry on HttpClient {
  Future<T> getWithRetry<T>(
    String endpoint, {
    Map<String, String>? headers,
    T Function(dynamic)? parser,
    RetryPolicy? retryPolicy,
  }) async {
    final policy = retryPolicy ?? const RetryPolicy();
    Exception? lastException;

    for (int attempt = 0; attempt < policy.maxAttempts; attempt++) {
      try {
        return await get<T>(endpoint, headers: headers, parser: parser);
      } on NetworkException catch (e) {
        lastException = e;
        if (attempt < policy.maxAttempts - 1) {
          final delay = Duration(
            milliseconds: (policy.initialDelay.inMilliseconds * 
                          pow(policy.backoffMultiplier, attempt)).round(),
          );
          await Future.delayed(delay);
        }
      } on ApiException catch (e) {
        // Don't retry client errors (4xx)
        if (e.statusCode != null && e.statusCode! >= 400 && e.statusCode! < 500) {
          rethrow;
        }
        lastException = e;
        if (attempt < policy.maxAttempts - 1) {
          final delay = Duration(
            milliseconds: (policy.initialDelay.inMilliseconds * 
                          pow(policy.backoffMultiplier, attempt)).round(),
          );
          await Future.delayed(delay);
        }
      }
    }

    throw lastException!;
  }
}

Global Error Handler

class GlobalErrorHandler {
  static void handleError(Exception error, StackTrace stackTrace) {
    // Log the error
    debugPrint('Error: $error');
    debugPrint('Stack trace: $stackTrace');

    // Report to crash analytics (Firebase Crashlytics, Sentry, etc.)
    // FirebaseCrashlytics.instance.recordError(error, stackTrace);

    // Show user-friendly message
    if (error is NetworkException) {
      _showErrorSnackBar('Please check your internet connection');
    } else if (error is ApiException) {
      _showErrorSnackBar('Server error: ${error.message}');
    } else {
      _showErrorSnackBar('An unexpected error occurred');
    }
  }

  static void _showErrorSnackBar(String message) {
    final context = navigatorKey.currentContext;
    if (context != null) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(message)),
      );
    }
  }
}

Best Practices

  1. Use Specific Exception Types: Create custom exceptions for different error scenarios to handle them appropriately.
  2. Implement Proper Logging: Log errors with sufficient context for debugging while avoiding sensitive data.
  3. Provide Meaningful User Messages: Don’t expose technical error details to users; provide actionable feedback instead.
  4. Handle Offline Scenarios: Implement caching and offline-first strategies for better user experience.
  5. Use Timeouts: Always set reasonable timeouts for network requests to avoid hanging requests.
  6. Implement Circuit Breakers: For high-traffic apps, consider implementing circuit breaker patterns to handle cascading failures.
  7. Test Error Scenarios: Write unit tests for your error handling code and use tools like proxy servers to simulate network issues.

Testing Error Handling

// Mock HTTP client for testing
class MockHttpClient extends Mock implements HttpClient {}

void main() {
  group('UserRepository Error Handling', () {
    late MockHttpClient mockHttpClient;
    late ApiUserRepository repository;

    setUp(() {
      mockHttpClient = MockHttpClient();
      repository = ApiUserRepository(mockHttpClient);
    });

    test('should throw UserNotFoundException when user not found', () async {
      when(() => mockHttpClient.get<Map<String, dynamic>>(
        '/users/1',
        parser: any(named: 'parser'),
      )).thenThrow(ApiException('Not found', statusCode: 404));

      expect(
        () => repository.getUser(1),
        throwsA(isA<UserNotFoundException>()),
      );
    });

    test('should handle network errors', () async {
      when(() => mockHttpClient.get<Map<String, dynamic>>(
        '/users/1',
        parser: any(named: 'parser'),
      )).thenThrow(NetworkException('No internet connection'));

      expect(
        () => repository.getUser(1),
        throwsA(isA<NetworkException>()),
      );
    });
  });
}

Conclusion

Proper HTTP error handling is crucial for creating robust Flutter applications. By implementing the patterns and strategies outlined in this guide, you can create apps that gracefully handle network issues, provide meaningful feedback to users, and maintain a great user experience even when things go wrong.

Remember to:

  • Use specific exception types for different error scenarios
  • Implement retry logic for transient failures
  • Provide clear, actionable error messages to users
  • Test your error handling thoroughly
  • Monitor and log errors for continuous improvement

With these tools and techniques, you’ll be well-equipped to handle any HTTP error scenario that comes your way in your Flutter applications.

Leave a Reply

Your email address will not be published. Required fields are marked *