JSON, serialization and HTTP clients
kotlinx.serialization with custom serializers, Ktor Client and Retrofit, timeouts, retries and mapping transport errors into your own types.
kotlinx.serialization
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import java.time.Instant
@Serializable
data class Article(
val id: Long,
val title: String,
@SerialName("published_at") val publishedAt: Instant,
val author: Author? = null,
val tags: List<String> = emptyList(),
)
@Serializable
data class Author(val name: String, val url: String? = null)
// custom serializer for a type the library does not know
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
}
val json = Json {
ignoreUnknownKeys = true // tolerate a server that adds fields
explicitNulls = false // omit nulls when encoding
encodeDefaults = false
coerceInputValues = true // fall back to the default on a wrong type
}| Setting | Effect | Risk |
|---|---|---|
ignoreUnknownKeys | New server fields do not break the client | Typos in field names are silently ignored |
isLenient | Accepts unquoted keys and values | Hides malformed payloads |
explicitNulls | Controls whether nulls are emitted | A server may require an explicit null |
coerceInputValues | Replaces an invalid value with the default | A real data problem looks like a missing value |
Ktor Client and Retrofit
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
val client = HttpClient {
install(ContentNegotiation) { json(json) }
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 5_000
socketTimeoutMillis = 15_000
}
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 2)
exponentialDelay()
}
defaultRequest {
url("https://api.example.com/")
header("Accept", "application/json")
}
}
suspend fun articles(client: HttpClient): List<Article> =
client.get("articles") { parameter("page", 1) }.body()
// Retrofit with the kotlinx converter, if you prefer annotation-based interfaces
interface ArticleApi {
@GET("articles")
suspend fun articles(@Query("page") page: Int): List<Article>
@POST("articles")
suspend fun create(@Body draft: ArticleDraft): Article
}- Ktor Client is multiplatform and plugin-oriented; Retrofit is JVM-first, reflection-based and extremely concise for a fixed REST API.
- Set connection, request and socket timeouts separately. A single overall timeout hides which stage is slow.
- Retry only idempotent requests by default; a POST retried without an idempotency key can create duplicates.
- Close the client when it is no longer needed — it owns a connection pool and a thread pool.
Mapping transport failures
suspend fun loadArticle(id: Long): Either<Article> = try {
Either.Ok(client.get("articles/$id").body())
} catch (e: CancellationException) {
throw e
} catch (e: ClientRequestException) { // 4xx
if (e.response.status == HttpStatusCode.NotFound) Either.Err(LoadError.NotFound)
else Either.Err(LoadError.Http(e.response.status.value, null))
} catch (e: ServerResponseException) { // 5xx
Either.Err(LoadError.Http(e.response.status.value, null))
} catch (e: HttpRequestTimeoutException) {
Either.Err(LoadError.Network(e))
} catch (e: IOException) {
Either.Err(LoadError.Network(e))
} catch (e: SerializationException) {
Either.Err(LoadError.Decoding("/articles"))
}⚠️
Ktor's default
expectSuccess setting throws on a non-2xx response. If you disable it to inspect the status yourself, remember that every call must then check response.status — an unchecked 500 body decodes into an empty object and looks like success.FAQ
Ktor Client or Retrofit for a new Android app?
Both are excellent. Retrofit has the shorter definition for a stable REST API and huge community knowledge; Ktor Client is the better choice if you share code with iOS, the browser or a server, or want plugins for logging, retries and auth in one place.
How do I keep a token fresh for every request?
Install an auth plugin or interceptor that adds the header, and on a 401 refresh once and replay the original request. Serialise concurrent refreshes through a mutex so ten parallel 401s produce one refresh, not ten.
Related
Building a REST service with Ktor Exceptions, Result and error-handling patterns
Last refreshed 2026-09-18.