From android-skills
Configures Ktor client for KMP/Android projects: plugin install order, kotlinx.serialization flags, bearer auth refresh, MockEngine testing, and error mapping at the repository boundary.
How this skill is triggered — by the user, by Claude, or both
Slash command
/android-skills:kmp-ktorThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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 equivale...
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).
HttpRequestRetry BEFORE HttpTimeoutThe 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.
val json = Json { ignoreUnknownKeys = true; coerceInputValues = true; encodeDefaults = true } // encodeDefaults: see the section below
HttpClient(engine) {
install(ContentNegotiation) { json(json) }
install(Auth) { bearer { /* loadTokens / refreshTokens */ } }
install(HttpRequestRetry) { // BEFORE HttpTimeout
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
}
install(HttpTimeout) { // AFTER HttpRequestRetry
requestTimeoutMillis = 30_000; connectTimeoutMillis = 15_000; socketTimeoutMillis = 15_000
}
}
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.)
encodeDefaults = true — or protocol-constant fields silently vanishkotlinx.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.
expectSuccess — pick one model, consistentlyexpectSuccess = 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.
markAsRefreshTokenRequest() or it loopsIn 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).
install(Auth) {
bearer {
loadTokens { tokenStorage.getTokens()?.let { BearerTokens(it.access, it.refresh) } }
refreshTokens {
val refresh = oldTokens?.refreshToken ?: return@refreshTokens null
val r = client.post("auth/refresh") {
markAsRefreshTokenRequest() // skip the Auth plugin for this call
setBody(RefreshRequestDto(refresh))
}.body<TokenResponseDto>()
tokenStorage.save(r.accessToken, r.refreshToken); BearerTokens(r.accessToken, r.refreshToken)
}
sendWithoutRequest { it.url.pathSegments.none { seg -> seg in listOf("login", "register") } }
}
}
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).
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.)
val client = HttpClient(engine) {
install(WebSockets) {
pingIntervalMillis = 30_000
contentConverter = KotlinxWebsocketSerializationConverter(json) // the shared configured instance — bare `Json` reverts to encodeDefaults = false
}
install(SSE)
}
client.webSocket("wss://api.example.com/ws") {
sendSerialized(SubscribeMessage(topic = "items"))
while (true) { val msg = receiveDeserialized<ServerMessage>(); /* handle */ }
}
// SSE — incoming is a Flow<ServerSentEvent>
client.sse("https://api.example.com/events") { incoming.collect { event -> /* event.event / event.data / event.id */ } }
Wrap SSE/WebSocket collection in a LaunchedEffect or repository coroutine so cancellation closes the HTTP connection when the consumer goes away.
Catch specific Ktor types at the repository (ClientRequestException / ServerResponseException / HttpRequestTimeoutException / IOException) and map to DataError — catch (e: Exception) would swallow CancellationException. The full repository pattern + DataError taxonomy lives in android-skills:android-data-layer. For richer per-error UI states (Unauthorized, RateLimited, Forbidden, …), a sealed ApiResult<T> + a safeRequest wrapper with expectSuccess = false is the alternative shape — pick one per project.
Inject HttpClientEngine so tests swap in MockEngine, reusing the production createHttpClient factory so plugin config matches:
val mockEngine = MockEngine { request ->
assertEquals("/users/42", request.url.encodedPath)
respond("""{"id":"42","name":"Ada","created_at":1700000000000}""",
HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"))
}
val repo = UserRepository(UserService(createHttpClient(mockEngine, baseUrl = "https://api.example.com/")))
npx claudepluginhub rcosteira79/android-skills --plugin android-skillsProvides Ktor client patterns for Android HTTP networking with content negotiation, timeouts, logging, authentication, API CRUD, and safe error handling.
Provides Ktor server patterns including routing DSL, plugin configuration (Auth, CORS, Serialization), Koin DI, kotlinx.serialization, WebSocket, and testApplication testing.
Provides Ktor server patterns including routing DSL, plugins, authentication, Koin DI, kotlinx.serialization, WebSockets, and testApplication testing for building HTTP servers with Kotlin coroutines.