2. The network layer
2. The network layer
Section titled “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.
What a Dio() per request costs
Section titled “What a Dio() per request costs” 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 totalA 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.
The shared client
Section titled “The shared client”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 callWhy 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.
What DioApiProvider looks like now
Section titled “What DioApiProvider looks like now”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.
Two shared URL builders
Section titled “Two shared URL builders”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.
What was deleted
Section titled “What was deleted”- the dead
CacheOptionsfield (MemCacheStore, never attached) _attachSharedInterceptors(Sentry now wired once inApiClient)- 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.