Token-refreshing API client
HTTP wrapper that attaches a bearer token, refreshes it once on a 401 even for parallel calls, and retries the request.
FlutterAPIsflutterhttpauthrefresh
Code
import 'dart:async';
import 'package:http/http.dart' as http;
abstract class TokenStore {
Future<String?> readAccess();
Future<String?> readRefresh();
Future<void> save({required String access, required String refresh});
Future<void> clear();
}
/// Exchanges a refresh token for a new pair, or throws.
typedef Refresher = Future<({String access, String refresh})> Function(String refreshToken);
class SessionExpired implements Exception {}
class ApiClient {
ApiClient({required this.baseUrl, required this.store, required this.refresher, http.Client? inner})
: _inner = inner ?? http.Client();
final String baseUrl; // from configuration, e.g. a build-time define
final TokenStore store;
final Refresher refresher;
final http.Client _inner;
Completer<void>? _refreshing; // shared by every request that hits a 401
Future<http.Response> get(String path) => _send('GET', path);
Future<http.Response> post(String path, String body) => _send('POST', path, body: body);
Future<http.Response> _send(String method, String path, {String? body}) async {
final first = await _once(method, path, body);
if (first.statusCode != 401) return first;
await _refreshOnce();
return _once(method, path, body); // single retry with the new token
}
Future<http.Response> _once(String method, String path, String? body) async {
final req = http.Request(method, Uri.parse('$baseUrl$path'));
final access = await store.readAccess();
if (access != null) req.headers['Authorization'] = 'Bearer $access';
if (body != null) {
req.headers['Content-Type'] = 'application/json';
req.body = body;
}
return http.Response.fromStream(await _inner.send(req));
}
Future<void> _refreshOnce() {
// Parallel 401s wait on the same refresh instead of racing each other.
final running = _refreshing;
if (running != null) return running.future;
final c = _refreshing = Completer<void>();
() async {
try {
final refresh = await store.readRefresh();
if (refresh == null) throw SessionExpired();
final pair = await refresher(refresh);
await store.save(access: pair.access, refresh: pair.refresh);
c.complete();
} catch (e) {
await store.clear();
c.completeError(e is SessionExpired ? e : SessionExpired());
} finally {
_refreshing = null;
}
}();
return c.future;
}
}
Needs: http@^1.2.0
How it works
What it solves
Access tokens expire while the app is open. If every screen handles 401 on its own, a burst of parallel requests each triggers its own refresh, and rotating refresh tokens then invalidate each other and log the user out.
How it works
ApiClient sends each request with the stored access token. On a 401 it calls _refreshOnce, which keeps a single Completer; the first caller starts the refresh and everyone else awaits the same future. When it completes the original request is retried exactly once, so a second 401 is returned to the caller instead of looping.
If refreshing fails or no refresh token exists, the store is cleared and SessionExpired is thrown, which your app can catch to navigate to the sign-in screen. Storage and the refresh call are injected, so the class has no knowledge of your backend.
Adapting it
Implement TokenStore with flutter_secure_storage and Refresher with a call to your refresh endpoint using a plain client without the interceptor. Add put and delete methods the same way, or wrap the request building in a helper that accepts headers.
Pitfalls
- The refresh call itself must not use this client, otherwise a 401 there recurses.
- Streamed or multipart bodies cannot be replayed; rebuild the request for those.
- Never log the Authorization header.
How to use it
Add http to pubspec.yaml, implement TokenStore (secure storage) and Refresher.
final api = ApiClient(baseUrl: const String.fromEnvironment('API_URL'), store: store, refresher: refresh);
final res = await api.get('/orders');
Catch SessionExpired to route to login.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.