Lesson 23 of 77 advanced

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

Dio is your app's postal service. Interceptors are like post office sorting rules: 'every outgoing package gets a tracking sticker (auth header)'. Token refresh is like auto-renewing your PO Box lease before it expires. Retry logic is like the postman trying three times before leaving a 'sorry we missed you' card. QueuedInterceptor is the line at the post office — everyone waits their turn, preventing chaos.

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

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. 1. Dio BaseOptions sets baseUrl and timeouts globally — all requests inherit these settings
  2. 2. connectTimeout: fail fast if the server doesn't respond in 10 seconds
  3. 3. receiveTimeout: fail fast if the server starts but takes too long to send data
  4. 4. LogInterceptor only runs in debug mode — never logs tokens in production
  5. 5. AuthInterceptor extends QueuedInterceptor — critical for serializing concurrent token refreshes
  6. 6. onRequest reads the access token from secure storage and injects it as a Bearer header
  7. 7. onError intercepts 401 responses before they reach the repository
  8. 8. On 401: call the refresh function, write the new token to secure storage
  9. 9. Clone the original request options with the new token and fetch it
  10. 10. On refresh failure: clear all tokens and reject — forces re-login
  11. 11. _mapError translates DioException to domain exceptions — no DioException leaks past the data layer
  12. 12. CancelToken: cancel previous request when a new load starts — prevents race conditions
  13. 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?
Two bugs: one around concurrent refresh calls, one around control flow.
Show answer
Bug 1: extends Interceptor instead of QueuedInterceptor — if 5 requests simultaneously get 401, all 5 call refreshToken() in parallel, causing a 'refresh storm' where multiple tokens are issued and old ones are invalidated. Fix: extend QueuedInterceptor to serialize concurrent error handling. Bug 2: handler.resolve(response) and handler.next(err) are both called on the 401 path — after resolving, the code falls through to handler.next(err) which throws an exception. Add 'return' after handler.resolve(response) to prevent the fall-through.

Explain like I'm 5

Imagine every letter your app sends needs a stamp (auth token). Instead of you licking a stamp for each letter, the post office (Dio interceptor) stamps them all automatically. If a letter comes back 'address changed' (401), the post office calls the postal service to get your new address (refresh token), then resends ALL the waiting letters with the new address — but only makes ONE phone call, not one per letter. That's the queued interceptor.

Fun fact

The QueuedInterceptor pattern for token refresh is one of the most googled Flutter problems. Naive implementations cause a 'refresh storm': 5 concurrent 401s trigger 5 parallel refresh calls, all succeed with different tokens, and 4 of them invalidate the session. QueuedInterceptor serializes this at the Dart level — it's the correct solution and a signal of real production experience.

Hands-on challenge

Build a production Dio setup for a fintech app: (1) BaseOptions with 10s timeouts and Content-Type header, (2) an AuthInterceptor that injects a Bearer token from FlutterSecureStorage, (3) a 401 handler that calls a refreshToken() function and retries the original request, (4) error mapping for 400, 401, 404, 500 status codes to domain exceptions, (5) debug-only LogInterceptor.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery