Skip to content
Commitwise
Menu
Android Development

Networking with Retrofit and Ktor

Call a REST API from an Android app with Retrofit or Ktor Client and kotlinx.serialization, turn HTTP and network failures into results the UI can show, add auth headers and timeouts with interceptors, and keep requests off the main thread and out of the composables.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The app works perfectly on office Wi-Fi. On the train it shows a spinner forever, a server error crashes it with an exception nobody caught, and a field the backend renamed last week makes every product list empty. Networking code is short to write and long to get right, because everything that can go wrong eventually does: no connection, a slow connection, a 500, an expired token, a response that doesn’t match your class.

This tutorial builds an API layer with Retrofit (the most widely used Android HTTP library) and shows the same thing with Ktor Client (Kotlin-first, also used in Kotlin Multiplatform), then makes it robust.

New to some of these? Open what you need, or skip ahead.

Basics:What is a REST API?

A REST APIREST APIA web API where things are resources at URLs, and HTTP methods like GET and POST say what to do with them.Open in glossary → is how an app talks to a server over HTTP. The app sends a request to a URL, like GET https://api.shop.example.com/products/42, and the server answers with a status code (200 OK, 404 Not Found, 500 Server Error) and usually a body in JSON, a text format for data:

{ "id": 42, "name": "Desk lamp", "priceCents": 3499 }
Basics:What is a coroutine?

A coroutineCoroutineA function that can pause and resume later without blocking a thread, used for async code in Kotlin.Open in glossary → is a Kotlin function that can suspend: pause while it waits (for the network, a database, a timer) without blocking the thread, and continue later. Functions marked suspend can only be called from other coroutines, usually started in a ViewModel with viewModelScope.launch. The Coroutines and Flow tutorial covers them.

Part Not started

Making requests

Ordering in a restaurant, you don’t walk into the kitchen; you tell the waiter “the soup, please” and get a bowl back. Retrofit is the waiter. You write an interface that lists the requests your app makes, as ordinary Kotlin functions, and Retrofit does the HTTP: building the URL, sending the request, and turning the JSON response into Kotlin objects.

Retrofit generates an implementation of an annotated interface at runtime; suspend functions are supported natively and are main-safe (OkHttp does the I/O on its own threads). Ktor Client is an explicit DSL over a pluggable engine (OkHttp on Android, Darwin on iOS), which makes it the default for KMP shared code. Both pair best with kotlinx.serialization: compile-time generated serializers, no reflection, R8-friendly, and explicit handling of unknown keys and defaults. Keep one client instance per app (connection pool, cache, HTTP/2 multiplexing) and inject it.

@Serializable
data class ProductDto(
    val id: Long,
    val name: String,
    @SerialName("price_cents") val priceCents: Long,
    val imageUrl: String? = null,          // optional in the JSON
)

interface ShopApi {
    @GET("products")
    suspend fun products(@Query("page") page: Int): List<ProductDto>

    @GET("products/{id}")
    suspend fun product(@Path("id") id: Long): ProductDto

    @POST("cart/items")
    suspend fun addToCart(@Body item: CartItemDto): CartDto
}

val json = Json { ignoreUnknownKeys = true }      // new server fields don't break old apps

val api: ShopApi = Retrofit.Builder()
    .baseUrl("https://api.shop.example.com/")      // must end with "/"
    .client(okHttpClient)
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()
    .create(ShopApi::class.java)
val client = HttpClient(OkHttp) {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    install(HttpTimeout) { requestTimeoutMillis = 15_000 }
    defaultRequest { url("https://api.shop.example.com/") }
    expectSuccess = true                            // throw on 4xx/5xx instead of returning them
}

class ShopService(private val client: HttpClient) {
    suspend fun products(page: Int): List<ProductDto> =
        client.get("products") { parameter("page", page) }.body()

    suspend fun product(id: Long): ProductDto =
        client.get("products/$id").body()
}
  • @Serializable data classes describe the JSON. @SerialName maps a JSON name like price_cents to a Kotlin name; a default value (= null) makes the field optional.
  • ignoreUnknownKeys = true matters: without it, the day the server adds a field, every old version of your app fails to parse the response.
  • suspend functions don’t block the main thread; Retrofit and Ktor do the waiting on background threads. Call them from a ViewModel with viewModelScope.launch.
  • One client for the whole app. Creating a new one per request throws away connection reuse and caching.
Spot the bugA request on every recomposition

Tap the line with the bug.

@Composable
fun ProductScreen(id: Long, api: ShopApi) {
    var product by remember { mutableStateOf<ProductDto?>(null) }
    val scope = rememberCoroutineScope()
    scope.launch { product = api.product(id) }

    product?.let { Text(it.name) } ?: CircularProgressIndicator()
}

Predict the output

The app's ProductDto has val name: String. The server starts sending one product whose name is null in the JSON. With the default Json settings, what happens when the list loads?

