$npx -y skills add rcosteira79/android-skills --skill kmp-ktorUse when setting up or working with Ktor client in KMP or Android projects — HttpClient configuration, per-platform engine selection, kotlinx.serialization, bearer auth with refresh, MockEngine testing, and error mapping at the repository boundary.
| 1 | # Ktor Client for KMP and Android |
| 2 | |
| 3 | This reference covers the Ktor client configuration traps — plugin install order, serialization flags, auth refresh, and error mapping — not the basics of a shared `HttpClient`, engine selection, or `ContentNegotiation` setup. **Related:** `android-skills:android-data-layer` (repository + the `DataError` error model — its canonical home), `android-skills:android-retrofit` (Android-only equivalent). |
| 4 | |
| 5 | ## Plugin install order — `HttpRequestRetry` BEFORE `HttpTimeout` |
| 6 | |
| 7 | The install order most often gotten wrong: installing `HttpTimeout` before `HttpRequestRetry`. Plugins run in install order for outgoing requests; retries must be able to catch timeout errors, so retry has to wrap timeout. |
| 8 | |
| 9 | ```kotlin |
| 10 | val json = Json { ignoreUnknownKeys = true; coerceInputValues = true; encodeDefaults = true } // encodeDefaults: see the section below |
| 11 | |
| 12 | HttpClient(engine) { |
| 13 | install(ContentNegotiation) { json(json) } |
| 14 | install(Auth) { bearer { /* loadTokens / refreshTokens */ } } |
| 15 | install(HttpRequestRetry) { // BEFORE HttpTimeout |
| 16 | retryOnServerErrors(maxRetries = 3) |
| 17 | exponentialDelay() |
| 18 | } |
| 19 | install(HttpTimeout) { // AFTER HttpRequestRetry |
| 20 | requestTimeoutMillis = 30_000; connectTimeoutMillis = 15_000; socketTimeoutMillis = 15_000 |
| 21 | } |
| 22 | } |
| 23 | ``` |
| 24 | |
| 25 | Reversed, `HttpTimeout` resolves the request as failed before the retry plugin sees it, so timeouts are never retried. (Separately, the `Auth` plugin handles 401 refresh independently — let `HttpRequestRetry` cover transient/5xx failures; don't chain the two around the same status code.) |
| 26 | |
| 27 | ## `encodeDefaults = true` — or protocol-constant fields silently vanish |
| 28 | |
| 29 | `kotlinx.serialization` defaults to `encodeDefaults = false`, which **strips any property whose value equals its declared default** from the serialized output. A `jsonrpc: String = "2.0"` (or `version = "1.0"`, `type = "..."`) disappears from the payload; the server rejects every request with a generic "invalid request," and the fix is a one-line flag — found only after hours chasing HTTP-layer red herrings. Always set it for client APIs — the `val json` defined at the top of this file does, alongside `ignoreUnknownKeys` and `coerceInputValues`. That one configured instance is what the whole client shares: `install(ContentNegotiation) { json(json) }` and the WebSocket converter both take it. |
| 30 | |
| 31 | ## `expectSuccess` — pick one model, consistently |
| 32 | |
| 33 | `expectSuccess = true` makes Ktor throw `ClientRequestException` (4xx) / `ServerResponseException` (5xx) on non-2xx — and that throw **runs before any manual status check**, so an `if (response.status == OK)` branch after it is dead code. Pick one model project-wide: `expectSuccess = true` + `try/catch` (matches the repository pattern), or `expectSuccess = false` + explicit `response.status.isSuccess()` inspection. Never mix them. |
| 34 | |
| 35 | ## Bearer refresh — `markAsRefreshTokenRequest()` or it loops |
| 36 | |
| 37 | In the `Auth` `bearer { refreshTokens { … } }` block, mark the refresh POST with `markAsRefreshTokenRequest()` so it isn't intercepted by the same `Auth` plugin — without it, a failing refresh triggers another refresh, looping infinitely. It's an `HttpRequestBuilder` extension: call it **inside the request builder block**, not bare in `refreshTokens { }` (where it doesn't compile). |
| 38 | |
| 39 | ```kotlin |
| 40 | install(Auth) { |
| 41 | bearer { |
| 42 | loadTokens { tokenStorage.getTokens()?.let { BearerTokens(it.access, it.refresh) } } |
| 43 | refreshTokens { |
| 44 | val refresh = oldTokens?.refreshToken ?: return@refreshTokens null |
| 45 | val r = client.post("auth/refresh") { |
| 46 | markAsRefreshTokenRequest() // skip the Auth plugin for this call |
| 47 | setBody(RefreshRequestDto(refresh)) |
| 48 | }.body<TokenResponseDto>() |
| 49 | tokenStorage.save(r.accessToken, r.refreshToken); BearerTokens(r.accessToken, r.refreshToken) |
| 50 | } |
| 51 | sendWithoutRequest { it.url.pathSegments.none { seg -> seg in listOf("login", "register") } } |
| 52 | } |
| 53 | } |
| 54 | ``` |
| 55 | |
| 56 | Keep `BearerTokens` at the plugin boundary; the rest of the app uses your own token type. `TokenStorage` is project-defined (DataStore on Android/JVM, Keychain on iOS). |
| 57 | |
| 58 | ## WebSockets & SSE — use the serialization converter |
| 59 | |
| 60 | For real-time transports, install the kotlinx-serialization converter so typed messages flow over the same `Json` config as `ContentNegotiation`; without it you hand-encode/decode `Frame.Text`. (SSE = server→client only, plain HTTP, built-in reconnect; WebSocket = bidirectional, manual reconnect, binary frames — default to SSE when the client only consumes.) |
| 61 | |
| 62 | ```kotlin |
| 63 | val client = HttpClient(engine) { |
| 64 | install(WebSockets) { |