Calling Out: The Ktor HTTP Client
Real services don’t live alone. They call payment providers, fetch from upstream APIs, talk to each other. Ktor ships an HTTP client alongside the server (same coroutine model, same plugin idea, same JSON support), so consuming an API feels just like serving one.
A client, configured
Add the client dependencies — core, an engine (CIO is a pure-Kotlin one), and the same content-negotiation/JSON pair you used on the server:
implementation("io.ktor:ktor-client-core")
implementation("io.ktor:ktor-client-cio")
implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-serialization-kotlinx-json")
Create a client and install plugins, exactly like configuring the server:
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
val client = HttpClient(CIO) {
install(ContentNegotiation) { json() }
}
A client holds a connection pool and is meant to be created once and reused — not per request. Build it at startup, share it, and client.close() on shutdown.
Making requests
A GET that deserializes the JSON response straight into a data class:
import io.ktor.client.request.*
import io.ktor.client.call.*
@Serializable
data class GitHubUser(val login: String, val name: String? = null, val public_repos: Int)
suspend fun fetchUser(username: String): GitHubUser =
client.get("https://api.github.com/users/$username").body()
Two things mirror the server side. client.get(url) is a suspend call — it awaits the response without blocking. And .body() uses content negotiation to parse the JSON into GitHubUser, the inverse of the server’s call.receive<T>(). The type you ask for drives the parsing.
POSTing a body works the same way, with setBody:
import io.ktor.http.*
suspend fun createRemoteTask(draft: NewTask): Task =
client.post("https://api.example.com/tasks") {
contentType(ContentType.Application.Json)
setBody(draft) // serialized to JSON by ContentNegotiation
}.body()
The lambda after the URL is where you set headers, the content type, query parameters, and the body. setBody(draft) serializes the object for you: the same plugin doing the reverse of what it does on the server.
Timeouts and defaults
A client calling the outside world needs guardrails. Two plugins you’ll almost always want:
import io.ktor.client.plugins.*
val client = HttpClient(CIO) {
install(ContentNegotiation) { json() }
install(HttpTimeout) {
requestTimeoutMillis = 10_000
connectTimeoutMillis = 5_000
}
install(DefaultRequest) {
url("https://api.example.com")
header(HttpHeaders.Accept, "application/json")
}
}
HttpTimeout stops a hung upstream from hanging you — without it, a slow dependency ties up your coroutines indefinitely. DefaultRequest sets a base URL and common headers once, so individual calls become relative: client.get("/tasks"). There are matching plugins for retries, auth (attaching a bearer token to every call), and logging client traffic.
Using it from a service
Wrap the client in a service and inject it like any other dependency (Koin handles this — register the HttpClient as a single):
class GitHubService(private val client: HttpClient) {
suspend fun repoCount(username: String): Int =
client.get("https://api.github.com/users/$username").body<GitHubUser>().public_repos
}
Now a route can enrich its response with data from an upstream API, and the calling code never deals with sockets or JSON parsing, just suspend functions returning typed objects. Wrap upstream failures in your own domain exceptions so StatusPages can translate them; a flaky dependency shouldn’t surface to your client as a raw 500.
One library, server and client
This is the same Ktor. The client and server share content negotiation, the plugin model, and the coroutine foundation, so calling an API and serving one feel like the same skill. And because the client is multiplatform, this exact code — HttpClient, .body(), the plugins — runs unchanged in an Android app, an iOS app, or Kotlin/JS in the browser. Learn the client once on the server, reuse it everywhere Kotlin runs.
Final thoughts
The Ktor client is the server’s mirror image: client.get(...).body<T>() is call.receive<T>() turned around, plugins install the same way, and everything is suspend-based. The non-obvious discipline is operational, not syntactic. Reuse one client, always set timeouts, and translate upstream failures into your own error types. Get those right and calling other services stays as clean as the rest of your Ktor code.
Next: WebSockets — for when request/response isn’t enough and you need a live, two-way connection.
Comments