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 = trueis essential: without it, adding a field to the API response crashes every installed client.- Set
callTimeoutas 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
suspendfunction in the interface is enough for coroutines: Retrofit handles the thread switch, so nowithContextis 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
}| Failure | Exception | User-facing message |
|---|---|---|
| No connectivity | UnknownHostException | You appear to be offline |
| Slow server | SocketTimeoutException | The request took too long |
| 4xx | HttpException with a code | Depends: 401 re-authenticate, 404 show an empty state |
| 5xx | HttpException | Something went wrong, with a retry |
| Cancelled | CancellationException | Nothing - 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.
Related
Local persistence with Room and DataStore Dependency injection with Hilt
Last refreshed 2026-09-18.