Android with Kotlin and Jetpack Compose · Architecture and Data: ViewModels, Navigation, Networking and Offline
Networking with Retrofit, kotlinx.serialization and Repositories
Call a REST API with Retrofit and kotlinx.serialization, hide it behind a repository, map errors to messages the UI can show, and retry with exponential backoff.
What you'll build
The data layer for the Repositories screen: a Retrofit API, JSON models that ignore unknown fields, a repository that retries transient failures with backoff, and error mapping that turns exceptions into user-friendly messages.
The idea
- Retrofit turns an annotated Kotlin interface into HTTP calls; with
suspendfunctions it works naturally with coroutines. - kotlinx.serialization converts JSON to Kotlin classes at compile time (no reflection). Set
ignoreUnknownKeys = trueso new fields from the server don't crash old app versions. - A repository is the only class that knows where data comes from. ViewModels call
repository.topRepos(); whether that hits the network, a cache or both is hidden. - Errors: separate transient problems (timeouts, 5xx, no connection), which are worth retrying, from permanent ones (4xx, bad data), which aren't.
Remember to add <uses-permission android:name="android.permission.INTERNET" /> to the manifest, and never do network calls on the main thread (suspend functions in Retrofit already switch threads).
The code
@Serializable
data class RepoDto(
val id: Long,
@SerialName("full_name") val fullName: String,
@SerialName("stargazers_count") val stars: Int = 0,
)
interface GitHubApi {
@GET("search/repositories")
suspend fun search(@Query("q") query: String, @Query("per_page") perPage: Int = 20): SearchResponse
}
val json = Json { ignoreUnknownKeys = true }
val api: GitHubApi = Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(GitHubApi::class.java)
class RepoRepository(private val api: GitHubApi, private val io: CoroutineDispatcher = Dispatchers.IO) {
suspend fun topRepos(): List<Repo> = withContext(io) {
retryWithBackoff { api.search("language:kotlin", perPage = 20).items.map { it.toDomain() } }
}
}
Dependencies: com.squareup.retrofit2:retrofit:3.0.0, com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0, org.jetbrains.kotlinx:kotlinx-serialization-json:1.12.0 and the org.jetbrains.kotlin.plugin.serialization Gradle plugin.
Try the logic
JSON parsing, error classification and retry with exponential backoff, run with virtual time so the waits are instant:
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.IOException
@Serializable
data class RepoDto(
val id: Long,
@SerialName("full_name") val fullName: String,
@SerialName("stargazers_count") val stars: Int = 0,
)
class HttpException(val code: Int) : Exception("HTTP $code")
fun isTransient(e: Exception) = e is IOException || (e is HttpException && e.code >= 500)
fun userMessage(e: Exception) = when {
e is IOException -> "You're offline. Showing saved data."
e is HttpException && e.code == 404 -> "Not found."
e is HttpException && e.code >= 500 -> "The server is having trouble. Try again soon."
else -> "Something went wrong."
}
suspend fun <T> retryWithBackoff(times: Int = 4, initialMs: Long = 500, block: suspend () -> T): T {
var wait = initialMs
repeat(times - 1) { attempt ->
try {
return block()
} catch (e: Exception) {
if (!isTransient(e)) throw e
println("attempt ${attempt + 1} failed (${e.message}), retrying in $wait ms")
delay(wait)
wait *= 2
}
}
return block()
}
val json = Json { ignoreUnknownKeys = true }
fun main() = runTest {
val body = "{\"id\": 1, \"full_name\": \"android/nowinandroid\", \"stargazers_count\": 18000, \"topics\": [\"compose\"]}"
println(json.decodeFromString<RepoDto>(body))
var calls = 0
val result = retryWithBackoff {
calls++
when (calls) {
1 -> throw IOException("timeout")
2 -> throw HttpException(503)
else -> "200 OK"
}
}
println("$result after $calls calls, ${testScheduler.currentTime} ms of (virtual) waiting")
val notFound = runCatching { retryWithBackoff { throw HttpException(404) } }.exceptionOrNull() as Exception
println("404 is not retried: ${userMessage(notFound)}")
println(userMessage(IOException()))
}
Output
RepoDto(id=1, fullName=android/nowinandroid, stars=18000) attempt 1 failed (timeout), retrying in 500 ms attempt 2 failed (HTTP 503), retrying in 1000 ms 200 OK after 3 calls, 1500 ms of (virtual) waiting 404 is not retried: Not found. You're offline. Showing saved data.
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Not ignoring unknown keys. A new field added by the backend crashes every installed app that can't parse it.
- Retrying everything. Retrying a 400 or 401 wastes battery and hides bugs; retry only transient failures, with backoff and a limit.
- Leaking DTOs into the UI. Map network models to domain models in the repository, so API changes don't ripple through screens.
- Showing raw exception messages. Map errors to messages users understand, and log the details for yourself.
Key points
- Retrofit with
suspendfunctions plus kotlinx.serialization is the standard REST stack. - Repositories hide data sources and map DTOs to domain models.
- Classify errors: retry transient ones with exponential backoff; surface the rest.
- Test network logic with fakes and virtual time (
runTest).
Further reading: Data layer, Retrofit on GitHub, kotlinx.serialization guide.
Practice questions
Why set ignoreUnknownKeys = true when parsing API responses in a mobile app?
Answer
Old app versions stay installed for months. If the server adds a field, a strict parser fails on every response for those users. Ignoring unknown keys lets the API evolve without breaking deployed clients.
Which network errors should a mobile app retry automatically?
Answer
Transient ones: timeouts, lost connections and 5xx server errors (and 429 after the Retry-After time), using exponential backoff with a limit. Not 4xx client errors such as 400, 401 or 404, which will fail again until something changes.
Open in the app to mark lessons complete, take notes and track your progress.