Networking & REST API Integration

Dio Interceptors: Logging, Auth Tokens & Retry

16 min Lesson 8 of 13

Dio Interceptors: Logging, Auth Tokens & Retry

As your Flutter app grows, you will find yourself repeating the same boilerplate on every network call: attaching an Authorization header, printing request details to the console, and re-attempting a request when the server is temporarily unavailable. Dio interceptors solve all three problems at a single point, without touching individual call sites. An interceptor is a middleware layer that sits between your application code and the Dio HTTP engine — it can inspect, modify, or short-circuit every request, response, and error that flows through the client.

How the Interceptor Pipeline Works

Dio processes each HTTP interaction through three hook points:

  • onRequest — called just before the request is sent to the server. Ideal for injecting headers or logging outgoing data.
  • onResponse — called immediately after a successful response is received. Use it to log response bodies or normalise data shapes.
  • onError — called when the request fails (network error, non-2xx status, or timeout). Use it to retry, refresh tokens, or convert errors into domain exceptions.

Each hook receives a handler object. You must call exactly one of handler.next(), handler.resolve(), or handler.reject() to pass control to the next interceptor or back to the caller.

Note: Dio processes interceptors in the order they are added. A logging interceptor should be registered last so it sees the final headers (including auth tokens added by earlier interceptors) and captures the true outgoing request.

Building a Logging Interceptor

A logging interceptor gives you visibility into every network interaction during development. It prints the method, URL, request headers, status code, and a truncated response body without any changes to call-site code.

LoggingInterceptor — complete implementation

import 'package:dio/dio.dart';

class LoggingInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    print('--> ${options.method} ${options.uri}');
    options.headers.forEach((k, v) => print('  Header: $k: $v'));
    if (options.data != null) print('  Body: ${options.data}');
    handler.next(options); // pass request to the next interceptor / Dio engine
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    final body = response.data.toString();
    final preview = body.length > 200 ? '${body.substring(0, 200)}...' : body;
    print('<-- ${response.statusCode} ${response.requestOptions.uri}');
    print('  Body preview: $preview');
    handler.next(response);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    print('ERR ${err.response?.statusCode} ${err.requestOptions.uri}: ${err.message}');
    handler.next(err); // propagate the error unchanged
  }
}

Building an Auth Token Interceptor

Rather than manually adding 'Authorization': 'Bearer \$token' to every http.get() call, an auth interceptor reads the token from a storage service and attaches it automatically. The token source can be an in-memory variable, SharedPreferences, or a secure-storage package — the interceptor does not care.

AuthInterceptor — inject the Bearer token on every request

import 'package:dio/dio.dart';

/// Reads the current access token and attaches it as a Bearer header.
/// Replace [_tokenStore] with your own token source (SharedPreferences, etc.).
class AuthInterceptor extends Interceptor {
  final TokenStore _tokenStore;

  AuthInterceptor(this._tokenStore);

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

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    // If the server returns 401, the token may have expired.
    // Attempt a silent token refresh before retrying the original request.
    if (err.response?.statusCode == 401) {
      try {
        final newToken = await _tokenStore.refreshAccessToken();
        // Clone the original request with the new token and retry it.
        final opts = err.requestOptions
          ..headers['Authorization'] = 'Bearer $newToken';
        final dio = Dio(); // use a fresh client to avoid interceptor loops
        final response = await dio.fetch(opts);
        return handler.resolve(response); // short-circuit the error
      } catch (_) {
        // Refresh also failed — propagate the original 401 error.
      }
    }
    handler.next(err);
  }
}

/// Minimal interface your token storage must satisfy.
abstract class TokenStore {
  Future<String?> getAccessToken();
  Future<String> refreshAccessToken();
}

Building a Retry Interceptor

Transient failures — DNS hiccups, brief server restarts, or flaky mobile connections — should not surface as errors to the user. A retry interceptor transparently re-sends a request up to N times with an exponential back-off delay.

RetryInterceptor — exponential back-off on network errors

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

class RetryInterceptor extends Interceptor {
  final Dio dio;
  final int maxRetries;
  final Duration initialDelay;

  RetryInterceptor({
    required this.dio,
    this.maxRetries = 3,
    this.initialDelay = const Duration(seconds: 1),
  });

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    // Only retry on connection / timeout errors, not on 4xx responses.
    final isNetworkError =
        err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.receiveTimeout ||
        err.type == DioExceptionType.connectionError;

    final attempt = (err.requestOptions.extra['retryCount'] as int?) ?? 0;

    if (isNetworkError && attempt < maxRetries) {
      // Store the attempt count in the request's extra map.
      err.requestOptions.extra['retryCount'] = attempt + 1;

      // Exponential back-off: 1 s, 2 s, 4 s, ...
      final delay = initialDelay * (1 << attempt);
      await Future.delayed(delay);

      try {
        final response = await dio.fetch(err.requestOptions);
        return handler.resolve(response);
      } on DioException catch (retryErr) {
        return handler.reject(retryErr);
      }
    }

    handler.next(err); // exhausted retries or non-retriable error
  }
}

Wiring Interceptors onto a Dio Instance

Register all interceptors once when you create the Dio client — typically in a service locator or dependency-injection setup. Order matters: auth runs first (adds the header), retry runs second (catches errors), logging runs last (sees the final request).

DioClient factory — composing all three interceptors

import 'package:dio/dio.dart';

Dio createDioClient(TokenStore tokenStore) {
  final dio = Dio(
    BaseOptions(
      baseUrl: 'https://api.example.com',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 15),
      headers: {'Accept': 'application/json'},
    ),
  );

  dio.interceptors.addAll([
    AuthInterceptor(tokenStore),     // 1. attach auth header
    RetryInterceptor(dio: dio),      // 2. retry transient failures
    LoggingInterceptor(),            // 3. log final request & response
  ]);

  return dio;
}
Tip: When a RetryInterceptor retries a request, it internally calls dio.fetch() on the same Dio instance, which re-runs all interceptors — including AuthInterceptor. This is exactly what you want: the retried request will get a fresh auth header. Just ensure the retry counter stored in extra is checked to avoid an infinite loop.
Warning: Never create a second Dio instance inside AuthInterceptor.onError that also has the same AuthInterceptor attached. That creates an interceptor loop: the refresh request triggers another 401, which triggers another refresh, and so on indefinitely. Use a separate, plain Dio() without auth interceptors for the token-refresh call.

Summary

Dio interceptors give you a clean, centralised place to handle cross-cutting concerns. A logging interceptor prints every request and response without polluting call-site code. An auth interceptor attaches Bearer tokens automatically and can silently refresh an expired token on a 401 error. A retry interceptor re-sends transient failures with exponential back-off so users never see a "connection error" for a one-second blip. Register interceptors in the order: auth → retry → logging, so each layer sees what the subsequent ones will act on.