Networking Layer
The single client every request flows through -auto token refresh, UTF-8 safety, uploads, and the realtime socket.
Last updated Thu Jul 16 2026 00:00:00 GMT+0000 (Coordinated Universal Time)
TL;DR -Every request the app makes goes through one client (
ApiClient). It quietly refreshes expired logins, sends text as proper UTF-8 so emoji and smart quotes never break, and handles uploads. A separate always-on socket carries realtime events (live rooms, chat).
Who this is for -Product & QA: the "In plain terms" section explains why sessions renew silently and why emoji-in-DMs once broke. Mobile & engineering: the refresh protocol, UTF-8 rules, error contract, upload path, socket layer, and build config.
Every time the app needs something from the server -your wallet, a chat message, a profile edit -it goes through a single shared piece of code called ApiClient. Funnelling everything through one door means the tricky, easy-to-get-wrong parts (renewing your login, encoding text correctly, handling errors) are solved once and reused everywhere.
Alongside the request/response client there's a second, always-open connection called a socket -used for things that need to happen live, like a gift appearing in a room the instant it's sent, without the app having to keep asking "anything new yet?".
In plain terms
Three things ApiClient handles that a reader should understand up front:
- Silent re-login. Your access pass expires every hour, but you never get kicked out mid-tap. When a request comes back saying "expired," the client fetches a fresh pass behind the scenes and retries -so long as your refresh window (30 days) is still open. A flaky Wi-Fi moment never logs you out; only a genuinely rejected login does.
- Emoji and smart-quotes work. Text is sent as UTF-8 (the modern universal text encoding). This sounds obvious, but a subtle default once caused emoji and iPhone curly quotes to crash requests before they even left the phone -so it's now an enforced rule.
- Consistent errors. When the server says no, the app gets a clean error with the reason. Screens that involve money always surface the failure; browse/list screens may quietly fall back to sample data so the app still looks alive offline.
The socket is the app's "live wire": one connection per session, shared by every feature that needs realtime updates, and it automatically reconnects and re-joins your rooms if the connection drops.
ApiClient (lib/core/network/api_client.dart)
A thin dart:io HttpClient wrapper providing getJson, postJson,
patchJson, deleteJson, and postMultipart. Everything the app sends
goes through it.
Automatic 401 retry with single-flight refresh
This is the "silent re-login" from above, in detail. Access tokens live 1 hour. When any request returns 401:
- The client calls
POST /auth/refreshwith the stored refresh token. - Single-flight: concurrent 401s share one refresh future -critical because refresh tokens are single-use and rotating; racing refreshes would revoke each other.
- The new session is persisted and the original request retries once.
UTF-8 discipline (learned the hard way)
This is the emoji bug, and the rule that fixed it. HttpClientRequest.write() encodes with Latin-1 when the Content-Type
header carries no charset. Emoji and iOS smart punctuation (’ U+2019) are
outside Latin-1, so requests used to throw "Contains invalid characters"
on-device before sending -breaking DMs and profile edits.
The fix, now the standing rule for any new request path:
- Send bodies as explicit bytes:
request.add(utf8.encode(jsonEncode(body))). - Declare
Content-Type: application/json; charset=utf-8. - Multipart framing uses
utf8.encode(...), neverString.codeUnits.
Error contract
Non-2xx responses raise AppError carrying the HTTP status and the
backend's message field when present. Repositories decide per-call whether
to rethrow (money paths must) or fall back to fixture data (list/browse
paths may, to keep the app demoable offline).
Uploads
postMultipart hand-builds the multipart body (avatar uploads). Files land
in the backend's uploads/ volume, served at /uploads/*.
Socket layer
This is the "live wire." lib/modules/signaling/ owns the Socket.IO connection: connect with the
JWT, expose streams of gateway events, auto-reconnect, and re-join rooms on
reconnect. Feature modules (live room, chat) subscribe through it rather
than opening their own sockets -one connection per app session.
Configuration
The app is pointed at a server at build time, not runtime. AppConfig reads compile-time defines:
flutter run \
--dart-define=API_BASE_URL=http://127.0.0.1:3000/api \
--dart-define=SIGNALING_URL=http://127.0.0.1:3000
Production builds default to the deployed API host.
Related pages
- State & Routing Conventions -the repositories that call
ApiClient. - Tokens, Sessions & Devices -the refresh-token rotation this client pairs with.
- Mobile App Architecture -where
core/network/sits in the app. - Realtime architecture -the gateway on the other end of the socket.