Retrying Failed API Requests in Flutter

When building Flutter applications that interact with remote APIs, network requests can occasionally fail due to various reasons: connectivity issues, server downtime, or rate limiting. Implementing a robust retry mechanism is essential to enhance the reliability and user experience of your app. This post delves into effective strategies for retrying failed API requests in Flutter, providing code examples and best practices.

Why Retry Failed API Requests?

  • Improved Reliability: Automatic retries handle transient network issues without interrupting the user.
  • Enhanced User Experience: Reduces frustration by automatically attempting to complete actions.
  • Handles Temporary Downtime: Provides resilience against temporary server outages.

Common Scenarios for Retrying Requests

  • Network Intermittency: Temporary loss of internet connection.
  • Server Overload: Temporary server unavailability due to high traffic.
  • Rate Limiting: API limiting the number of requests in a time window.

Basic Retry Implementation in Flutter

Here’s a simple approach to implement a retry mechanism using the http package and Future.delayed for delays.

Step 1: Add http Package

Add the http package to your pubspec.yaml file:

dependencies:
  http: ^1.1.0

Step 2: Implement Retry Logic

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

Future<dynamic> fetchData(String url, {int maxRetries = 3, Duration retryDelay = const Duration(seconds: 1)}) async {
  int retries = 0;
  while (retries < maxRetries) {
    try {
      final response = await http.get(Uri.parse(url));

      if (response.statusCode == 200) {
        return jsonDecode(response.body);
      } else {
        print('Request failed with status: ${response.statusCode}');
        retries++;
        if (retries < maxRetries) {
          print('Retrying in ${retryDelay.inSeconds} seconds...');
          await Future.delayed(retryDelay);
        } else {
          throw Exception('Failed to fetch data after $maxRetries retries: ${response.statusCode}');
        }
      }
    } catch (e) {
      print('Exception occurred: $e');
      retries++;
      if (retries < maxRetries) {
        print('Retrying in ${retryDelay.inSeconds} seconds...');
        await Future.delayed(retryDelay);
      } else {
        throw Exception('Failed to fetch data after $maxRetries retries: $e');
      }
    }
  }
  throw Exception('Max retries reached, request failed.');
}

void main() async {
  try {
    final data = await fetchData('https://rickandmortyapi.com/api/character');
    print('Data fetched successfully: $data');
  } catch (e) {
    print('Error: $e');
  }
}

In this example:

  • fetchData function takes a URL, the maximum number of retries, and the delay between retries as parameters.
  • It attempts the request in a loop, incrementing the retry counter on each failure.
  • If the request is successful (status code 200), it returns the decoded JSON response.
  • If it fails (status code other than 200) or an exception occurs, it waits for the specified delay before retrying.
  • If the maximum number of retries is reached without success, it throws an exception.

Implementing Exponential Backoff

Exponential backoff increases the delay between retries, reducing the load on the server if it is experiencing issues.

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

Future<dynamic> fetchDataWithExponentialBackoff(String url, {int maxRetries = 5, Duration baseDelay = const Duration(milliseconds: 500)}) async {
  int retries = 0;
  while (retries < maxRetries) {
    try {
      final response = await http.get(Uri.parse(url));

      if (response.statusCode == 200) {
        return jsonDecode(response.body);
      } else {
        print('Request failed with status: ${response.statusCode}');
        retries++;
        if (retries < maxRetries) {
          final delay = baseDelay * (1 << retries); // Exponential delay
          print('Retrying in ${delay.inSeconds} seconds...');
          await Future.delayed(delay);
        } else {
          throw Exception('Failed to fetch data after $maxRetries retries: ${response.statusCode}');
        }
      }
    } catch (e) {
      print('Exception occurred: $e');
      retries++;
      if (retries < maxRetries) {
        final delay = baseDelay * (1 << retries); // Exponential delay
        print('Retrying in ${delay.inSeconds} seconds...');
        await Future.delayed(delay);
      } else {
        throw Exception('Failed to fetch data after $maxRetries retries: $e');
      }
    }
  }
  throw Exception('Max retries reached, request failed.');
}

void main() async {
  try {
    final data = await fetchDataWithExponentialBackoff('https://rickandmortyapi.com/api/character');
    print('Data fetched successfully: $data');
  } catch (e) {
    print('Error: $e');
  }
}

