Back to Posts

Advanced Network Error Handling in Flutter

16 min read

Network errors are inevitable in mobile applications. This comprehensive guide will show you how to implement robust error handling strategies in Flutter to ensure a smooth user experience even when network issues occur.

Common Network Errors and Solutions

1. Connection Timeout

When the server takes too long to respond:

// Problem: Basic HTTP request without timeout
final response = await http.get(Uri.parse('https://api.example.com/data'));

Solution with proper timeout handling:

Future<dynamic> fetchDataWithTimeout() async {
  try {
    final response = await http.get(
      Uri.parse('https://api.example.com/data'),
    ).timeout(
      const Duration(seconds: 10),
      onTimeout: () {
        throw TimeoutException('Server took too long to respond');
      },
    );
    
    return jsonDecode(response.body);
  } on TimeoutException catch (e) {
    // Handle timeout specifically
    throw NetworkException('Connection timeout: Please try again');
  } on SocketException catch (e) {
    // Handle no internet connection
    throw NetworkException('No internet connection');
  } catch (e) {
    throw NetworkException('Failed to fetch data: $e');
  }
}

2. Error Response Handling

Create a robust error handling system:

class NetworkException implements Exception {
  final String message;
  final int? statusCode;
  final dynamic data;
  final String? errorCode;
  final DateTime timestamp;

  NetworkException(
    this.message, {
    this.statusCode,
    this.data,
    this.errorCode,
    DateTime? timestamp,
  }) : timestamp = timestamp ?? DateTime.now();

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

class ApiService {
  final _client = Dio();  // Using Dio for better error handling

  Future<T> handleResponse<T>(Response response, T Function(dynamic) parser) {
    switch (response.statusCode) {
      case 200:
      case 201:
        return Future.value(parser(response.data));
      case 400:
        throw NetworkException('Bad request', statusCode: 400, data: response.data);
      case 401:
        throw NetworkException('Unauthorized', statusCode: 401);
      case 403:
        throw NetworkException('Forbidden', statusCode: 403);
      case 404:
        throw NetworkException('Not found', statusCode: 404);
      case 500:
        throw NetworkException('Server error', statusCode: 500);
      default:
        throw NetworkException(
          'Unexpected error occurred',
          statusCode: response.statusCode,
        );
    }
  }
}

3. Implementing Retry Mechanism

Create a retry utility for failed requests:

class RetryOptions {
  final int maxAttempts;
  final Duration delay;
  final bool exponentialBackoff;
  final List<int>? retryableStatusCodes;

  const RetryOptions({
    this.maxAttempts = 3,
    this.delay = const Duration(seconds: 1),
    this.exponentialBackoff = true,
    this.retryableStatusCodes,
  });

  Future<T> retry<T>(Future<T> Function() operation) async {
    int attempts = 0;
    while (true) {
      try {
        attempts++;
        return await operation();
      } catch (error) {
        if (attempts >= maxAttempts) {
          rethrow;
        }
        
        // Check if error is retryable
        if (error is NetworkException && 
            retryableStatusCodes != null && 
            !retryableStatusCodes!.contains(error.statusCode)) {
          rethrow;
        }
        
        final waitTime = exponentialBackoff 
            ? delay * math.pow(2, attempts - 1) 
            : delay;
        await Future.delayed(waitTime);
      }
    }
  }
}

// Usage example
final retryOptions = RetryOptions(
  maxAttempts: 3,
  retryableStatusCodes: [408, 429, 500, 502, 503, 504],
);
final result = await retryOptions.retry(() => fetchData());

Advanced Error Handling Techniques

1. Circuit Breaker Pattern

Implement a circuit breaker to prevent cascading failures:

class CircuitBreaker {
  final int failureThreshold;
  final Duration resetTimeout;
  final Duration halfOpenTimeout;
  int _failureCount = 0;
  DateTime? _lastFailureTime;
  bool _isOpen = false;
  bool _isHalfOpen = false;

  CircuitBreaker({
    this.failureThreshold = 3,
    this.resetTimeout = const Duration(seconds: 30),
    this.halfOpenTimeout = const Duration(seconds: 5),
  });

  Future<T> execute<T>(Future<T> Function() operation) async {
    if (_isOpen) {
      if (_lastFailureTime != null &&
          DateTime.now().difference(_lastFailureTime!) > resetTimeout) {
        _isOpen = false;
        _isHalfOpen = true;
        _lastFailureTime = DateTime.now();
      } else {
        throw CircuitBreakerException('Circuit breaker is open');
      }
    }

    try {
      final result = await operation();
      _reset();
      return result;
    } catch (e) {
      _failureCount++;
      _lastFailureTime = DateTime.now();
      
      if (_failureCount >= failureThreshold) {
        _isOpen = true;
        _isHalfOpen = false;
      }
      
      rethrow;
    }
  }