Retrofit or Ktor

  • Retrofit: declarative, mature, huge ecosystem (call adapters, mocks), Android/JVM only. Good default for Android-only apps.
  • Ktor Client: imperative DSL, multiplatform (shared networking for Android and iOS in KMP), plugins for auth, retries, logging, content negotiation. Slightly more code per endpoint.
  • Either way: DTOs stay in the data layer and are mapped to domain models, so a backend rename touches one mapper; generate DTOs from an OpenAPI spec for large APIs; keep R8 rules for serialization (kotlinx.serialization ships them).
  • Retrofit: annotated interface, suspend functions, main-safe. Ktor: DSL over OkHttp/Darwin engines, KMP-friendly.
  • kotlinx.serialization with ignoreUnknownKeys = true; nullable or defaulted fields for optional data; @SerialName for wire names.
  • One shared client (connection pool, HTTP/2, cache), injected. Never fetch from a composable body; ViewModel + viewModelScope.
  • DTOs mapped to domain models at the data layer boundary.

Check this part

  1. Why set ignoreUnknownKeys = true?

  2. Where should a screen's network request be started?

  3. Why choose Ktor Client over Retrofit?

Describe the API as a Kotlin interface with suspend functions (Retrofit) or call a shared HttpClient (Ktor), and map JSON to @Serializable data classes. Both run on OkHttp or a similar engine; suspend functions are main-safe, so call them from a ViewModel, never from a composable.

Part Not started

When requests fail

Every request can fail, and the user deserves to know why in words they understand. There are three different kinds of failure:

  • No answer at all: no internet, airplane mode, a timeout. Retrofit throws an IOException. Tell the user to check their connection, and offer a Retry button.
  • The server said no: a status like 401 (not logged in), 404 (not found) or 500 (server broke). Retrofit throws an HttpException with the code. Each one deserves its own message.
  • An answer you can’t read: the JSON doesn’t match your class. A SerializationException. That’s a bug to report, not something the user can fix.

Retrofit suspend functions throw IOException for transport failures, HttpException for non-2xx (unless you return Response<T>), and serialization exceptions for decoding errors; Ktor throws ClientRequestException/ServerResponseException with expectSuccess. Translate at the repository boundary into a domain result (Result<T> or a sealed ApiResult) so the UI layer never sees transport types. Be careful with runCatching in coroutines: it also catches CancellationException, which breaks structured concurrency unless rethrown.

sealed interface AppError {
    data object Offline : AppError
    data object Unauthorized : AppError
    data object NotFound : AppError
    data class Server(val code: Int) : AppError
    data class Unexpected(val cause: Throwable) : AppError
}

class ProductRepository(private val api: ShopApi) {
    suspend fun product(id: Long): Result<Product> = apiCall { api.product(id).toDomain() }
}

suspend fun <T> apiCall(block: suspend () -> T): Result<T> =
    try {
        Result.success(block())
    } catch (e: CancellationException) {
        throw e                                         // never swallow cancellation
    } catch (e: IOException) {
        Result.failure(AppException(AppError.Offline))
    } catch (e: HttpException) {
        Result.failure(AppException(when (e.code()) {
            401 -> AppError.Unauthorized
            404 -> AppError.NotFound
            else -> AppError.Server(e.code())
        }))
    } catch (e: SerializationException) {
        Result.failure(AppException(AppError.Unexpected(e)))   // also report to crash logging
    }

class AppException(val error: AppError) : Exception()
  • One place translates errors, so every screen gets the same behavior.
  • CancellationException is rethrown: when the user leaves the screen, the request is cancelled; that’s not an error to show.
  • The ViewModel turns AppError.Offline into “No internet connection. Try again.” and shows a Retry button.
Spot the bugAn error that never shows

Tap the line with the bug.

fun load() = viewModelScope.launch {
    _state.value = UiState.Loading
    val result = runCatching { api.products(page = 1) }
    result.onSuccess { _state.value = UiState.Loaded(it) }
    result.onFailure { Log.e("Products", "Failed", it) }
}

Production puzzles

Predict the output

An OkHttpClient has callTimeout unset, connectTimeout 10 s, readTimeout 10 s. The server accepts the connection and then sends the response body one byte every 5 seconds. How long until the call fails?

Retries and offline

  • Retry only what’s safe: GETs and requests with an idempotency key; never blindly retry a POST that creates an order. Exponential backoff with jitter, a small cap, and only for transport errors, 429 and 503.
  • Don’t retry in a loop while offline: observe connectivity (ConnectivityManager.NetworkCallback) or let WorkManager retry with a network constraint.
  • Offline-first apps read from a local database and sync in the background, so most screens never wait on the network at all (see Room database).
  • Transport (IOException), HTTP status (HttpException), decoding (SerializationException): translate at the repository into a domain result.
  • Rethrow CancellationException; plain runCatching swallows it.
  • Every failure path ends in a UI state with retry.
  • callTimeout for an overall ceiling; readTimeout is per read. Retry idempotent requests only, with backoff and jitter; connectivity-aware or WorkManager for offline.

