Mastering Flutter Dio Interceptors: Auth, Logging, Retry, and Token Refresh

Master Flutter Dio interceptors: headers, logging, retries with backoff, robust token refresh, error mapping, testing, and best practices.

ASOasis
8 min read
Mastering Flutter Dio Interceptors: Auth, Logging, Retry, and Token Refresh

Image used for representation purposes only.

Overview

Dio is a powerful HTTP client for Dart and Flutter that offers a flexible interceptor system. Interceptors let you observe, modify, retry, or short‑circuit requests and responses at predictable points in the lifecycle. With them you can:

  • Add headers and authentication tokens automatically
  • Log requests/responses safely
  • Retry transient failures with backoff and jitter
  • Refresh expired tokens and replay failed calls
  • Normalize errors to app‑friendly types
  • Enforce cross‑cutting rules (timeouts, caching, metrics)

This guide shows practical, production‑ready patterns for using Dio interceptors in Flutter.

How interceptors work in Dio

Interceptors form a chain that wraps around every request.

  • Request phase: runs before the request is sent; you can mutate RequestOptions or cancel/resolve early.
  • Response phase: runs when a response arrives; you can transform or replace the Response.
  • Error phase: runs when an exception occurs; you can map, retry, or convert it into a successful Response.

You can add interceptors in order; the last added runs closest to the network. Order matters when composing behaviors like auth, logging, and retries.

Project setup

Add Dio to pubspec.yaml and create a single, shared Dio instance with sensible defaults.

final dio = Dio(
  BaseOptions(
    baseUrl: 'https://api.example.com',
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 20),
    sendTimeout: const Duration(seconds: 20),
    headers: {'Accept': 'application/json'},
    responseType: ResponseType.json,
    contentType: 'application/json',
  ),
);

Tip: Use dependency injection (e.g., get_it or Riverpod) to provide this Dio instance across your app.

Interceptor options: extend vs. wrap

You can implement an interceptor in two primary ways:

  • Extend Interceptor and override onRequest, onResponse, onError.
  • Use InterceptorsWrapper/QueuedInterceptorsWrapper and pass callbacks.

Extending Interceptor is ideal for reusable, testable classes. QueuedInterceptorsWrapper ensures callbacks run sequentially, which is helpful during token refresh to avoid races.

Add authentication headers automatically

A simple interceptor can add a Bearer token to each request.

class TokenStore {
  String? accessToken;
}

class AuthInterceptor extends Interceptor {
  final TokenStore store;
  AuthInterceptor(this.store);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = store.accessToken;
    if (token != null && token.isNotEmpty) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }
}

// Usage
final tokenStore = TokenStore();
dio.interceptors.add(AuthInterceptor(tokenStore));

Safe logging without leaking secrets

Dio includes LogInterceptor, but you should redact sensitive data.

class SafeLogInterceptor extends Interceptor {
  final void Function(String) printer;
  SafeLogInterceptor({this.printer = debugPrint});

  String _redact(String input) {
    // Very basic redaction: hide tokens and passwords
    return input
      .replaceAll(RegExp(r'("Authorization"\s*:\s*"Bearer [^"]+")'), '"Authorization": "Bearer ***"')
      .replaceAll(RegExp(r'("password"\s*:\s*"[^"]+")', caseSensitive: false), '"password": "***"');
  }

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    printer('[REQ] ${options.method} ${options.uri}');
    if (options.data != null) printer(_redact(options.data.toString()));
    handler.next(options);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    printer('[RES] ${response.statusCode} ${response.requestOptions.uri}');
    handler.next(response);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    printer('[ERR] ${err.type} ${err.message} @ ${err.requestOptions.uri}');
    handler.next(err);
  }
}

// Place logging near the top so you see mutated values too
dio.interceptors.add(SafeLogInterceptor());

Retry transient failures with exponential backoff

Build a RetryInterceptor that retries on timeouts, network errors, or 5xx responses.

import 'dart:math';

class RetryInterceptor extends Interceptor {
  final Dio dio;
  final int retries;
  final Duration baseDelay;
  final int maxDelayMs;

  RetryInterceptor({
    required this.dio,
    this.retries = 3,
    this.baseDelay = const Duration(milliseconds: 300),
    this.maxDelayMs = 3 * 1000,
  });

  bool _shouldRetry(DioException err) {
    final code = err.response?.statusCode ?? 0;
    return err.type == DioExceptionType.connectionTimeout ||
           err.type == DioExceptionType.receiveTimeout   ||
           err.type == DioExceptionType.sendTimeout      ||
           err.type == DioExceptionType.connectionError  ||
           code >= 500;
  }

  Duration _backoff(int attempt) {
    final base = baseDelay.inMilliseconds;
    final ms = min(maxDelayMs, base * (1 << (attempt - 1)));
    final jitter = Random().nextInt(100); // add a little jitter
    return Duration(milliseconds: ms + jitter);
  }

