Many apps that use Dio end up with the same four interceptors. One retries failed requests. One attaches the access token and refreshes it on a 401. One logs. And at some point someone adds a fourth to stop the same GET from firing three times when three widgets build together.
They are usually written by hand, in a hurry, and they tend to have the same bugs. The retry delay is linear and zero on the first retry. The retry replays a POST, and the server may run the same write twice. Five requests get a 401 at once and the app refreshes the token five times. The logger prints the Authorization header to the console.
dio_essentials is a pure Dart package with those four interceptors, written to avoid those bugs. Its only dependency is dio, and it works on the VM, Flutter and the web.
What it does
DedupInterceptor
It merges identical in-flight requests into one network call. The key is the method, the URI (query order ignored), selected headers and a body hash, and you can supply your own key builder.
There is an optional short-lived cache for GET and HEAD with a configurable TTL. Writes are never cached or replayed, and a write clears the cache. Cancelling one waiter never cancels the others: the shared call is cancelled only when every waiter has cancelled. Each waiter gets its own copy of the response, so callers cannot mutate each other's data.
RetryInterceptor
It uses real exponential backoff with full jitter: random(0, min(cap, base * 2^n)). You configure the base, the cap and the maximum number of attempts. It honours Retry-After in both the seconds form and the HTTP-date form.
It retries GET, HEAD, OPTIONS, PUT and DELETE only. POST and PATCH are retried only when the request has an Idempotency-Key header or opts in explicitly. It retries connection errors, timeouts and the statuses 408, 429, 502, 503 and 504, and a custom predicate can change this. It never retries a cancelled request, or a Stream or FormData body. The clock, sleep and random source can be injected for deterministic tests.
AuthRefreshInterceptor
It attaches a token from an async provider. On a 401, it runs exactly one refresh while concurrent requests wait for it, then replays each of them once with the new token. If the refresh fails, every waiter fails with a SessionExpiredException, and onSessionExpired fires once.
It supports skip paths such as '/auth/*', and an optional proactive refresh when the JWT exp is within a leeway. It cannot loop: any request made inside the refresh callback bypasses the interceptor, even on the same Dio instance. And when a stale 401 arrives after a refresh already finished, it compares tokens and replays without a second refresh.
RedactingLogInterceptor
A compact logger with one record per event and a pluggable sink. It redacts headers, query parameters, form fields and nested JSON keys (authorization, cookie, set-cookie, password, token, access, refresh, secret, api_key, and more). Matching ignores case and separators.
It replaces any JWT-shaped string with a fingerprint such as jwt#a1b2c3, truncates large bodies, and flags HTML responses to JSON endpoints. Error records are scrubbed too, including the exception's text. The redaction happens on the structured data before formatting, not per printed line, so a token wrapped across two lines is not missed.
There is also JwtClaims.decodeUnverified, which reads JWT claims such as exp and sub for client-side hints. It does not verify the signature.
How to use it
Add it from GitHub:
dependencies: dio_essentials: git: https://github.com/AbdeldjalilChougui/dio_essentials Then build your client. The order matters, and the README example shows the recommended one:
import 'package:dio/dio.dart'; import 'package:dio_essentials/dio_essentials.dart'; Dio buildClient(TokenStore store, void Function() goToLogin) { final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com')); dio.interceptors.addAll([ // 1. Dedup first: everything after it sees one request per merged group. DedupInterceptor(dio: dio, cacheTtl: const Duration(seconds: 3)), // 2. The logger next, so it also logs the 401s and 503s that the // interceptors below recover from. RedactingLogInterceptor(sink: print, maxBodyChars: 1500), // 3. Auth. AuthRefreshInterceptor( dio: dio, tokenProvider: store.readAccess, refreshToken: () async { // Requests made in here bypass the interceptor automatically. final res = await dio.post<Map<String, dynamic>>( '/auth/refresh', data: {'refresh': await store.readRefresh()}, ); final access = res.data!['access'] as String; await store.writeAccess(access); return access; }, onSessionExpired: goToLogin, skipPaths: const ['/auth/*'], proactiveLeeway: const Duration(seconds: 30), ), // 4. Retry. RetryInterceptor( dio: dio, maxAttempts: 4, baseDelay: const Duration(milliseconds: 300), maxDelay: const Duration(seconds: 10), ), ]); return dio; } TokenStore is your own class with readAccess, readRefresh and writeAccess.
A POST becomes safely retryable when you give it an idempotency key:
await dio.post<void>( '/payments', data: {'amount': 1200}, options: Options(headers: {'Idempotency-Key': paymentId}), ); Per-request switches go through Options(extra: {...}): DedupInterceptor.extraKey to force or skip merging, RetryInterceptor.retryExtraKey to allow or forbid a retry, and AuthRefreshInterceptor.skipExtraKey for a request with no token and no refresh.
Why that order
Dio runs handlers in the order the interceptors were added. When an interceptor recovers from an error, the error handlers after it never see that error.
-
DedupInterceptorgoes first. The shared call re-enters the chain, so any interceptor before it would run twice for the first caller. -
RedactingLogInterceptorbefore auth and retry logs every attempt, including recovered failures. The trade-off is that the request line does not show headers added later, such asAuthorization. If you put it last, you see the final headers, but only final outcomes. -
AuthRefreshInterceptorandRetryInterceptorreplay through the sameDioinstance, so replays go through the whole chain again. Pass them theDiothey are attached to. A replay through a freshDio()loses your interceptors.
example/main.dart is a runnable, offline tour: dart run example/main.dart.
Things to know
- Dedup copies JSON-like data only. A merged response is deep-copied for maps, lists, strings, numbers and bytes. Custom objects produced by a custom transformer are shared. Requests with
FormDataorStreambodies, and requests expectingResponseType.stream, are never merged. - Retry and
FormData.FormDatabodies are never retried, even when every part could be cloned. - The refresh callback must persist the new token, so that
tokenProviderreturns it. A401on a request that carried no token is passed through without a refresh. - The bypass rule uses a
Zonevalue. Work that escapes the zone, such as a request started from a stream listener created earlier, must be marked withskipExtraKeyor listed inskipPaths. - Redaction is best effort for values it cannot recognise. A secret whose key is not in the configured names, that is not a JWT or auth-scheme credential, and that was not redacted elsewhere in the same request, will be printed. Add your own names with
redactKeysorredactKeyFragments. Values shorter than 4 characters are redacted where they appear structurally, but are not searched for in free text. -
JwtClaimsnever verifies signatures, by design. Use it for hints such as "refresh soon", never for trust.
Why I built it
The README has a section called "Migrating from ad-hoc interceptors", and it is a list of bugs. A delay computed as base * (factor * attempt) is linear, and zero on the first retry. Retrying on connection errors "for every method" also replays POST. A QueuedInterceptor serialises all errors behind one refresh, so unrelated errors are delayed. A refresh callback that calls the same Dio can deadlock. A dedup cache keyed by method and path only replays a POST response. A logger that redacts per printed line misses a token wrapped across lines.
None of these shows up in a happy-path test. They show up under load, on a bad connection, or when a token expires while five requests are in flight. I wanted these four interceptors written once, with each of those cases handled, instead of rewritten in every project.
Get the code
The source, the per-request options table, the example and the tests are on GitHub: https://github.com/AbdeldjalilChougui/dio_essentials. It is MIT licensed.
Originally published at abdeldjalilchougui.biz.
![Software Architecture Fundamentals — Ch.09: Scalable System Design [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fulk272oee9h5w3aozfmo.png)
![Software Architecture Fundamentals — Ch.10: Software Defined Robot Architecture [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkgatq0r1ijgi590hux6o.png)
![Software Architecture Fundamentals — Ch.11: Cross Platform SW Design [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuj6eszp10nmqzkcrqxcn.png)
