skills / android
networking
Talking to a server: client configuration, what comes back, what happens when it does not, and how a request is authorised. Use when the app makes HTTP calls.
npx skyl.dev add android/networkingInstalls android/core with it, because a layer that refers to its core reads wrong without it.
Rules
installedScope, priority, and when the whole skill does not apply
Talking to a server: the client itself, what comes back, what happens when it does not, and how a
request is authorised. core owns which failures reach the user (DATA-5) and that a layer owns its
types (BOUND-2). db owns caching and staleness. mvvm owns where the source decision lives. This
owns the wire.
When a rule here conflicts with the code you are editing the surrounding convention wins for style and structure, but never for a rule whose failure loses user data, leaks a credential, or ships a crash. Fix those in their own change, not inside another one.
Scope. New endpoints and new clients. Match the client already configured in the module.
When not to apply(whole-skill): a single call to a service you control, in a prototype.
Priority. must, the failure hangs, leaks, or reaches production silently. should, real
exceptions exist; name yours.
The client
CLIENT-1mustOne HTTP client for the app, built once and injected. Never constructed per request or inside a suspend function.
Whya client owns a connection pool and a thread pool. One per request means no connection reuse, a fresh TCP and TLS handshake every time, and the discarded clients leak threads and sockets until something notices. On a slow network the handshake is most of the latency.
Not whena genuinely different configuration is needed, a different host with different auth, and then it is a second long-lived client, not a per-call one.
CLIENT-2mustSet a whole-call timeout, not only the per-operation ones.
Whythe per-operation defaults are reasonable, OkHttp gives connect, read and write ten seconds each, and they do not bound the request. callTimeout defaults to 0, meaning no timeout and it is the only one that spans the entire call: DNS, connect, sending the body, the server thinking reading the response, and every redirect and retry along the way. Each individual operation can keep resetting its own ten seconds while the call as a whole never finishes. The coroutine waiting on it is never resumed and the user watches a spinner with no end and no error.
Not whena deliberately long-lived connection, a stream, a large upload, which needs its own larger value rather than none.
CLIENT-3shouldBase URL, shared headers and content type are configured once on the client, not repeated per call site.
Whya hardcoded base URL cannot be pointed at staging, and a header set at forty call sites is set at thirty-nine after the next refactor.
Not whena header that genuinely varies per request.
What comes back
WIRE-1mustThe parser tolerates unknown fields. A server adding a field must not break the app.
Whyservers add fields without telling clients, and a strict parser turns that into a deserialization failure on a screen that was working, for users on an old build, with no way to fix it but an update. This is the most common cause of a working app breaking without a release.
Not whennever for a response you do not control.
WIRE-2mustResponse fields are nullable unless the server contract guarantees them. Request fields are not.
Whythe asymmetry is the point. A missing field in a response is a crash if the type says it cannot be absent, and servers omit fields, on error paths, for older accounts, in partial responses. A request field you are supposed to supply should fail at compile time if you do not.
Not whena field the server contractually guarantees, and then the guarantee is worth a comment.
WIRE-3shouldWire names are declared explicitly on the model rather than inherited from property names.
Whyotherwise renaming a Kotlin property silently changes the JSON you send and expect, and the break is at runtime against a server that did not change. The annotation makes the wire format a decision rather than a side effect of refactoring.
Not whena format you generate and consume on both ends.
WIRE-4mustA field the protocol requires is sent even when it holds its default value.
Whyserializers commonly omit defaults, so a constant like a version or type discriminator vanishes from the payload and the server rejects every request with a generic error that names nothing. It is invisible in the client's own logs because the object looks correct.
Not whenthe field is genuinely optional and the server treats absent and default alike.
When it fails
FAIL-1mustDistinguish no-connectivity, timeout, and a server response, and map each to a different domain failure.
Whythey need different responses. No connectivity is retryable and the user should be told to check; a timeout may already have succeeded server-side; a 4xx will fail identically forever. Collapsing them into "network error" means the retry button is offered for the one case where it cannot help.
Not whennever, this is the whole reason the layer exists.
FAIL-2mustRetry only what is safe to repeat: transient transport failures and a server saying it is temporarily unavailable. Never a 4xx. Never a non-idempotent write unless the request carries an identity the server deduplicates on.
Whyretrying a 4xx repeats a request that is wrong, forever. Retrying a POST that already succeeded but whose response was lost creates the order twice, and the client cannot tell that case from a genuine failure.
Not whenthe server documents the endpoint as idempotent.
FAIL-3shouldRetry with backoff and jitter, and a bounded number of attempts.
Whyevery client retrying on a fixed schedule after an outage arrives together and keeps the server down. Jitter spreads the herd; a bound stops one screen retrying forever.
Not whena single retry of a cheap read.
FAIL-4mustDo not check connectivity before a request as a precondition. Make the request and handle the failure.
Whythe check is a race, connectivity can drop between the check and the call, and a reported connection does not mean the host is reachable. A validated-connectivity signal is useful for telling the user why something failed, and useless as a gate.
Not whendeciding whether to schedule deferred work, which is a different question.
Authorisation
AUTH-1mustThe token is attached by the client, not by a parameter on each endpoint.
Whyone endpoint that forgets the parameter is an unauthenticated request, and it fails as a 401 that looks like an expired session rather than a missing header. There is no compiler check for the endpoint you did not annotate.
Not whenan endpoint that must be called without auth, and that is an exclusion on the client by route, not the absence of a parameter.
AUTH-2mustA token refresh cannot trigger itself. The refresh request is excluded from the attach-and-retry path, and refresh attempts are bounded.
Whyotherwise a 401 on refresh triggers a refresh, which 401s, which triggers a refresh. It presents as the app hanging on launch and hammering the auth server, and it only happens once the token has actually expired, so it reaches production.
Not whennever.
AUTH-3shouldConcurrent requests that hit a 401 refresh once between them, not once each.
Whya screen firing four parallel calls with an expired token performs four refreshes, and on a server that rotates refresh tokens three of them invalidate the fourth, signing the user out at the moment the app was recovering.
Not whena single-request client where concurrency is impossible.
Streaming
STREAM-1mustA long-lived connection is bound to the lifetime of whatever consumes it, so cancelling the consumer closes the connection.
Whya socket held after the screen is gone keeps the radio awake and the server holding state. Nothing closes it, because nothing knows the reader has left.
Not whenthe connection is genuinely app-scoped and intended to outlive any screen.
Why
Why the whole-call timeout is the one that matters. The per-operation timeouts look like they
bound a request and do not. OkHttp gives connect, read and write ten seconds each, and each one
resets on activity. A server that dribbles a response a byte at a time never trips the read timeout;
a redirect chain restarts the clock at every hop. callTimeout is the only setting that bounds the
whole thing, DNS, connect, request body, server thinking, response body, redirects, retries, and
it defaults to zero, meaning no timeout at all.
So a request against a sick server can hang indefinitely with all three per-operation timeouts correctly configured. The coroutine waiting on it is never resumed, and the user watches a spinner that will never stop.
Why unknown fields are the most common way a working app breaks. Nothing shipped. Nobody deployed. A backend team added a field to a response because a different client needed it, and every install with a strict parser starts failing on a screen that worked yesterday. Those users cannot fix it; they need a new build. The tolerance costs one setting and removes an entire class of outage from a decision made by people who do not know your app exists.
The nullability half is the same argument from the other side. Servers omit fields, on error paths for older accounts, in partial responses, and a non-null type turns an omission into a deserialization crash. Request fields are the opposite: those you are supposed to supply, and the compiler should say so.
Why "network error" is not one thing. No connectivity, a timeout, and a 4xx need three different responses. Offline is retryable and the user can act on it. A timeout may have succeeded on the server, the response was lost, not the work. A 4xx will fail identically forever, and offering a retry button for it is a lie. Collapsing all three into one message means the only case where retry cannot help is the one where it is offered.
Why retrying a write is different from retrying a read. A read is safe to repeat. A write that timed out may already have been applied, the client cannot distinguish "never arrived" from "arrived, response lost", and those need opposite handling. Retrying the second creates the order twice. The fix is not to avoid retrying but to make the request identifiable, so the server can recognise the repeat and return the original result.
Why token refresh is where auth code goes wrong. Refresh is the one request that must not be authorised the normal way, and the one whose failure must not trigger itself. A 401 on refresh that triggers a refresh is an infinite loop that only appears once a token has genuinely expired, so it passes every test and reaches production. And a screen firing four parallel calls with a stale token refreshes four times; on a server that rotates refresh tokens, three of those invalidate the fourth and sign the user out at the exact moment the app was recovering.
Pitfalls
- A spinner that never stops, against a server that is up but sick. No whole-call timeout.
- The app breaks and nobody deployed anything. A strict parser and a server that added a field.
- A crash on some accounts and not others. A non-null response field the server omits for older records.
- The server rejects every request with a generic error. A protocol-constant field omitted because it held its default value.
- A retry button offered for an error that will never succeed. All failures collapsed into one.
- Two orders from one tap. A non-idempotent write retried after a lost response.
- The app hangs on launch and hammers the auth server. Refresh triggering refresh.
- Signed out at the moment the app recovered. Four concurrent refreshes, three invalidating the fourth.
- A socket held open after the user left the screen. A stream not bound to its consumer.
Provenance
Eval 13, 24 runs across two tasks on Haiku 4.5 and Sonnet 5. ** One rule separated: CLIENT-2, on
Haiku, 0/2 → 2/2.** It is also the rule whose reason came from a primary source rather than the
corpus, checking OkHttp's actual defaults showed the per-operation timeouts are ten seconds each
and only callTimeout defaults to none.
AUTH-1, AUTH-2 and AUTH-3 are satisfied unaided by both models on a task built to tempt them.
Kept anyway: they cost little, and the sample is two per cell.
WIRE-3, WIRE-4, CLIENT-3, FAIL-3, FAIL-4 and STREAM-1 were not exercised.
This register was the richest of any axis, 81 evidenced claims from 12 repos, and produced the
thinnest result. That is the project's pattern holding: heavy corpus backing predicts rules the
model already follows. See evals/android/eval-13-net/RESULTS.md.
References
Depth a rule points at, loaded only when the agent asks for it.
Failure, retry, and the write that already happened
Referenced by networking FAIL-1, FAIL-2 and FAIL-3.
Three failures, three responses
| What happened | Retryable? | What the user should see |
|---|---|---|
| No connectivity | yes, immediately | "you're offline", and retry when connectivity returns |
| Timeout / transport failure | yes, with backoff | "that took too long", and the work may have happened |
| Server responded 5xx | yes, with backoff | "something went wrong, try again" |
| Server responded 4xx | no | what is actually wrong, this will fail identically forever |
Collapsing these into "network error" means the retry button is shown for the 4xx, which is the one case where it cannot possibly help, and the user taps it until they give up.
The write that may already have happened
A read is safe to repeat. A write is not, and the reason is that the client cannot tell these two apart:
- the request never reached the server;
- the request was applied and the response was lost.
Both look like a timeout. Retrying the first is correct; retrying the second creates the thing twice.
The fix is not to avoid retrying. It is to make the request identifiable, so the server can recognise a repeat:
POST /api/favourites
Idempotency-Key: 0f8c2e1a-... // generated by the client, stable across retries
The server records the key with the result. A repeat returns the original response rather than
performing the action again. Without server support, the safe options are to not retry the write, or
to make the operation naturally idempotent, PUT of a full state rather than POST of a delta.
Backoff and jitter
Retrying on a fixed interval means every client that failed during an outage returns at the same moment and keeps the server down. Exponential backoff spreads them out; jitter a random fraction added to each delay, is what stops them re-synchronising on the next attempt.
Bound the attempts. A screen that retries forever is a screen that never shows an error, and a battery that drains while nobody is looking.
Do not gate on connectivity
if (!isOnline()) return Failure.Offline // don't
api.load()
Two problems. It is a race, connectivity can drop between the check and the call. And a reported connection does not mean your host is reachable: captive portals, DNS failures and firewalled networks all report connected.
Make the request. Handle the failure. A validated-connectivity signal is useful for explaining a failure that already happened, and for deciding whether to schedule deferred work, not as a gate in front of a call.
Evidence
Talking to a server: the client itself, what comes back, what happens when it does not, and how a request is authorised.
What was run
1 eval, 36 recorded runs, on Haiku 4.5 and Sonnet 5. Every run is archived: the generated sources, the prompt each arm received, and the model each one reported.
Two tasks, control against +core against +core+networking.
What loading the skill changed
Setting a whole-call timeout rather than only the per-operation ones. The per-operation defaults are reasonable and do not bound the request: a call can keep resetting its own ten seconds while never finishing, and the coroutine waiting on it is never resumed. Improved on Haiku.
This is also the one rule here whose reason came from reading the library's documented defaults rather than from the corpus.
What the tested models already handle
Attaching a token on the client rather than per endpoint, excluding the refresh request from its own interceptor, and collapsing concurrent refreshes were all handled unaided on a task built to tempt them.
What this skill demonstrates
The claim register behind it was the richest of any axis in this family: 81 evidenced claims from 12 repositories. Heavy documentation predicts rules the models already follow, because the corpus and the training data are the same material.
Around this skill
Loads when a project has
gradle dependency
- com.squareup.retrofit2:retrofit
- com.squareup.okhttp3:okhttp
- io.ktor:ktor-client-core
- io.ktor:ktor-client-android