Dio Interceptors: Logging, Auth Tokens & Retry
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.
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;
}
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.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.