Check this part

  1. The phone is in airplane mode. Which exception does a Retrofit call throw?

  2. Why rethrow CancellationException?

  3. Which OkHttp timeout bounds the whole request?

Network calls fail in three ways: no connection or timeout (IOException), an HTTP error status (HttpException), or a response that doesn't parse. Catch them at the repository and return a Result or sealed type the UI can show, never let them crash the app.

Part Not started

Interceptors and authentication

Every request needs the same things: the user’s access token, the app version, maybe a language header. Instead of adding them in every function, OkHttp lets you add an interceptor: code that sees every request on its way out (and every response on its way back) and can change it.

Application interceptors run once per call (headers, logging, metrics); network interceptors see each network attempt including redirects. For OAuth, attach the access token in an interceptor and refresh it in an Authenticator, which OkHttp calls on 401. The refresh must be serialized: many concurrent calls hit 401 at the same time, and only one should refresh, the rest reuse the new token. Never log bodies or auth headers in release builds.

class AuthInterceptor(private val tokens: TokenStore) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokens.accessToken() ?: return chain.proceed(chain.request())
        val request = chain.request().newBuilder()
            .header("Authorization", "Bearer $token")
            .build()
        return chain.proceed(request)
    }
}

class TokenAuthenticator(
    private val tokens: TokenStore,
    private val authApi: AuthApi,
) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        if (response.request.header("Authorization") == null) return null
        if (responseCount(response) >= 2) return null               // already retried: give up, log out
        val newToken = synchronized(this) {
            val current = tokens.accessToken()
            val sent = response.request.header("Authorization")?.removePrefix("Bearer ")
            if (current != null && current != sent) current         // someone else refreshed already
            else runBlocking { tokens.refresh(authApi) }            // OkHttp thread, not main
        } ?: return null
        return response.request.newBuilder().header("Authorization", "Bearer $newToken").build()
    }

    private fun responseCount(response: Response): Int =
        generateSequence(response) { it.priorResponse }.count()
}

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(AuthInterceptor(tokenStore))
    .authenticator(TokenAuthenticator(tokenStore, authApi))
    .addInterceptor(HttpLoggingInterceptor().apply {
        level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
    })
    .callTimeout(30, TimeUnit.SECONDS)
    .build()
  • The interceptor adds the token to every request.
  • The authenticator runs when the server answers 401: it gets a new token with the refresh token and repeats the request once. If that fails too, the user has to log in again.
  • synchronized makes sure that when five requests fail at once, only the first one refreshes; the others see the new token and use it.
  • Logging full bodies only in debug builds: release logs must never contain tokens or personal data.
Exerciseeasy

Add common headers

The backend wants every request to include X-App-Version (from BuildConfig.VERSION_NAME) and Accept-Language with the user’s current language. Write an interceptor and add it to the client.

›

chain.request().newBuilder().header(...) adds or replaces a header. The current locale comes from Locale.getDefault().toLanguageTag().

SolutionTry it yourself first
class CommonHeadersInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request().newBuilder()
            .header("X-App-Version", BuildConfig.VERSION_NAME)
            .header("Accept-Language", Locale.getDefault().toLanguageTag())
            .build()
        return chain.proceed(request)
    }
}

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(CommonHeadersInterceptor())
    .addInterceptor(AuthInterceptor(tokenStore))
    .build()

Reading the locale inside intercept (instead of once in the constructor) means a language change in the app’s settings applies to the next request without rebuilding the client.

Production puzzles

Spot the bugLogged out at random

Tap the line with the bug.

class TokenAuthenticator(
    private val tokens: TokenStore,
    private val authApi: AuthApi,
) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        val newToken = runBlocking { tokens.refresh(authApi) } ?: return null
        return response.request.newBuilder()
            .header("Authorization", "Bearer $newToken")
            .build()
    }
}

  • Application interceptors for headers, logging, metrics; network interceptors per attempt.
  • Token in an interceptor, refresh in an Authenticator: single-flight (lock, compare token sent vs current), retry once, then log out. Refresh rotation + parallel refreshes = revoked sessions.
  • Ktor: Auth plugin with bearer { loadTokens; refreshTokens }.
  • No body/header logging in release builds.

Check this part

  1. What does an OkHttp interceptor do?

  2. Why must token refresh be single-flight?

OkHttp interceptors add behavior to every request: auth headers, logging, common headers. An Authenticator refreshes an expired token once and retries; make it thread-safe so ten parallel 401s cause one refresh, not ten.

Key takeaways

  • Describe the API as a Retrofit interface (or Ktor calls) with suspend functions and @Serializable data classes.
  • Set ignoreUnknownKeys = true, and make optional fields nullable or defaulted.
  • Start requests from a ViewModel, never from a composable body.
  • Turn every failure into a state the user can see and retry; rethrow cancellation.
  • One injected client; DTOs mapped to domain models at the boundary.
  • Errors translated once at the repository; callTimeout as the overall ceiling.
  • Retry only idempotent requests with backoff; offline handled by connectivity or WorkManager.
  • Single-flight token refresh with a retry limit; no sensitive logging in release.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.