How to Implement a Retry Interceptor in Flutter with Dio

How to Implement a Retry Interceptor in Flutter with Dio

FlutterPulse

This article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!πŸš€

Let's learn how to implement a retry interceptor in Flutter using the Dio library to enhance the resilience of your network requests.

1. What are Interceptors?

In an HTTP client, an interceptor is middleware that sits between your application and the server. It allows you to intercept and modify outgoing HTTP requests and incoming responses before they reach your application code. This enables you to perform actions like:

  • Adding headers
  • Handling errors
  • Logging requests
  • Performing authentication checks

Interceptors apply this logic consistently across all API calls without requiring repetitive code in each individual request.

2. Why Use Interceptors?

Interceptors act as middleware, intercepting HTTP requests and responses at key points in their lifecycle. The HttpClient Interceptor architecture offers several benefits:

  • Centralized Logic: Encapsulate cross-cutting concerns like authentication, error handling, and logging in a single place.
  • Dynamic Request/Response Modification: Modify requests and responses programmatically based on application state or server requirements.
  • Global Error Recovery Strategies: Implement consistent error handling and retry mechanisms across your application.

These capabilities are particularly valuable for applications dealing with APIs where network operations must handle:

  • Token-based authentication
  • Unreliable network conditions
  • Consistent error reporting

By decoupling network logic from business logic, interceptors align with SOLID principles, promoting maintainable and scalable code.

3. How Do Interceptors Work?

Interceptors operate in three distinct phases, providing interception points at different stages of the HTTP request/response cycle:

Phase 1: onRequest

  • Purpose: Modify outgoing requests before they are sent to the server.
  • Key Operations:
    – Inject authentication headers (e.g., JWT tokens in the Authorizationheader).
    – Validate request parameters before sending.
    – Add API keys or localization headers.
    – Cancel invalid requests (e.g., requests with missing required fields).

Phase 2: onResponse

  • Purpose: Process successful server responses after they are received but before they are processed by your application.
  • Key Operations:
    – Normalize data formats
    – Extract metadata from response headers (e.g., pagination tokens, rate limits).

Phase 3: onError

  • Purpose: Handle failed requests and implement error recovery mechanisms.
  • Key Operations:
    – Retry transient failures (e.g., network timeouts, 5xx server
    errors).
    – Redirect users to a login screen after session expiration (HTTP 401 Unauthorized).
    – Log errors to monitoring tools for debugging and analysis.

4. How to Implement a Retry Interceptor

Now, let's focus on the main topic: implementing a retry interceptor. Retry interceptors are crucial for automatically managing transient network failures, enhancing application resilience and user experience.

4.1. Understanding Retry Strategies: Intelligent Retries

Simply retrying every failed request without discrimination can be inefficient and detrimental. Intelligent retry strategies are essential for effective error recovery. These strategies involve:

  • Targeting Transient Errors: Retry only when errors are likely temporary:
    – Network timeouts (DioExceptionType.connectionTimeout, DioExceptionType.receiveTimeout, DioExceptionType.sendTimeout, DioExceptionType.connectionError).
    – HTTP 5xx server errors (e.g., 500 Internal Server Error, 503 Service Unavailable).
    – Application-specific errors indicating transient issues (e.g., rate limit exceeded).
  • Avoiding Non-Transient Errors: Do not retry errors indicating permanent issues or requiring client-side changes:
    – HTTP 4xx client errors (e.g., 400 Bad Request, 404 Not Found).
    – Request validation failures.
  • Exponential Backoff with Jitter: Implement increasing delays between retries to avoid overwhelming the server and incorporate randomness (jitter) to prevent retry storms.
  • Selective Retries: Apply retry logic only to specific request types or API endpoints as needed, allowing fine-grained control.

By implementing these intelligent retry strategies within a Dio interceptor, you can build more resilient and user-friendly Flutter applications.

4.2. Retry Interceptor with Dio: Code Implementation

Let's implement a RetryInterceptor in Flutter using Dio, incorporating exponential backoff and selective retries. Here is the Dart code for the RetryInterceptor class:

import 'dart:async';
import 'dart:math';
import 'package:dio/dio.dart';

import '../models/retry_config.dart';

class RetryInterceptor extends Interceptor {
static const retryEnabledKey = 'retry_enabled';
static const retryErrorConditionsKey = 'retry_error_conditions';
RetryInterceptor({
required this.dio,
required this.retryConfig,
});
final Dio dio;
final RetryConfig retryConfig;
@override
Future onError(DioException err, ErrorInterceptorHandler handler) async {
final retryCount = err.requestOptions.retryCount + 1;
if (!err.requestOptions.isRetryEnabled(retryEnabledKey) ||
!_shouldRetry(err, retryCount, retryConfig)) {
return handler.next(err);
}

err.requestOptions.retryCount = retryCount;
final delay = _calculateDelay(retryCount);
print(
'Retry attempt: $retryCount, Delay: ${delay.inMilliseconds}ms, Error: ${err.message}');
if (delay != Duration.zero) {
await Future.delayed(delay);
}

try {
final response = await dio.request(
err.requestOptions.path,
options: Options(
method: err.requestOptions.method,
headers: err.requestOptions.headers,
extra: err.requestOptions.extra,
),
cancelToken: err.requestOptions.cancelToken,
data: err.requestOptions.data,
queryParameters: err.requestOptions.queryParameters,
);
return handler.resolve(response);
} on DioException catch (e) {
return super.onError(e, handler);
} catch (_) {
return handler.reject(err);
}
}

Duration _calculateDelay(int retryCount) {
final random = Random();
final jitter = random.nextDouble() * retryConfig.baseDelayInMiliseconds;
final delay = retryConfig.baseDelayInMiliseconds * pow(2, retryCount - 1);
return Duration(milliseconds: (delay + jitter).toInt());
}

bool _shouldRetry(
DioException error, int retryCount, RetryConfig retryConfig) {
if (retryCount > retryConfig.maxRetries) {
return false;
}

final retryErrorStatusCodes =
error.requestOptions.getRetryErrorStatusCodes(retryErrorConditionsKey);
final isRetryableError = error.response != null &&
retryErrorStatusCodes.contains(error.response?.statusCode);

return error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.connectionError ||
isRetryableError;
}
}

