AgentStack
SKILL verified MIT Self-run

Kmp Ktor

skill-rcosteira79-android-skills-kmp-ktor · by rcosteira79

Use 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.

No reviews yet
0 installs
7 views
0.0% view→install

Install

$ agentstack add skill-rcosteira79-android-skills-kmp-ktor

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Kmp Ktor? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Ktor Client for KMP and Android

Modern Ktor client setup for Kotlin Multiplatform and Android projects using kotlinx.serialization, the Auth plugin for bearer tokens, and MockEngine for testing. The same HttpClient configuration runs on Android, iOS, Desktop, and Web — only the engine changes per platform.

Related skills: See android-skills:android-data-layer for the Repository pattern, error propagation model, and offline-first strategies. See android-skills:android-retrofit for the equivalent Android-only setup with Retrofit.


Dependencies and Platform Engines

Ktor's HttpClient is platform-agnostic — only the underlying engine is platform-specific. Pick one engine per source set.

Per-platform engine selection

| Platform | Engine | Dependency | |----------|--------|------------| | Android | OkHttp | ktor-client-okhttp | | iOS | Darwin (NSURLSession) | ktor-client-darwin | | JVM/Desktop | CIO (or OkHttp) | ktor-client-cio | | JS/Wasm | JS | ktor-client-js | | Tests (any platform) | MockEngine | ktor-client-mock |

Version catalog

[versions]
ktor = ""  # verify at https://ktor.io/docs/releases.html

[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }

Source set wiring

commonMain.dependencies {
    implementation(libs.ktor.client.core)
    implementation(libs.ktor.client.content.negotiation)
    implementation(libs.ktor.serialization.kotlinx.json)
    implementation(libs.ktor.client.logging)
    implementation(libs.ktor.client.auth)
}

androidMain.dependencies {
    implementation(libs.ktor.client.okhttp)
}

iosMain.dependencies {
    implementation(libs.ktor.client.darwin)
}

commonTest.dependencies {
    implementation(libs.ktor.client.mock)
}

The engine module belongs in the platform source set. The factory is provided via expect/actual or DI — see DI Setup below.


HttpClient Configuration

Create a single HttpClient instance and reuse it. Each HttpClient owns a connection pool, dispatcher threads, and plugin state — creating one per request leaks resources and defeats keep-alive.

fun createHttpClient(
    engine: HttpClientEngine,
    baseUrl: String,
    isDebug: Boolean = false,
): HttpClient = HttpClient(engine) {
    install(ContentNegotiation) {
        json(Json {
            ignoreUnknownKeys = true
            coerceInputValues = true
            encodeDefaults = true  // include default-valued fields when serializing — see RIGHT vs WRONG below
        })
    }

    defaultRequest {
        url(baseUrl)
        headers.append(HttpHeaders.Accept, ContentType.Application.Json.toString())
    }

    install(HttpTimeout) {
        connectTimeoutMillis = 15_000
        requestTimeoutMillis = 30_000
        socketTimeoutMillis = 15_000
    }

    install(Logging) {
        logger = Logger.DEFAULT
        level = if (isDebug) LogLevel.BODY else LogLevel.HEADERS
        sanitizeHeader { it.equals(HttpHeaders.Authorization, ignoreCase = true) }
    }

    expectSuccess = true  // Ktor throws ClientRequestException / ServerResponseException on non-2xx
}

expectSuccess = true matches the try/catch error model used by the Repository pattern below. If you prefer to inspect status codes manually, set expectSuccess = false and apply that choice consistently across the project — never mix the two.

| Setting | Behavior | Use when | |---|---|---| | true (used above) | Throws ClientRequestException / ServerResponseException on non-2xx | Pairing with try/catch at the repository boundary — matches the pattern in this skill | | false | Returns the response regardless of status | Inspecting response.status manually in a custom wrapper (see "Advanced: Sealed ApiResult" below) |

Plugin install order

Plugins execute in installation order for outgoing requests and reverse order for responses. The order that holds up across most projects:

ContentNegotiation → Auth → HttpRequestRetry → HttpTimeout → ContentEncoding

The two installs that interact in non-obvious ways:

  • HttpRequestRetry before HttpTimeout — retries should be able to catch timeout errors. Reversing this skips timeouts because HttpTimeout resolves the request as failed before the retry plugin sees the response.
  • Auth plugin handles 401s independently from HttpRequestRetry — let Auth do the bearer refresh dance; let HttpRequestRetry cover transient network failures and 5xx. Don't try to chain them around the same status code.

Service Layer

Wrap HttpClient in a typed service class. Service methods return DTOs — mapping to domain models happens in the repository.

class UserService(private val client: HttpClient) {

