Error Handling and Retry Logic in Flutter

Error Handling and Retry Logic in Flutter

What You’ll Learn

Learn how to gracefully handle errors in Flutter apps and implement smart retry mechanisms. You’ll discover patterns for catching exceptions, showing user-friendly error messages, and automatically retrying failed operations.

Why Error Handling Matters

Real-world apps face constant challenges: network failures, server errors, invalid user input, and unexpected data. Good error handling makes the difference between an app that crashes and one that recovers gracefully.

Common scenarios:

Basic Error Handling Patterns

Pattern 1: Try-Catch for Synchronous Code

String parseUserAge(String input) {
  try {
    final age = int.parse(input);
    if (age < 0 || age > 150) {
      throw FormatException('Age must be between 0 and 150');
    }
    return 'Valid age: $age';
  } on FormatException catch (e) {
    return 'Error: ${e.message}';
  } catch (e) {
    return 'Unexpected error: $e';
  }
}

Pattern 2: Try-Catch for Async Operations

Future<User> fetchUser(String userId) async {
  try {
    final response = await http.get(
      Uri.parse('https://api.example.com/users/$userId'),
    );

    if (response.statusCode == 200) {
      return User.fromJson(jsonDecode(response.body));
    } else if (response.statusCode == 404) {
      throw UserNotFoundException('User $userId not found');
    } else {
      throw ApiException('Server error: ${response.statusCode}');
    }
  } on SocketException {
    throw NetworkException('No internet connection');
  } on TimeoutException {
    throw NetworkException('Request timed out');
  } on FormatException {
    throw DataException('Invalid response format');
  }
}

Example: Smart Retry Logic

Here’s a reusable retry function with exponential backoff:

import 'dart:async';
import 'package:flutter/material.dart';

// Custom exception types
class NetworkException implements Exception {
  final String message;
  NetworkException(this.message);

  @override
  String toString() => message;
}

// Retry configuration
class RetryConfig {
  final int maxAttempts;
  final Duration initialDelay;
  final Duration maxDelay;
  final double backoffMultiplier;

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

// Generic retry function with exponential backoff
Future<T> retryOperation<T>({
  required Future<T> Function() operation,
  RetryConfig config = const RetryConfig(),
  bool Function(Exception)? retryIf,
}) async {
  int attempt = 0;
  Duration delay = config.initialDelay;

  while (true) {
    attempt++;

    try {
      return await operation();
    } on Exception catch (e) {
      // Check if we should retry
      final shouldRetry = retryIf?.call(e) ?? true;

      if (attempt >= config.maxAttempts || !shouldRetry) {
        rethrow; // Give up and throw the error
      }

      // Wait before retrying (exponential backoff)
      print('Attempt $attempt failed: $e. Retrying in ${delay.inSeconds}s...');
      await Future.delayed(delay);

      // Increase delay for next attempt
      delay = Duration(
        milliseconds: (delay.inMilliseconds * config.backoffMultiplier).round(),
      );

      // Cap at maximum delay
      if (delay > config.maxDelay) {
        delay = config.maxDelay;
      }
    }
  }
}

// Usage example
class UserRepository {
  Future<User> getUser(String id) async {
    return retryOperation(
      operation: () async {
        final response = await http.get(
          Uri.parse('https://api.example.com/users/$id'),
        ).timeout(Duration(seconds: 5));

        if (response.statusCode == 200) {
          return User.fromJson(jsonDecode(response.body));
        } else {
          throw NetworkException('Failed to load user: ${response.statusCode}');
        }
      },
      config: RetryConfig(
        maxAttempts: 3,
        initialDelay: Duration(seconds: 1),
      ),
      retryIf: (e) {
        // Only retry network errors, not validation errors
        return e is NetworkException || e is TimeoutException;
      },
    );
  }
}

UI Pattern: Error State Management

Display loading, error, and success states elegantly:

class UserScreen extends StatefulWidget {
  @override
  State<UserScreen> createState() => _UserScreenState();
}

class _UserScreenState extends State<UserScreen> {
  bool _isLoading = false;
  String? _errorMessage;
  User? _user;

  final _repository = UserRepository();

  @override
  void initState() {
    super.initState();
    _loadUser();
  }

  Future<void> _loadUser() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    try {
      final user = await _repository.getUser('123');
      setState(() {
        _user = user;
        _isLoading = false;
      });
    } on NetworkException catch (e) {
      setState(() {
        _errorMessage = e.toString();
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _errorMessage = 'An unexpected error occurred';
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('User Profile')),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    if (_isLoading) {
      return Center(child: CircularProgressIndicator());
    }

    if (_errorMessage != null) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, size: 64, color: Colors.red),
            SizedBox(height: 16),
            Text(_errorMessage!, style: TextStyle(fontSize: 16)),
            SizedBox(height: 24),
            ElevatedButton.icon(
              onPressed: _loadUser,
              icon: Icon(Icons.refresh),
              label: Text('Retry'),
            ),
          ],
        ),
      );
    }

    if (_user != null) {
      return Center(
        child: Text('Hello, ${_user!.name}!'),
      );
    }

    return Center(child: Text('No data'));
  }
}

Try It Yourself

Enhance the retry mechanism:

  1. Add a progress indicator showing retry attempts (“Attempt 2 of 3”)
  2. Implement a circuit breaker that stops retries after consecutive failures
  3. Add offline detection to avoid retrying when device has no connection
  4. Create a custom error widget that shows different messages for different error types

Challenge: Build a file upload feature with retry logic that:

Tip of the Day

Global Error Handling: Catch uncaught errors with FlutterError.onError and runZonedGuarded:

void main() {
  FlutterError.onError = (details) {
    // Log to crash reporting service
    print('Flutter error: ${details.exception}');
  };

  runZonedGuarded(
    () => runApp(MyApp()),
    (error, stack) {
      // Catch async errors
      print('Uncaught error: $error');
    },
  );
}

Testing Errors: Simulate errors in development using mock services. This helps you design better error UIs and recovery flows before users encounter real failures.