  void _reset() {
    _failureCount = 0;
    _lastFailureTime = null;
    _isOpen = false;
    _isHalfOpen = false;
  }
}

class CircuitBreakerException implements Exception {
  final String message;
  final DateTime timestamp;
  
  CircuitBreakerException(this.message) : timestamp = DateTime.now();
}

2. Offline Support with Caching

Implement offline support using Hive for local storage:

class CachedApiService {
  final ApiService _apiService;
  final Box _cacheBox;
  final Duration _cacheDuration;
  final bool _enableOfflineMode;

  CachedApiService({
    required ApiService apiService,
    required Box cacheBox,
    Duration cacheDuration = const Duration(hours: 1),
    bool enableOfflineMode = true,
  })  : _apiService = apiService,
        _cacheBox = cacheBox,
        _cacheDuration = cacheDuration,
        _enableOfflineMode = enableOfflineMode;

  Future<T> get<T>(String key, Future<T> Function() fetchData) async {
    try {
      // Try to get fresh data
      final data = await fetchData();
      await _cacheBox.put(key, {
        'data': data,
        'timestamp': DateTime.now().millisecondsSinceEpoch,
      });
      return data;
    } catch (e) {
      if (!_enableOfflineMode) rethrow;
      
      // If network fails, try to get cached data
      final cached = _cacheBox.get(key);
      if (cached != null) {
        final timestamp = cached['timestamp'] as int;
        final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
        
        if (DateTime.now().difference(cacheTime) < _cacheDuration) {
          return cached['data'] as T;
        }
      }
      rethrow;
    }
  }
}

3. Error Logging and Analytics

Implement comprehensive error logging:

class ErrorLogger {
  final FirebaseCrashlytics _crashlytics;
  final AnalyticsService _analytics;
  final bool _enableLogging;

  ErrorLogger(
    this._crashlytics,
    this._analytics, {
    bool enableLogging = true,
  }) : _enableLogging = enableLogging;

  Future<void> logError(dynamic error, StackTrace? stackTrace) async {
    if (!_enableLogging) return;

    if (error is NetworkException) {
      await _crashlytics.recordError(
        error,
        stackTrace,
        reason: 'Network Error',
        information: [
          'statusCode: ${error.statusCode}',
          'errorCode: ${error.errorCode}',
          'timestamp: ${error.timestamp}',
        ],
      );
      
      await _analytics.logEvent('network_error', {
        'status_code': error.statusCode,
        'error_code': error.errorCode,
        'message': error.message,
        'timestamp': error.timestamp.toIso8601String(),
      });
    } else {
      await _crashlytics.recordError(error, stackTrace);
    }
  }
}

Error UI Handling

1. Advanced Error Widget

Create a reusable error widget with different states:

class NetworkErrorWidget extends StatelessWidget {
  final String message;
  final VoidCallback onRetry;
  final NetworkErrorType type;
  final bool showRetry;
  final bool showDetails;

  const NetworkErrorWidget({
    required this.message,
    required this.onRetry,
    this.type = NetworkErrorType.generic,
    this.showRetry = true,
    this.showDetails = false,
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          _buildIcon(),
          SizedBox(height: 16),
          Text(
            message,
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 16),
          ),
          if (showDetails) ...[
            SizedBox(height: 8),
            Text(
              _getDetails(),
              textAlign: TextAlign.center,
              style: TextStyle(fontSize: 12, color: Colors.grey),
            ),
          ],
          if (showRetry) ...[
            SizedBox(height: 16),
            ElevatedButton.icon(
              onPressed: onRetry,
              icon: Icon(Icons.refresh),
              label: Text('Retry'),
            ),
          ],
        ],
      ),
    );
  }

  Widget _buildIcon() {
    switch (type) {
      case NetworkErrorType.noConnection:
        return Icon(Icons.wifi_off, size: 48, color: Colors.orange);
      case NetworkErrorType.timeout:
        return Icon(Icons.timer_off, size: 48, color: Colors.red);
      case NetworkErrorType.serverError:
        return Icon(Icons.error_outline, size: 48, color: Colors.red);
      default:
        return Icon(Icons.error_outline, size: 48, color: Colors.grey);
    }
  }

  String _getDetails() {
    switch (type) {
      case NetworkErrorType.noConnection:
        return 'Please check your internet connection and try again';
      case NetworkErrorType.timeout:
        return 'The server took too long to respond';
      case NetworkErrorType.serverError:
        return 'There was a problem with the server';
      default:
        return 'An unexpected error occurred';
    }
  }
}