    suspend fun listUsers(page: Int = 1): UserListDto =
        client.get("users") {
            parameter("page", page)
        }.body()

    suspend fun getUser(id: String): UserDto =
        client.get("users/$id").body()

    suspend fun createUser(request: CreateUserDto): UserDto =
        client.post("users") {
            contentType(ContentType.Application.Json)
            setBody(request)
        }.body()

    suspend fun deleteUser(id: String) {
        client.delete("users/$id")
    }
}

Path parameters use Kotlin string templates. Query parameters use parameter("key", value). Request bodies use setBody(request) paired with contentType(ContentType.Application.Json).


DTOs and Mapping

DTOs are @Serializable and mirror the API contract exactly. Domain models have no serialization annotations.

@Serializable
data class UserDto(
    val id: String,
    val name: String,
    @SerialName("created_at") val createdAt: Long,
)

data class User(val id: String, val name: String, val createdAt: Instant)

fun UserDto.toDomain(): User = User(
    id = id,
    name = name,
    createdAt = Instant.fromEpochMilliseconds(createdAt),
)

Use @SerialName when JSON keys differ from Kotlin field names. Provide defaults for optional fields so missing keys don't throw.


Repository — Error Handling

Catch Ktor exceptions at the repository layer and map to domain error types. Never let ClientRequestException, ServerResponseException, HttpRequestTimeoutException, or IOException reach the ViewModel. See android-skills:android-data-layer for the full repository pattern.

class UserRepository(private val service: UserService) {

    suspend fun getUser(id: String): Result = try {
        Result.success(service.getUser(id).toDomain())
    } catch (e: ClientRequestException) {  // 4xx
        Result.failure(DataError.Server(e.response.status.value, e.message))
    } catch (e: ServerResponseException) {  // 5xx
        Result.failure(DataError.Server(e.response.status.value, e.message))
    } catch (e: HttpRequestTimeoutException) {
        Result.failure(DataError.Network(e))
    } catch (e: IOException) {
        Result.failure(DataError.Network(e))
    }
}

// Reuse the same error hierarchy across the data layer — see android-skills:android-data-layer
sealed class DataError(message: String, cause: Throwable? = null) : Exception(message, cause) {
    class Network(cause: Throwable) : DataError("Network error", cause)
    class Server(val code: Int, message: String?) : DataError("Server error $code: $message")
    class Local(cause: Throwable) : DataError("Local storage error", cause)
}

Catch specific Ktor exception types — catch (e: Exception) would swallow CancellationException and break structured concurrency. See android-skills:kotlin-flows for the full pattern.

Advanced: sealed ApiResult with expectSuccess = false

The Result + DataError pattern above is the default. When error handling needs structured per-error-type data — distinct UI states for Unauthorized, RateLimited, Forbidden, SerializationError, Timeout — a sealed ApiResult paired with expectSuccess = false and a safeRequest wrapper is the alternative shape:

sealed class ApiResult {
    data class Success(val data: T) : ApiResult()
    sealed class Failure : ApiResult() {
        data class HttpError(val code: Int, val message: String, val serverMessage: String? = null) : Failure()
        data class NetworkError(val message: String) : Failure()
        data class SerializationError(val message: String) : Failure()
        data class Timeout(val message: String) : Failure()
        data class Unauthorized(val serverMessage: String? = null) : Failure()
        data class Unknown(val cause: Throwable) : Failure()
    }
}

suspend inline fun  HttpClient.safeRequest(
    block: HttpRequestBuilder.() -> Unit,
): ApiResult = try {
    val response = request { block() }
    when (response.status.value) {
        in 200..299 -> ApiResult.Success(response.body())
        401 -> ApiResult.Failure.Unauthorized()
        in 400..499 -> ApiResult.Failure.HttpError(response.status.value, "Request failed")
        in 500..599 -> ApiResult.Failure.HttpError(response.status.value, "Server error")
        else -> ApiResult.Failure.HttpError(response.status.value, "Unexpected status")
    }
} catch (e: CancellationException) {
    throw e  // never swallow — breaks structured concurrency
} catch (e: HttpRequestTimeoutException) {
    ApiResult.Failure.Timeout("Request timed out")
} catch (e: IOException) {
    ApiResult.Failure.NetworkError("No internet connection")
} catch (e: SerializationException) {
    ApiResult.Failure.SerializationError("Invalid response format")
} catch (e: Exception) {
    ApiResult.Failure.Unknown(e)
}

Configure the client with expectSuccess = false when using this wrapper — the wrapper inspects response.status.value itself rather than relying on Ktor to throw.

| Pattern | Pick when | |---|---| | Result + DataError (default) | Three or four UI states are enough (Loading, Success, Network error, Server error); team prefers Kotlin stdlib types | | ApiResult + safeRequest (advanced) | UI needs distinct surface for Unauthorized, RateLimited, Forbidden, etc.; team prefers exhaustive when matching at the ViewModel boundary |