  @override
  Future onError(DioException err, ErrorInterceptorHandler handler) async {
    var attempt = (err.requestOptions.extra['retry_attempt'] as int?) ?? 0;
    if (_shouldRetry(err) && attempt < retries) {
      attempt++;
      await Future.delayed(_backoff(attempt));

      final opts = err.requestOptions;
      opts.extra['retry_attempt'] = attempt;

      try {
        final response = await dio.fetch(opts);
        return handler.resolve(response);
      } catch (e) {
        return handler.next(e is DioException ? e : err);
      }
    }
    handler.next(err);
  }
}

// Add retry close to the bottom so it runs near the network
dio.interceptors.add(RetryInterceptor(dio: dio));

Notes:

  • Keep retries low; retrying non‑idempotent POSTs can cause issues. Use requestOptions.extra to opt‑in/out per call.
  • Consider checking Retry-After headers for 429 responses if your API emits them.

Robust token refresh without race conditions

When the server returns 401 for an expired access token, refresh it once, then replay queued requests.

Pattern:

  1. AuthInterceptor reads the current token and adds Authorization.
  2. TokenRefreshInterceptor reacts to 401s, performs a refresh via a separate authDio, updates TokenStore, and retries the original request.
  3. A Completer barrier ensures only one refresh runs at a time; others await.
class TokenRefreshInterceptor extends Interceptor {
  final Dio dio;        // main client
  final Dio authDio;    // dedicated client for refresh endpoint
  final TokenStore store;

  Completer<void>? _refreshCompleter;

  TokenRefreshInterceptor(this.dio, this.authDio, this.store);

  bool _isUnauthorized(DioException err) => err.response?.statusCode == 401;

  @override
  Future onError(DioException err, ErrorInterceptorHandler handler) async {
    if (!_isUnauthorized(err)) return handler.next(err);

    try {
      await _refreshTokenOnce();

      // Clone and retry original request with updated token
      final req = err.requestOptions;
      final options = Options(
        method: req.method,
        headers: req.headers,
        responseType: req.responseType,
        contentType: req.contentType,
        followRedirects: req.followRedirects,
        receiveDataWhenStatusError: req.receiveDataWhenStatusError,
        validateStatus: req.validateStatus,
        extra: Map<String, dynamic>.from(req.extra)..remove('retry_attempt'),
      );

      final response = await dio.request(
        req.path,
        data: req.data,
        queryParameters: req.queryParameters,
        options: options,
        cancelToken: req.cancelToken,
        onSendProgress: req.onSendProgress,
        onReceiveProgress: req.onReceiveProgress,
      );

      return handler.resolve(response);
    } catch (e) {
      // If refresh fails, bubble up and let caller handle sign-out
      return handler.next(err);
    }
  }

  Future<void> _refreshTokenOnce() async {
    // Reuse an in-flight refresh if present
    if (_refreshCompleter != null) {
      return _refreshCompleter!.future;
    }

    _refreshCompleter = Completer<void>();
    try {
      final refreshToken = store.accessToken == null ? null : await _readRefreshToken();
      if (refreshToken == null) throw StateError('No refresh token');

      // Do not attach AuthInterceptor to authDio to avoid loops
      final resp = await authDio.post(
        '/auth/refresh',
        data: {'refresh_token': refreshToken},
        options: Options(headers: {'Authorization': null}),
      );

      final newAccess = resp.data['access_token'] as String?;
      final newRefresh = resp.data['refresh_token'] as String?;

      await _persistTokens(newAccess, newRefresh);
      _refreshCompleter!.complete();
    } catch (e) {
      _refreshCompleter!.completeError(e);
      rethrow;
    } finally {
      _refreshCompleter = null;
    }
  }

  Future<String?> _readRefreshToken() async {
    // Implement secure storage read
    return 'stored-refresh-token';
  }

  Future<void> _persistTokens(String? access, String? refresh) async {
    store.accessToken = access;
    // Persist both in secure storage; update memory cache too
  }
}

// Setup
final authDio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
dio.interceptors
  ..add(AuthInterceptor(tokenStore))
  ..add(TokenRefreshInterceptor(dio, authDio, tokenStore));

Key points:

  • Use a dedicated Dio for refresh to avoid interceptor recursion.
  • Serialize refresh calls with a Completer so parallel 401s don’t trigger multiple refreshes.
  • Keep refresh logic in onError and keep AuthInterceptor simple.

Ordering and composition

A practical order from top (first) to bottom (closest to network):

  1. SafeLogInterceptor (redacted logging)
  2. AuthInterceptor (adds Authorization)
  3. Custom headers/metrics interceptors
  4. TokenRefreshInterceptor (handles 401s)
  5. RetryInterceptor (retries transient errors)

