Networking with Retrofit, coroutines and repositories

Retrofit and OkHttp configuration, kotlinx.serialization, dispatchers, Result-based error handling and a repository that hides the network.

Configuring the client

@Serializable
data class BookDto(val id: Long, val title: String, val authorId: Long)

interface BookApi {
    @GET("books")
    suspend fun books(@Query("page") page: Int = 0): List<BookDto>

    @GET("books/{id}")
    suspend fun book(@Path("id") id: Long): BookDto
}

val json = Json {
    ignoreUnknownKeys = true        // tolerate fields the server adds
    explicitNulls = false
    coerceInputValues = true        // null in a non-null field becomes the default
}

val okHttp = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(20, TimeUnit.SECONDS)
    .callTimeout(30, TimeUnit.SECONDS)
    .retryOnConnectionFailure(true)
    .addInterceptor { chain ->
        val request = chain.request().newBuilder()
            .header("Authorization", "Bearer " + tokenStore.accessToken())
            .build()
        chain.proceed(request)
    }
    .addInterceptor(HttpLoggingInterceptor().apply {
        level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
                else HttpLoggingInterceptor.Level.NONE
    })
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(okHttp)
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()

val api: BookApi = retrofit.create()
  • ignoreUnknownKeys = true is essential: without it, adding a field to the API response crashes every installed client.
  • Set callTimeout as well as read and connect timeouts. A call timeout covers the whole exchange, including retries and redirects.
  • Log bodies only in debug builds. An access token in a log line is a credential leak, and logs are collected by crash reporters.
  • A suspend function in the interface is enough for coroutines: Retrofit handles the thread switch, so no withContext is needed around the call.
⚠️
Never put an API key in the app. The APK is a public artefact: strings, BuildConfig fields and resources are all extractable. Secrets belong on your own server, and the app talks to that instead.

Errors as values

sealed interface ApiError {
    data object Offline : ApiError
    data object Timeout : ApiError
    data class Http(val code: Int, val message: String) : ApiError
    data class Unknown(val cause: Throwable) : ApiError
}

suspend fun <T> safeCall(block: suspend () -> T): Result<T> =
    try {
        Result.success(block())
    } catch (e: CancellationException) {
        throw e                                  // never swallow cancellation
    } catch (e: UnknownHostException) {
        Result.failure(ApiException(ApiError.Offline))
    } catch (e: SocketTimeoutException) {
        Result.failure(ApiException(ApiError.Timeout))
    } catch (e: HttpException) {
        Result.failure(ApiException(ApiError.Http(e.code(), e.message())))
    } catch (e: IOException) {
        Result.failure(ApiException(ApiError.Offline))
    } catch (e: Throwable) {
        Result.failure(ApiException(ApiError.Unknown(e)))
    }

class BookRepository(private val api: BookApi, private val dao: BookDao) {

    suspend fun books(): List<Book> = safeCall { api.books() }
        .map { dtos -> dtos.map { it.toDomain() } }
        .onSuccess { dao.replaceAll(it) }        // cache what succeeded
        .getOrElse { cached -> dao.all() }       // fall back to the last good data
}
FailureExceptionUser-facing message
No connectivityUnknownHostExceptionYou appear to be offline
Slow serverSocketTimeoutExceptionThe request took too long
4xxHttpException with a codeDepends: 401 re-authenticate, 404 show an empty state
5xxHttpExceptionSomething went wrong, with a retry
CancelledCancellationExceptionNothing - the screen went away

Catching CancellationException and turning it into an error breaks structured concurrency: the coroutine reports a failure instead of propagating cancellation, so a cancelled screen can still write to state. Always re-throw it first.

The repository boundary

class DefaultBookRepository(
    private val api: BookApi,
    private val dao: BookDao,
    private val io: CoroutineDispatcher = Dispatchers.IO,
) : BookRepository {

    // single source of truth: the database, with the network refreshing it
    override fun observeBooks(): Flow<List<Book>> = dao.observeAll()

    override suspend fun refresh(): Result<Unit> = withContext(io) {
        safeCall { api.books() }
            .map { dtos -> dao.replaceAll(dtos.map(BookDto::toDomain)) }
    }
}

// offline-first: the UI renders from the database and the refresh is a background
// side effect, so the screen works with no connectivity and updates when it returns
  • Keep Retrofit types out of the UI layer. DTOs are the wire format and change with the API; domain models are what the screens use, and the mapping is where a server change is absorbed.
  • One repository per domain concept, not per endpoint. It owns the caching rule, the retry policy and the mapping, which is precisely the logic you do not want duplicated in three ViewModels.
  • Inject a dispatcher so tests can pass a test dispatcher instead of touching real threads.
  • An offline-first repository returns a flow from the local database; the network result is written to the database and the flow emits the update.

FAQ

Why does my JSON parsing fail on some responses?
Usually a field that is null in the payload for a non-null Kotlin property, or an unexpected enum value. Set coerceInputValues, give the property a default, and make enums tolerant of unknown values with a fallback case.
Retrofit or Ktor client?
Both are mature and coroutine-based. Retrofit with OkHttp is the conventional Android choice with the widest ecosystem of interceptors; Ktor shares types with the server side and suits a Kotlin Multiplatform project.

Local persistence with Room and DataStore Dependency injection with Hilt

Last refreshed 2026-09-18.