Pick one per project. Mixing both produces inconsistent error surfaces and confused reviewers.


Bearer Token Authentication

Use Ktor's Auth plugin with bearer. The plugin loads the cached token, attaches it to outgoing requests, and refreshes on 401 automatically.

fun createAuthenticatedClient(
    engine: HttpClientEngine,
    baseUrl: String,
    tokenStorage: TokenStorage,
    onSessionExpired: () -> Unit,
): HttpClient = HttpClient(engine) {
    install(ContentNegotiation) {
        json(Json {
            ignoreUnknownKeys = true
            encodeDefaults = true
        })
    }
    defaultRequest { url(baseUrl) }

    install(Auth) {
        bearer {
            loadTokens {
                val tokens = tokenStorage.getTokens() ?: return@loadTokens null
                BearerTokens(tokens.access, tokens.refresh)
            }

            refreshTokens {
                val refresh = oldTokens?.refreshToken ?: return@refreshTokens null
                try {
                    markAsRefreshTokenRequest()  // skip Auth plugin for this call
                    val response = client.post("auth/refresh") {
                        contentType(ContentType.Application.Json)
                        setBody(RefreshRequestDto(refresh))
                    }.body()

                    tokenStorage.save(response.accessToken, response.refreshToken)
                    BearerTokens(response.accessToken, response.refreshToken)
                } catch (e: Exception) {
                    onSessionExpired()
                    null
                }
            }

            sendWithoutRequest { request ->
                request.url.pathSegments.none { it in listOf("login", "register") }
            }
        }
    }
}

markAsRefreshTokenRequest() prevents the refresh call from being intercepted by the same Auth plugin — without it, a failing refresh would trigger another refresh, looping infinitely.

TokenStorage is a project-defined interface (DataStore on Android/JVM, Keychain on iOS). Keep BearerTokens at the plugin boundary only; the rest of the app uses your own token type.


DI Setup

Koin (KMP)

Engine factory lives in platform modules; the rest is shared.

// commonMain
val networkModule = module {
    single { createHttpClient(get(), baseUrl = "https://api.example.com/") }
    single { UserService(get()) }
}

expect val engineModule: Module

// androidMain
actual val engineModule: Module = module {
    single { OkHttp.create() }
}

// iosMain
actual val engineModule: Module = module {
    single { Darwin.create() }
}

Hilt (Android-only projects)

Hilt does not run in commonMain. For pure Android projects:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideHttpClient(): HttpClient =
        createHttpClient(OkHttp.create(), baseUrl = "https://api.example.com/")

    @Provides @Singleton
    fun provideUserService(client: HttpClient): UserService = UserService(client)
}

For KMP projects that also want Hilt on Android, expose the HttpClient from a Koin module in commonMain and have a Hilt @Provides method on the Android side fetch it from Koin — or use Koin throughout the project.


Testing with MockEngine

Inject HttpClientEngine into the factory so tests can swap in MockEngine. Reuse the production createHttpClient factory so plugin configuration matches.

@Test
fun `getUser maps DTO to domain`() = runTest {
    val mockEngine = MockEngine { request ->
        assertEquals("/users/42", request.url.encodedPath)
        respond(
            content = """{"id":"42","name":"Ada","created_at":1700000000000}""",
            status = HttpStatusCode.OK,
            headers = headersOf(HttpHeaders.ContentType, "application/json"),
        )
    }
    val client = createHttpClient(mockEngine, baseUrl = "https://api.example.com/")
    val repo = UserRepository(UserService(client))

    val result = repo.getUser("42").getOrThrow()

    assertEquals("Ada", result.name)
}

@Test
fun `getUser maps 404 to DataError-Server`() = runTest {
    val mockEngine = MockEngine {
        respond(content = """{"error":"not found"}""", status = HttpStatusCode.NotFound)
    }
    val client = createHttpClient(mockEngine, baseUrl = "https://api.example.com/")
    val repo = UserRepository(UserService(client))

    val error = repo.getUser("999").exceptionOrNull()

    assertIs(error)
    assertEquals(404, error.code)
}

For multi-route tests, branch on request.url.encodedPath inside the MockEngine lambda. See android-skills:android-testing for how Ktor fakes fit the three-tier test model.


WebSockets and Server-Sent Events

Real-time transports — pick based on direction and reconnection needs.

| Criterion | SSE (ktor-client-core) | WebSockets (ktor-client-websockets) | |---|---|---| | Direction | Server → client only | Bidirectional | | Protocol | HTTP (standard) | WebSocket (protocol upgrade) | | Auto-reconnect | Built-in

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.