Skip to content

2. The network layer

Everything in doc 1 sat on top of one transport, and that transport was the cheapest thing to fix and the biggest single win. This doc covers it before the caching work, because caching a slow client just hides a slow client.

OLD — DioApiProvider.get() NEW — one shared client
───────────────────────────── ──────────────────────────────
request 1 request 1
await userAgent() ← platform channel (UA resolved once, memoised)
Dio() ← new HttpClient ApiClient.instance.dio
DNS ──────────► DNS ──────────►
TCP ◄──────────► TCP ◄──────────►
TLS ◄──────────► TLS ◄──────────►
GET ──────────► GET ──────────►
request 2 request 2
await userAgent() ← again (cached)
Dio() ← another client same Dio
DNS ──────────► ← again ── reuses the connection ──►
TCP ◄──────────► ← again GET ──────────►
TLS ◄──────────► ← again
GET ──────────►
… × 6 on a cold start … × 6, one handshake total

A Dio instance owns an HttpClient, and an HttpClient owns the keep-alive connection pool. Throwing the client away after one request throws the pool away with it. Nothing could ever be reused.

await userAgent() had the same shape: ua_client_hints does a MethodChannel round trip and caches nothing of its own, so it ran before every single request.

lib/core/network/api_client.dart is a lazily-built singleton:

ApiClient.instance.dio
│
├── BaseOptions
│ connectTimeout : 8s ← the line that did not exist
│ receiveTimeout : 15s
│ sendTimeout : 15s
│
├── Http2Adapter(ConnectionManager(idleTimeout: 30s))
│ └─ multiplexes every call onto ONE connection
│ (behind `_useHttp2`, one flag to flip if the CDN objects)
│
├── InterceptorsWrapper(onRequest:)
│ ├─ User-Agent ← _resolveUserAgent(), memoised, never throws
│ └─ Authorization: Bearer <token>
│ read PER REQUEST from current_user.value
│
└── dio.addSentry() attached once, not per call

Why the auth header moved into an interceptor

Section titled “Why the auth header moved into an interceptor”

The old code baked the token into dio.options.headers at construction. That was harmless when the client lived for exactly one request. A shared client outlives login and logout, so a token captured at construction would be wrong the moment the user signed in or out. Reading current_user.value.token inside onRequest means every request carries whatever is true at the instant it is sent — and the header is removed, not left stale, when the token is empty.

Why connectTimeout matters more than the rest

Section titled “Why connectTimeout matters more than the rest”
dio.options
connectTimeout ─── how long to wait for a socket to open
sendTimeout ─── how long to wait while writing the body
receiveTimeout ─── how long to wait between response bytes
Only receiveTimeout was set.
Dead network:
├─ connect …………………………………………………… OS default (75s+ on iOS)
└─ receiveTimeout never even starts, because there is
no connection for bytes to arrive on.

Setting connectTimeout: 8s converts an indefinite hang into an error the app can respond to. Every other improvement in these docs depends on that error actually arriving.

The abstract base and all its URL helpers (getApiV1, getApiV2, postApiV1, …) are unchanged — nineteen datasources extend it and none of them needed touching. Only the four verb bodies changed:

Dio get _dio => ApiClient.instance.dio;
Future<ApiResult> _send(Future<Response<dynamic>> Function() request) async {
try {
return ApiResult.fromData(await request());
} on SocketException catch (error) {
throw Exception("No Internet connection");
} on DioException catch (error) {
return ApiResult.fromData(error.response);
}
}
Future<ApiResult> get(url) => _send(() => _dio.get(url));

Error semantics are deliberately identical to before. A timeout or DNS failure produces a DioException with no response, ApiResult.fromData turns that into a thrown "Bad Request", and the repositories catch it — which is precisely the hook doc 3 uses to fall back to disk.

Per-verb timeouts that differed from the base (POST 30 s, PUT/DELETE 60 s) are preserved via a per-request Options, since connectTimeout is a base-options-only setting in Dio 5.

Cache keys have to match the URLs actually requested, or the cache never hits. Both sides now call the same functions:

String apiV1Url(String route) =>
"$api$route?country=${country.value}&platform=mobile&language=...";
String apiV2Url(String route) =>
"$api_v2$route?country=${country.value}&platform=mobile&language=...";

Note country in there. It is a query parameter on every URL, so it is part of every cache key — which is why doc 4 has to care about restoring it before anything else runs.

  • the dead CacheOptions field (MemCacheStore, never attached)
  • _attachSharedInterceptors (Sentry now wired once in ApiClient)
  • four copies of the header-building block
  • four await userAgent() calls per… well, per request

The orphaned debug logging interceptor was not deleted — it moved into ApiClient behind _logRequests, so it is one flag away from being useful instead of unreachable.


Next: Cache-first boot — now that requests are fast and, crucially, fail promptly, we can stop making them at all.