In this enhanced version:

  • The baseDelay parameter specifies the initial delay.
  • The delay is calculated as baseDelay * (1 << retries), which doubles the delay with each retry (1, 2, 4, 8, …).
  • This helps prevent overwhelming the server with rapid retries.

Using the retry Package

For a more sophisticated approach, you can leverage the retry package, which provides more advanced retry strategies.

Step 1: Add retry Package

Add the retry package to your pubspec.yaml file:

dependencies:
  retry: ^3.1.1
  http: ^1.1.0

Step 2: Implement Retry Logic with retry

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

Future<dynamic> fetchDataWithRetryPackage(String url) async {
  const retryOptions = RetryOptions(maxAttempts: 5, delayFactor: Duration(milliseconds: 500));

  try {
    final response = await retryOptions.retry(
      () async {
        final response = await http.get(Uri.parse(url));

        if (response.statusCode == 200) {
          return response;
        } else {
          throw Exception('Request failed with status: ${response.statusCode}');
        }
      },
      onRetry: (e) => print('Retrying request: $e'),
    );
    return jsonDecode(response.body);
  } catch (e) {
    print('Failed to fetch data after multiple retries: $e');
    throw e;
  }
}

void main() async {
  try {
    final data = await fetchDataWithRetryPackage('https://rickandmortyapi.com/api/character');
    print('Data fetched successfully: $data');
  } catch (e) {
    print('Error: $e');
  }
}

Key improvements using the retry package:

  • The retryOptions allow specifying the maximum number of attempts and delay factors.
  • The retry() method encapsulates the network request, automatically retrying on exceptions.
  • The onRetry callback lets you log or handle each retry attempt.

Handling Specific Error Codes

Sometimes, you might want to retry only for specific HTTP status codes, such as 503 (Service Unavailable) or 429 (Too Many Requests).

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

Future<dynamic> fetchDataWithSpecificErrorRetry(String url, {int maxRetries = 3, Duration retryDelay = const Duration(seconds: 1)}) async {
  int retries = 0;
  final retryableStatusCodes = [503, 429]; // Status codes to retry

  while (retries < maxRetries) {
    try {
      final response = await http.get(Uri.parse(url));

      if (response.statusCode == 200) {
        return jsonDecode(response.body);
      } else if (retryableStatusCodes.contains(response.statusCode)) {
        print('Request failed with status: ${response.statusCode}. Retrying...');
        retries++;
        await Future.delayed(retryDelay);
      }
      else {
        throw Exception('Request failed with non-retryable status: ${response.statusCode}');
      }
    } catch (e) {
      print('Exception occurred: $e');
      retries++;
      if (retries < maxRetries) {
        print('Retrying in ${retryDelay.inSeconds} seconds...');
        await Future.delayed(retryDelay);
      } else {
        throw Exception('Failed to fetch data after $maxRetries retries: $e');
      }
    }
  }
  throw Exception('Max retries reached, request failed.');
}

void main() async {
  try {
    final data = await fetchDataWithSpecificErrorRetry('https://rickandmortyapi.com/api/character');
    print('Data fetched successfully: $data');
  } catch (e) {
    print('Error: $e');
  }
}

In this adjusted function:

  • A retryableStatusCodes list is defined, containing status codes for which retries are appropriate.
  • The function retries only if the response status code is in this list.
  • This ensures that you don’t retry for non-recoverable errors like 400 (Bad Request) or 404 (Not Found).

Best Practices for Retrying API Requests

  • Limit the Number of Retries: Too many retries can overload the server or drain the user’s battery.
  • Implement Exponential Backoff: Avoid overwhelming the server with rapid retries by increasing the delay between retries.
  • Handle Specific Error Codes: Only retry for transient errors like 503 or 429. Avoid retrying for client-side errors like 400 or 404.
  • Provide User Feedback: Display a message indicating that the app is attempting to retry, preventing the user from thinking the app is stuck.
  • Use Circuit Breaker Pattern: Prevent repeated failures from impacting the entire application by temporarily halting requests to a failing service.
  • Test Your Retry Logic: Simulate network failures and server errors to ensure the retry mechanism works as expected.

Conclusion

Implementing retry mechanisms in Flutter applications significantly enhances their reliability and user experience when dealing with remote APIs. Whether through basic retry loops, exponential backoff, or specialized packages like retry, Flutter provides the tools necessary to handle transient network issues and temporary server downtimes. By adhering to best practices and considering specific error codes, you can build robust and resilient applications.