enum NetworkErrorType {
  generic,
  noConnection,
  timeout,
  serverError,
}

2. Integration with State Management

Example using Riverpod:

final dataProvider = StateNotifierProvider<DataNotifier, AsyncValue<Data>>((ref) {
  return DataNotifier(ref.watch(apiServiceProvider));
});

class DataNotifier extends StateNotifier<AsyncValue<Data>> {
  final ApiService _apiService;
  final CircuitBreaker _circuitBreaker;
  final ErrorLogger _errorLogger;

  DataNotifier(this._apiService)
      : _circuitBreaker = CircuitBreaker(),
        _errorLogger = ErrorLogger(FirebaseCrashlytics.instance, AnalyticsService()),
        super(const AsyncValue.loading());

  Future<void> fetchData() async {
    state = const AsyncValue.loading();
    
    try {
      final data = await _circuitBreaker.execute(() => 
        _apiService.fetchData()
      );
      state = AsyncValue.data(data);
    } on NetworkException catch (e, stack) {
      await _errorLogger.logError(e, stack);
      state = AsyncValue.error(e, stack);
    } catch (e, stack) {
      await _errorLogger.logError(e, stack);
      state = AsyncValue.error(e, stack);
    }
  }
}

3. Implementation in Widget

class DataScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final dataAsync = ref.watch(dataProvider);

    return dataAsync.when(
      loading: () => Center(child: CircularProgressIndicator()),
      error: (error, stack) {
        if (error is NetworkException) {
          return NetworkErrorWidget(
            message: error.message,
            type: _getErrorType(error),
            onRetry: () => ref.read(dataProvider.notifier).fetchData(),
            showDetails: true,
          );
        }
        return NetworkErrorWidget(
          message: 'An unexpected error occurred',
          onRetry: () => ref.read(dataProvider.notifier).fetchData(),
        );
      },
      data: (data) => ListView.builder(
        itemCount: data.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(data[index].title),
          );
        },
      ),
    );
  }

  NetworkErrorType _getErrorType(NetworkException error) {
    if (error.statusCode == null) return NetworkErrorType.noConnection;
    if (error.statusCode == 408) return NetworkErrorType.timeout;
    if (error.statusCode! >= 500) return NetworkErrorType.serverError;
    return NetworkErrorType.generic;
  }
}

Best Practices

  1. Connection Monitoring
class ConnectivityService {
  final Connectivity _connectivity = Connectivity();
  final _controller = StreamController<ConnectivityResult>();
  final _subscriptions = <StreamSubscription>[];

  Stream<ConnectivityResult> get onConnectivityChanged => _controller.stream;

  Future<void> initialize() async {
    _subscriptions.add(
      _connectivity.onConnectivityChanged.listen((result) {
        _controller.add(result);
      }),
    );
  }

  Future<bool> isConnected() async {
    final result = await _connectivity.checkConnectivity();
    return result != ConnectivityResult.none;
  }

  void dispose() {
    for (final subscription in _subscriptions) {
      subscription.cancel();
    }
    _controller.close();
  }
}
  1. Error Recovery Strategies

    • Implement exponential backoff for retries
    • Use circuit breakers for critical services
    • Cache responses for offline support
    • Provide clear error messages to users
    • Log errors for debugging and analytics
  2. Performance Considerations

    • Set appropriate timeouts
    • Implement request cancellation
    • Use connection pooling
    • Optimize payload size
    • Implement proper caching
  3. Security Best Practices

    • Validate all responses
    • Handle sensitive data properly
    • Implement proper authentication
    • Use HTTPS for all requests
    • Sanitize error messages

Conclusion

Effective network error handling is crucial for creating robust Flutter applications. By implementing these advanced techniques and following best practices, you can ensure your app provides a smooth user experience even in challenging network conditions.

Next Steps

  1. Implement comprehensive error logging
  2. Add offline support with caching
  3. Set up monitoring and analytics
  4. Create automated tests for error scenarios
  5. Document error handling procedures

Remember to:

  • Test error scenarios thoroughly
  • Monitor error rates in production
  • Update error handling as needed
  • Keep error messages user-friendly
  • Follow security best practices

Happy coding with robust error handling!