This order ensures retries occur after refresh attempts and after any request transformations.

Cancellation and timeouts

Use CancelToken to cancel in‑flight requests (e.g., user navigates away or retypes a search query):

final cancel = CancelToken();
final future = dio.get('/search', queryParameters: {'q': 'flutter'}, cancelToken: cancel);
// Later
cancel.cancel('User navigated away');

Set per‑request timeouts when needed:

await dio.get(
  '/slow',
  options: Options(receiveTimeout: const Duration(seconds: 5)),
);

Map DioException to domain errors

Centralize error mapping so UI code handles a small, predictable set.

enum NetworkErrorType { noConnection, timeout, unauthorized, server, badCertificate, cancelled, unknown }

class NetworkError implements Exception {
  final NetworkErrorType type;
  final String message;
  NetworkError(this.type, this.message);
}

NetworkError mapDioError(DioException e) {
  switch (e.type) {
    case DioExceptionType.connectionTimeout:
    case DioExceptionType.receiveTimeout:
    case DioExceptionType.sendTimeout:
      return NetworkError(NetworkErrorType.timeout, 'Request timed out');
    case DioExceptionType.badCertificate:
      return NetworkError(NetworkErrorType.badCertificate, 'Certificate error');
    case DioExceptionType.cancel:
      return NetworkError(NetworkErrorType.cancelled, 'Request cancelled');
    case DioExceptionType.connectionError:
      return NetworkError(NetworkErrorType.noConnection, 'No internet connection');
    case DioExceptionType.badResponse:
      final code = e.response?.statusCode ?? 0;
      if (code == 401) return NetworkError(NetworkErrorType.unauthorized, 'Unauthorized');
      if (code >= 500) return NetworkError(NetworkErrorType.server, 'Server error');
      return NetworkError(NetworkErrorType.unknown, 'Unexpected response');
    default:
      return NetworkError(NetworkErrorType.unknown, e.message ?? 'Unknown error');
  }
}

Testing interceptors

Testing gives confidence that headers, retries, and refresh flows behave correctly.

  • Use a mock adapter (e.g., http_mock_adapter) to stub network responses.
  • Inject TokenStore and verify headers are set when tokens exist.
  • Simulate 401 then return a refreshed token and ensure the original request is replayed.

Example with a mock adapter pattern:

final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
final adapter = DioAdapter(dio: dio); // from http_mock_adapter package

// 1) First call returns 401
adapter.onGet(
  '/profile',
  (server) => server.reply(401, {'error': 'expired'}),
  headers: {'Authorization': 'Bearer old-token'},
);

// 2) Refresh endpoint returns new tokens
final authDio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
final authAdapter = DioAdapter(dio: authDio);
authAdapter.onPost('/auth/refresh', (server) => server.reply(200, {
  'access_token': 'new-token',
  'refresh_token': 'new-refresh',
}));

// 3) Replay of /profile succeeds
adapter.onGet('/profile', (server) => server.reply(200, {'name': 'Dash'}),
  headers: {'Authorization': 'Bearer new-token'},
);

Performance tips

  • Interceptors run on the main isolate; avoid heavy JSON work there. Offload large processing to compute or isolates.
  • Keep logs concise in release builds; excessive logging can hurt frame times.
  • Prefer immutable request mutations to avoid accidental cross‑request leakage.

Security considerations

  • Never log raw tokens, API keys, or PII. Redact aggressively.
  • Use HTTPS everywhere. For certificate pinning, provide a custom HttpClientAdapter with a SecurityContext and validate server certificates, or use a dedicated pinning plugin.
  • Store tokens in secure storage (flutter_secure_storage) and keep an in‑memory cache for speed.

Troubleshooting checklist

  • 401 loop? Ensure the refresh call uses a separate Dio without AuthInterceptor to avoid recursion.
  • Requests not retried? Verify that RetryInterceptor runs after TokenRefreshInterceptor and that you’re not retrying non‑idempotent calls.
  • Headers missing? Confirm interceptor order and that a later interceptor didn’t overwrite them.
  • Duplicate refreshes? Use a Completer barrier or a mutex to serialize refresh.
  • Spurious timeouts? Calibrate connect/receive/send timeouts per environment.

Putting it all together

A typical production stack might look like this:

final tokenStore = TokenStore();
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'))
  ..interceptors.add(SafeLogInterceptor())
  ..interceptors.add(AuthInterceptor(tokenStore));

final authDio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));

dio.interceptors
  ..add(TokenRefreshInterceptor(dio, authDio, tokenStore))
  ..add(RetryInterceptor(dio: dio));

With these pieces, your Flutter app gains resilient, observable, and secure networking behavior powered by Dio interceptors.

Related Posts