| name | ktor-client-kmp |
| description | Setting up the Ktor HTTP client in `commonMain` with per-platform engines, auth, interceptors, retries, and logging. Use when implementing networking for a KMP app. |
Ktor Client in KMP
Instructions
Ktor is the canonical multiplatform HTTP client. Configure it once in commonMain; platforms only supply the engine.
1. Dependencies
[versions]
ktor = "2.3.12"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-contentneg = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-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-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentneg)
implementation(libs.ktor.serialization.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) }
jsMain.dependencies { implementation(libs.ktor.client.js) }
commonTest.dependencies { implementation(libs.ktor.client.mock) }
}
2. Engine factory via expect/actual
expect fun httpEngine(): HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
actual fun httpEngine(): HttpClientEngine = OkHttp.create {
config { retryOnConnectionFailure(true) }
}
import io.ktor.client.engine.darwin.Darwin
actual fun httpEngine(): HttpClientEngine = Darwin.create {
configureRequest { setAllowsCellularAccess(true) }
}
import io.ktor.client.engine.js.Js
actual fun httpEngine(): HttpClientEngine = Js.create()
3. Shared client in commonMain
fun buildHttpClient(
baseUrl: String,
tokenProvider: TokenProvider,
json: Json,
): HttpClient = HttpClient(httpEngine()) {
expectSuccess = true
install(ContentNegotiation) { json(json) }
install(Logging) {
level = LogLevel.INFO
logger = object : Logger {
override fun log(message: String) = Napier.d(tag = "Http", message = message)
}
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 15_000
socketTimeoutMillis = 30_000
}
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay(base = 2.0, maxDelayMs = 8_000)
retryIf { _, response -> response.status.value == 429 }
}
install(Auth) {
bearer {
loadTokens { tokenProvider.load()?.let { BearerTokens(it.access, it.refresh) } }
refreshTokens {
val refreshed = tokenProvider.refresh(oldTokens?.refreshToken)
?: return@refreshTokens null
BearerTokens(refreshed.access, refreshed.refresh)
}
sendWithoutRequest { req -> req.url.host == Url(baseUrl).host }
}
}
defaultRequest {
url(baseUrl)
header("Accept", "application/json")
}
}
4. Typed API layer
class ArticleApi(private val http: HttpClient) {
suspend fun latest(page: Int): PagedResponse<ArticleDto> =
http.get("articles") {
parameter("page", page)
}.body()
suspend fun create(article: ArticleDto): ArticleDto =
http.post("articles") {
contentType(ContentType.Application.Json)
setBody(article)
}.body()
}
expectSuccess = true makes non-2xx responses throw ResponseException; map those to domain errors at the repository layer, not in the API class.
5. Error mapping
suspend fun <T> withHttpErrorMapping(block: suspend () -> T): Result<T> = runCatching {
block()
}.recoverCatching {
when (it) {
is ClientRequestException ->
throw NetworkException.Http(it.response.status.value, it.response.bodyAsText())
is ServerResponseException ->
throw NetworkException.Server(it.response.status.value)
is HttpRequestTimeoutException, is ConnectTimeoutException, is SocketTimeoutException ->
throw NetworkException.Timeout
is IOException -> throw NetworkException.Offline
else -> throw NetworkException.Unknown(it)
}
}
6. Certificate pinning (Android only, via engine config)
actual fun httpEngine(): HttpClientEngine = OkHttp.create {
config {
certificatePinner(CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build())
}
}
On iOS, use App Transport Security / URLSession pinning at the platform level — do not try to cross-pin from Kotlin.
7. Testing with MockEngine
@Test
fun latestArticlesReturnsList() = runTest {
val mock = MockEngine { request ->
assertEquals("/articles", request.url.encodedPath)
respond(
content = """[{"id":1,"title":"Hi"}]""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(mock) { install(ContentNegotiation) { json() } }
val api = ArticleApi(client)
assertEquals(listOf(ArticleDto(1, "Hi")), api.latest(page = 1))
}
Checklist