REST APIs with Dio, Interceptors, Token Refresh & Retries
Production HTTP Client Patterns Every Senior Flutter Dev Uses Daily
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Dio is Flutter's production HTTP client. Interceptors provide middleware for auth token injection, logging, and error handling. QueuedInterceptor handles token refresh correctly under concurrent requests. Proper error mapping, retry logic, and cancellation complete a production-grade HTTP layer.
Real-world relevance
In a SaaS collaboration app with real-time features: every API request automatically gets the JWT from FlutterSecureStorage via the auth interceptor. When the JWT expires mid-session, QueuedInterceptor refreshes it exactly once while queuing all concurrent requests. Failed requests due to network blips retry with exponential backoff. When the user leaves a screen, CancelToken stops in-flight requests. All DioExceptions are mapped to domain exceptions before reaching the UI.
Key points
- Dio Over http Package — Dio provides: interceptors, request/response transformation, timeout configuration, multipart uploads, download progress, cancellation tokens, and auto-parsing of JSON. The http package is simpler but lacks interceptors — essential for auth token injection and retry logic. In production, Dio is the de facto standard.
- BaseOptions Configuration — Set baseUrl, connectTimeout, receiveTimeout, headers globally. connectTimeout: how long to wait for a TCP connection. receiveTimeout: how long to wait for the server to start responding. In a fintech app, set tight timeouts (5-10s) to fail fast rather than leaving users waiting at payment screens.
- Interceptors — The Power Feature — Interceptors are middleware for HTTP requests. onRequest: add headers (auth token, device ID, locale). onResponse: transform data, log successes. onError: handle errors, trigger token refresh, retry. Stack multiple interceptors with dio.interceptors.addAll([logInterceptor, authInterceptor, retryInterceptor]).
- Auth Token Injection — In onRequest, read the access token from secure storage and add it to every request: options.headers['Authorization'] = 'Bearer $token'. This eliminates per-request token passing. The interceptor is the single place to update when auth header format changes.
- Token Refresh with QueuedInterceptor — When a 401 response arrives, you need to: (1) refresh the access token, (2) retry the failed request with the new token. If multiple requests hit 401 simultaneously, only ONE refresh should happen — others must queue. QueuedInterceptor serializes concurrent requests during refresh, preventing N parallel refresh calls.
- Retry Logic — Transient network failures (5xx, timeout) should be retried automatically. Use the dio_smart_retry or dio_retry package, or implement manually with a RequestOptions clone. Retry with exponential backoff: wait 1s, 2s, 4s. Idempotent requests (GET, PUT) are safe to retry. Non-idempotent (POST payments) need careful retry logic with server-side idempotency keys.
- Error Mapping — Never expose DioException to the domain or UI layer. In the data layer, catch DioException and throw domain exceptions: NotFoundException, UnauthorizedException, NetworkException, ServerException. The UI handles domain exceptions, not HTTP status codes. This is the Repository layer's responsibility.
- Pagination Patterns — Cursor-based pagination: server returns a nextCursor token — use it in the next request. More scalable, consistent during live data updates. Offset-based: use page number and page size. Simpler but inconsistent if items are added during pagination. In a real-time chat app, use cursor pagination to avoid missing messages.
- Cancellation Tokens — Use CancelToken to cancel in-flight requests when the user navigates away. Pass the token to Dio: dio.get(url, cancelToken: _cancelToken). In your widget/ViewModel dispose(), call _cancelToken.cancel(). This prevents setState() on disposed widgets and memory leaks.
- Logging in Development — Add LogInterceptor in debug mode only: if (kDebugMode) dio.interceptors.add(LogInterceptor(responseBody: true)). Never log tokens or PII in production. Pretty-print JSON with JsonEncoder.withIndent(). In a CI pipeline, use separate environment-aware DI configuration to exclude the logger.
- Response Caching — dio_cache_interceptor provides HTTP-level caching with ETag/Last-Modified support. For fintech data, cache conservatively with short TTLs. For reference data (countries, currencies), cache aggressively. The cache layer lives in the data layer — the domain never knows data is cached.
Code example
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter/foundation.dart';
// === 1. Dio Configuration ===
Dio createDio({required String baseUrl}) {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-App-Version': '2.1.0',
},
),
);
if (kDebugMode) {
dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
logPrint: (o) => debugPrint(o.toString()),
));
}
dio.interceptors.add(AuthInterceptor(
storage: const FlutterSecureStorage(),
onRefresh: () => _refreshToken(dio),
));
return dio;
}
// === 2. Auth Interceptor with QueuedInterceptorWrapper ===
class AuthInterceptor extends QueuedInterceptor {
final FlutterSecureStorage _storage;
final Future<String?> Function() _onRefresh;
AuthInterceptor({required FlutterSecureStorage storage, required Future<String?> Function() onRefresh})
: _storage = storage, _onRefresh = onRefresh;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _storage.read(key: 'access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
// Token expired — attempt refresh
try {
final newToken = await _onRefresh();
if (newToken != null) {
await _storage.write(key: 'access_token', value: newToken);
// Retry original request with new token
final opts = err.requestOptions;
opts.headers['Authorization'] = 'Bearer $newToken';
final response = await Dio().fetch(opts);
return handler.resolve(response);
}
} catch (e) {
// Refresh failed — force logout
await _storage.deleteAll();
handler.reject(err);
return;
}
}
handler.next(err);
}
}
// === 3. Error Mapping in Repository ===
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<T> get<T>(String path, {required T Function(dynamic) fromJson, CancelToken? cancelToken}) async {
try {
final response = await _dio.get(path, cancelToken: cancelToken);
return fromJson(response.data);
} on DioException catch (e) {
throw _mapError(e);
}
}
Exception _mapError(DioException e) {
if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.receiveTimeout) {
return const TimeoutException('Connection timed out');
}
switch (e.response?.statusCode) {
case 400: return ValidationException(e.response?.data['message'] ?? 'Bad request');
case 401: return const UnauthorizedException();
case 403: return const ForbiddenException();
case 404: return const NotFoundException();
case 422: return UnprocessableException(e.response?.data);
case 500: return const ServerException();
default: return NetworkException(e.message ?? 'Unknown error');
}
}
}
// === 4. Cursor Pagination Pattern ===
class PaginatedResponse<T> {
final List<T> items;
final String? nextCursor;
final bool hasMore;
const PaginatedResponse({required this.items, this.nextCursor, required this.hasMore});
factory PaginatedResponse.fromJson(Map<String, dynamic> json, T Function(dynamic) fromItem) =>
PaginatedResponse(
items: (json['data'] as List).map(fromItem).toList(),
nextCursor: json['meta']['next_cursor'] as String?,
hasMore: json['meta']['has_more'] as bool,
);
}
// Paginated repository method
Future<PaginatedResponse<Message>> getMessages({String? cursor, int limit = 30}) async {
final response = await _dio.get(
'/messages',
queryParameters: {'cursor': cursor, 'limit': limit},
);
return PaginatedResponse.fromJson(response.data, Message.fromJson);
}
// === 5. Cancellation ===
class MessageViewModel {
final Dio _dio;
CancelToken? _cancelToken;
MessageViewModel(this._dio);
Future<void> loadMessages(String channelId) async {
_cancelToken?.cancel();
_cancelToken = CancelToken();
try {
await _dio.get('/channels/$channelId/messages', cancelToken: _cancelToken);
} on DioException catch (e) {
if (CancelToken.isCancel(e)) return; // Normal — user navigated away
rethrow;
}
}
void dispose() => _cancelToken?.cancel();
}Line-by-line walkthrough
- 1. Dio BaseOptions sets baseUrl and timeouts globally — all requests inherit these settings
- 2. connectTimeout: fail fast if the server doesn't respond in 10 seconds
- 3. receiveTimeout: fail fast if the server starts but takes too long to send data
- 4. LogInterceptor only runs in debug mode — never logs tokens in production
- 5. AuthInterceptor extends QueuedInterceptor — critical for serializing concurrent token refreshes
- 6. onRequest reads the access token from secure storage and injects it as a Bearer header
- 7. onError intercepts 401 responses before they reach the repository
- 8. On 401: call the refresh function, write the new token to secure storage
- 9. Clone the original request options with the new token and fetch it
- 10. On refresh failure: clear all tokens and reject — forces re-login
- 11. _mapError translates DioException to domain exceptions — no DioException leaks past the data layer
- 12. CancelToken: cancel previous request when a new load starts — prevents race conditions
- 13. CancelToken.isCancel(e) distinguishes intentional cancellations from real errors
Spot the bug
class AuthInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
final newToken = await refreshToken();
final opts = err.requestOptions;
opts.headers['Authorization'] = 'Bearer $newToken';
final response = await Dio().fetch(opts);
handler.resolve(response);
}
handler.next(err);
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Dio Package (pub.dev)
- Dio Interceptors (Dio GitHub)
- flutter_secure_storage (pub.dev)
- dio_smart_retry (pub.dev)
- Dio Token Refresh Pattern (Flutter Community)