Dev.to · 4 min read

Integrating APIs Seamlessly in Flutter — My Battle-Tested Pattern

Integrating APIs Seamlessly in Flutter — My Battle-Tested Pattern

After a dozen production apps, this is the API integration architecture I now use in every Flutter project — dio, interceptors, retry, caching, and error handling in five files. My first real Flutter app had an API layer that looked like a crime scene: forty-something functions, each one spinning up its own http call, its own error handling, its own hardcoded base URL. Every screen called the network directly. Every bug was a hunt across a different file. Refactoring it took a full week, and I swore I would never write Flutter networking that way again. Since then I have shipped that lesson across a dozen apps — e-commerce, a logistics tracking dashboard, a booking product, a fintech prototype. The pattern settled into five files that I now drop into every new project and barely touch afterward: the dio client, the auth interceptor, a retry layer, a cache, and typed repositories. This article walks you through each one with working code, then covers the failure modes I keep hitting so you skip the week I lost. Why dio and Not Plain http The standard http package is fine for one-off requests. It is not fine for an app with auth, retries, logging, and timeouts, because you end up reimplementing the same plumbing in every function. dio gives you four things out of the box that make the pattern possible: Interceptors — hook into every request and response, which is where auth headers, logging, and token refresh live. Retry logic — pluggable, with per-request control. Timeouts — configurable connect, receive, and send timeouts per client. Response transformation — typed access to JSON without boilerplate. Add dio and dio_cache_interceptor to pubspec.yaml: dependencies: flutter: sdk: flutter dio: ^5.4.0 dio_cache_interceptor: ^3.5.0 dio_cache_interceptor_db_store: ^3.2.0 File 1: The Client (Where Every Request Flows) One dio instance for the whole app. This is the file that owns the base URL, the timeouts, and the interceptors — and it is the reason you will never scatter Uri.parse('https://your-api.com/...') across your screens again. import 'package:dio/dio.dart'; import 'auth_interceptor.dart'; import 'retry_interceptor.dart'; Dio buildDio() { final dio = Dio( BaseOptions( baseUrl: 'https://your-api.com/api/v1', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), sendTimeout: const Duration(seconds: 10), headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, ), ); dio.interceptors.addAll([ AuthInterceptor(dio), RetryInterceptor(dio), LogInterceptor(requestBody: false, responseBody: false), ]); return dio; } One detail people miss: timeouts are set here, once, instead of being forgotten per call. And the LogInterceptor in debug builds only — gate it behind a flag or a build check, because response bodies in logs are a security hole on user devices. File 2: The Auth Interceptor (Tokens Refresh Automatically) This is the interceptor that saves your app from 401s. It attaches the access token to every outgoing request, and when a 401 comes back, it fires a single refresh request and retries the original call once — so the user never sees an error flash. class AuthInterceptor extends Interceptor { AuthInterceptor(this._dio); final Dio _dio; @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { final token = await TokenStore.readAccessToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } @override void onError(DioException err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode != 401) return handler.next(err); try { final ok = await _refreshToken(); if (!ok) { await TokenStore.clear(); handler.reject(err); // route to login return; } final token = await TokenStore.readAccessToken(); err.requestOptions.headers['Authorization'] = 'Bearer $token'; final response = await _dio.fetch(err.requestOptions); // retry once handler.resolve(response); } catch (_) { handler.next(err); } } } Two gotchas that cost me hours each: the refresh request must not itself go through the auth interceptor, or you get an infinite 401 loop — guard it with a flag on the BaseOptions. And never retry a POST on 401 blindly; the retried request can double-submit. Refresh-once-and-give-up is the safe behavior. File 3: Retry With Exponential Backoff Network flakiness is a feature of mobile life, not a bug in your code. A user driving through a tunnel will hit timeouts that have nothing to do with your API. The retry interceptor handles the two recoverable cases — timeouts and the 429 rate-limit — with exponential backoff and a cap. class RetryInterceptor extends Interceptor { @override Future onError(DioException err, ErrorInterceptorHandler handler) async { final maxRetries = 3; final options = err.requestOptions; final retries = options.extra['retryCount'] as int? ?? 0; final retryable = err.type == DioExceptionType.connectionTimeout || err.type == DioExceptionType.receiveTimeout || err.type == DioExceptionType.connectionError || err.response?.statusCode == 429 || err.response?.statusCode == 502 || err.response?.statusCode == 503; if (!retryable || retries >= maxRetries) return handler.next(err); final delayMs = 500 * (1

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News