extension RequestOptionsRetryExtension on RequestOptions {
bool isRetryEnabled(String key) => (extra[key] as bool?) ?? false;
List<int> getRetryErrorStatusCodes(String key) {
final statusCodes = extra[key] as List<int>?;
return statusCodes ?? [];
}

int get retryCount => (extra['retryCount'] as int?) ?? 0;
set retryCount(int value) => extra['retryCount'] = value;
}

4.3. Code Breakdown: Key Components Explained

Let's examine the essential parts of the RetryInterceptor code to quickly understand its functionality:

  • RetryInterceptor Class and Constants: This class is the core of the retry mechanism, extending Dio's Interceptor. retryEnabledKey and retryErrorConditionsKey are constants used to selectively control retries for individual requests via RequestOptions.extra.
  • onError Method: Error Handling and Retry Orchestration: This is the central method, automatically called by Dio on request errors. It manages the entire retry process:
    – Retry Check: Verifies if retries are enabled and needed using isRetryEnabled() and _shouldRetry().
    – Delay Calculation: Calculates delay using _calculateDelay().
    – Request Re-execution: Re-sends request using dio.request().
    – Chain Resolution: Handles success or failure.
  • _shouldRetry Method: Retry Decision Logic: This method determines if a retry is necessary by checking retry limits, DioExceptionType, and custom status codes.
  • _calculateDelay Method: Retry Delay Strategy: Implements exponential backoff with jitter for calculating delays.
  • RetryConfig:
class RetryConfig {
final int maxRetries;
final int baseDelayInMiliseconds;

const RetryConfig({
this.maxRetries = 3,
this.baseDelayInMiliseconds = 1000,
});
}
  • RetryConfig: Configures retry parameters:
  • maxRetries: Maximum retry attempts (default: 3).
  • baseDelayInMiliseconds: base delay for exponential backoff (default:1000ms).

The RetryInterceptor code already supports selective retries using retryEnabledKey and retryErrorConditionsKey in requestOptions.extra. Here's how to integrate:

4.4. Enabling/Disabling Retries per Request

Enable retries for specific requests by adding retryEnabledKey: trueto the extra map in Dio's request options:

final response = await dio.get('/api/endpoint',
options: Options(extra: {RetryInterceptor.retryEnabledKey: false}));

If retryEnabledKey is not provided, retries are disabled by default.

4.5. Specifying Retryable Status Codes per Request

Retry requests based on specific HTTP status codes by adding retryErrorConditionsKey with a list of status codes to extra:

final response = await dio.get('/api/endpoint',
options: Options(extra: {
RetryInterceptor.retryErrorConditionsKey: [408, 504], // Retry 408 and 504
}));

This example retries requests failing with 408 (Request Timeout) or 504 (Gateway Timeout) status codes, in addition to default DioExceptionType errors.

The default retry behavior is determined by the RetryConfig provided to the RetryInterceptor. Customize data class to adjust default retry settings as needed.

4.6. Looking Ahead: Possible Improvements

While the RetryInterceptor provides a robust foundation, here are a few ways you could further enhance it:

  • Remote Configuration: For even greater flexibility, consider using Firebase Remote Config or similar services to manage your RetryConfig values (like maxRetries and baseDelayInMiliseconds) remotely. This would allow you to adjust your retry strategy on the fly without app updates, which is particularly useful for responding to changing network conditions or server loads.
  • Idempotency Reminder: It's crucial to remember that retry mechanisms are most effective for idempotent requests β€” requests that can be safely repeated without causing unintended side effects. Ensure that the API endpoints you are applying retries to are designed to be idempotent. For non-idempotent requests (like certain types of write operations), you might need to implement additional safeguards or avoid retries altogether.

5. Wrapping Up

Implementing retry interceptors with Dio is a fantastic way to level up the robustness of your Flutter apps! Let's recap the key points to remember:

  • Interceptors are powerful middleware in Dio, allowing you to centralize network logic and keep your code clean.
  • Retry interceptors automatically handle transient errors, making your app more resilient to flaky networks.
  • Intelligent retry strategies, like exponential backoff and selective retries, are crucial for efficient error recovery.
  • Dio makes it easy to implement retry logic with interceptors and request options, giving you fine-grained control.

By incorporating these techniques, you can significantly improve your app's robustness and provide a better user experience.

Have you implemented retry logic in your Flutter apps before? What strategies worked best for you? I'd love to hear about your experiences in the comments! Feel free to share your thoughts or connect with me on LinkedIn for further discussions.

Thanks for reading πŸš€ and happy coding on your Flutter journey!

Report Page