Loading, empty, error and retry
Module 8 caught IOException in PricesViewModel.refresh() and said the network would bring other failures. Here is the first one. A scratch build with that catch, the Retrofit data source from lesson 1 passing exceptions straight through, and the test server answering 500. Tap "Refresh":
E/AndroidRuntime(24238): FATAL EXCEPTION: main
E/AndroidRuntime(24238): retrofit2.HttpException: HTTP 500 Internal Server Error
E/AndroidRuntime(24238): at retrofit2.KotlinExtensions$await$2$2.onResponse(KotlinExtensions.kt:53)
E/AndroidRuntime(24238): at retrofit2.OkHttpCall$1.onResponse(OkHttpCall.java:164)
E/AndroidRuntime(24238): at okhttp3.internal.connection.RealCall$AsyncCall.run(RealCall.kt:584)
E/AndroidRuntime(24238): at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1156)
E/AndroidRuntime(24238): Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@93f1673, Dispatchers.Main.immediate]
A server's bad afternoon, and the app dies in the farmer's hand. Notice there is not one Mandi Bhav line in the trace: the exception was made on an OkHttp thread and travelled through the coroutine, so the stack shows where it was created, not who failed to catch it. HttpException is not an IOException; neither is a malformed file. This lesson lists everything a request can do wrong, gives each one a name and a policy, and then gives every state of the Prices screen something true to say, including the one module 7 left open: what loading looks like when the source is slow.
Every way a request fails
| What happened | What Retrofit and OkHttp throw | Mandi Bhav calls it |
|---|---|---|
| Airplane mode, no signal | UnknownHostException, ConnectException (both IOException) |
NoConnection |
| Online, but the server refused or reset the connection | ConnectException, SocketException (IOException) |
Unreachable |
| Took too long: to connect, between bytes, or overall | SocketTimeoutException, or InterruptedIOException("timeout") |
Timeout |
| Answered 404, 500, 503 | HttpException, with code() |
Server(code) |
| Answered 200 with half a file, a missing field, a wrong type | SerializationException |
BadData |
| Answered 200 with a date the app cannot parse | DateTimeParseException |
BadData |
| The caller was cancelled | CancellationException |
Nothing: it must go through |
Two rows share ConnectException. On the emulator, airplane mode gave:
I/OkHttp (22059): <-- HTTP FAILED: java.net.ConnectException: Failed to connect to /10.0.2.2:8765. http://10.0.2.2:8765/api/v1/prices.json (27ms)
and stopping the server, with the phone online, gave:
I/OkHttp (23308): <-- HTTP FAILED: java.net.ConnectException: Failed to connect to /10.0.2.2:8765. http://10.0.2.2:8765/api/v1/prices.json (10ms)
The same exception, the same message. Telling a farmer "No connection" when her phone is fine and the server is down sends her to restart her phone for nothing. The only thing that can tell them apart is the phone's own view of its network, which lesson 6's NetworkMonitor provides.
One exception the app understands
The data layer translates all of that into one type of its own, in data/RefreshFailure.kt:
sealed interface RefreshFailure {
data object NoConnection : RefreshFailure
data object Unreachable : RefreshFailure
data object Timeout : RefreshFailure
data class Server(val code: Int) : RefreshFailure
data object BadData : RefreshFailure
}
class RefreshException(val failure: RefreshFailure, cause: Throwable? = null) :
Exception("Refresh failed: $failure", cause)
and the Retrofit data source does the translating, around every request:
private suspend fun <T> request(block: suspend () -> T): T = try {
block()
} catch (e: HttpException) {
throw RefreshException(RefreshFailure.Server(e.code()), e)
} catch (e: SerializationException) {
throw RefreshException(RefreshFailure.BadData, e)
} catch (e: DateTimeException) {
throw RefreshException(RefreshFailure.BadData, e)
} catch (e: InterruptedIOException) {
throw RefreshException(RefreshFailure.Timeout, e)
} catch (e: IOException) {
// The same exception for airplane mode and for a server that is
// down; only the phone's own view of its connection tells them apart.
val failure = if (networkMonitor.isOnlineNow()) RefreshFailure.Unreachable else RefreshFailure.NoConnection
throw RefreshException(failure, e)
}
with fetchLatestPrices() = request { api.latestPrices().asLatestPrices(baseUrl) }, so the mapping's date parsing is inside it too. The order is the logic: SocketTimeoutException is an InterruptedIOException, which is an IOException, so the timeout branch must come first or every timeout says "no connection". And nothing here catches CancellationException, lesson 2's rule. The ViewModel then catches exactly one type, RefreshException, and Retrofit, OkHttp and kotlinx.serialization stay below the repository, where module 7 said such details belong.
Timeouts, chosen rather than inherited
OkHttp's defaults, read from a new OkHttpClient() in a scratch test:
connect=10000 read=10000 write=10000 call=0 retryOnConnectionFailure=true
The read timeout is per read, not per request. A server that sends a few bytes every few seconds, which is what a request looks like on a weak signal, never trips it, and call=0 means there is no limit on the whole request. A MockWebServer test makes that concrete, with a client that has a 500 ms read timeout and a 1,500 ms call timeout:
@Test
fun aTrickleThatNeverTripsTheReadTimeoutStillHitsTheCallTimeout() = runTest {
// 100 bytes every 400 ms: each read is inside the 500 ms read timeout,
// and the whole file would take about 14 s. Only the call timeout stops it.
server.enqueue(
MockResponse.Builder().body(committed("prices.json")).throttleBody(100, 400, TimeUnit.MILLISECONDS).build(),
)
…
assertEquals(RefreshFailure.Timeout, failure)
assertTrue("took $took", took.inWholeMilliseconds in 1_400..3_000)
}
It passes: the call timeout ended it inside the test's window of 1.4 to 3 seconds, where the read timeout alone would have let it run for about fourteen. Mandi Bhav's client keeps 10 seconds to connect and between reads, and adds a call timeout of 20 seconds, in di/NetworkModule.kt:
fun pricesHttpClient(
interceptors: Set<Interceptor> = emptySet(),
connectTimeoutMillis: Long = 10_000,
readTimeoutMillis: Long = 10_000,
callTimeoutMillis: Long = 20_000,
): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(connectTimeoutMillis, TimeUnit.MILLISECONDS)
.readTimeout(readTimeoutMillis, TimeUnit.MILLISECONDS)
.callTimeout(callTimeoutMillis, TimeUnit.MILLISECONDS)
.apply { interceptors.forEach { addInterceptor(it) } }
.build()
as a plain function so the tests build the same client with shorter limits. Twenty seconds is a judgement: the whole of prices.json is 3.5 KB, which arrives in well under that on a 2G connection, and a farmer waiting longer than that wants to be told.
Retrying, with a policy
Not every failure deserves a second try. retryOnConnectionFailure=true above means OkHttp already retries some connection problems itself, silently, before you hear about them. On top of that:
| Failure | Retry at once? | Why |
|---|---|---|
Server(503), Server(500), 408, 429 |
Yes, up to 3 tries, waiting 1 s then 2 s | Usually brief, and fails fast, so a retry costs little |
Unreachable |
Yes, the same | A refused or reset connection often clears in seconds |
Timeout |
No | On a slow link the retry probably times out too, and costs the farmer another 20 seconds and more data |
NoConnection |
No | Nothing will change in two seconds; lesson 6 retries when the connection returns |
Server(404) |
No | The file is not there; asking again will not put it there |
BadData |
No | The same bytes will fail the same way |
retryingTransientFailures in RefreshFailure.kt implements it, wrapping each fetch in the repository:
suspend fun <T> retryingTransientFailures(
attempts: Int = 3,
firstDelayMillis: Long = 1_000,
block: suspend () -> T,
): T {
var wait = firstDelayMillis
repeat(attempts - 1) {
try {
return block()
} catch (e: RefreshException) {
if (!e.failure.isWorthRetryingSoon) throw e
}
delay(wait)
wait *= 2
}
return block()
}
It catches only RefreshException, and delay is a suspension point, so leaving the screen stops the waiting at once. The doubling wait is exponential backoff: if many phones retry a struggling server, each waits longer each time instead of hammering it. On the emulator, with the server answering 500:
11-08 22:48:28.253 I/OkHttp (22059): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:48:28.263 I/OkHttp (22059): <-- 500 Internal Server Error http://10.0.2.2:8765/api/v1/prices.json (9ms, 9-byte body)
11-08 22:48:29.266 I/OkHttp (22059): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:48:29.273 I/OkHttp (22059): <-- 500 Internal Server Error http://10.0.2.2:8765/api/v1/prices.json (6ms, 9-byte body)
11-08 22:48:31.275 I/OkHttp (22059): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:48:31.279 I/OkHttp (22059): <-- 500 Internal Server Error http://10.0.2.2:8765/api/v1/prices.json (3ms, 9-byte body)
Three tries, one second and then two apart, then: "The prices server has a problem (error 500). Try again later." A larger app adds random jitter to the wait, so that a thousand phones that failed at the same second do not retry at the same second; for one farmer's tap it is not worth the code.
Every state the Prices screen can be in
Module 7 measured loading from memory at 28 ms and decided the detail screen should draw nothing. Module 8's database answered in under a second. The network is the slow case module 7 left open, and the first launch is where it matters: there is nothing saved to show.
| State | When | What the screen shows |
|---|---|---|
| Loading | Before the database answers, under a second | The thin bar; nothing else, or it flickers |
| First download | Nothing saved, refresh running | A spinner and "Downloading today's prices…" in the body |
| Slow first download | The same, after 5 seconds | Plus: "This is taking a while; the connection is slow. Once they arrive, the prices are saved and open instantly next time." |
| First download failed | Nothing saved, a RefreshFailure |
The failure in plain words, and a "Try again" button |
| Content | Prices saved | The prices and module 8's "Updated" line |
| Refreshing | Prices saved, refresh running | The prices, and the thin bar under the app bar |
| Refresh failed | Prices saved, a failure | The prices unchanged, and a snackbar with the failure and "Retry" |
| Offline | No validated connection | The prices, with "Offline. Showing the prices saved on this phone." (lesson 6) |
The decision for slow sources is the second and third rows. Show what you have at once; when you have nothing, say what you are doing, and after a few seconds say that it is slow and that waiting is worth it. Five seconds is a choice, not a measurement of anything: long enough that a normal download never shows it, short enough that the farmer has not yet decided the app is broken. The timer is UI state, so it lives in the composable that shows it:
isDownloading -> {
var isSlow by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(SLOW_AFTER_MILLIS)
isSlow = true
}
CircularProgressIndicator()
Text("Downloading today's prices…", style = MaterialTheme.typography.titleMedium)
if (isSlow) {
Text(
text = "This is taking a while; the connection is slow. Once they arrive, " +
"the prices are saved and open instantly next time.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
On the emulator, a cleared app and a server told to wait seven seconds per file showed the spinner, then the slow message, then nine prices after 14 seconds. The ViewModel starts that download itself, once, when it finds the database empty:
init {
viewModelScope.launch {
// A new install has nothing to show, so it downloads today's
// prices without waiting for a tap. Once per ViewModel: a
// rotation keeps it, so this does not run again.
if (repository.todaysPrices().first().isEmpty()) refresh()
}
}
PricesUiState gains refreshFailure and isOnline, and refresh() catches RefreshException, keeps the failure, and only sets a snackbar message when there are prices on screen: on an empty screen the body already says it. The words are one function, refreshFailureMessage(failure, hasSavedPrices), so every failure has exactly one sentence, and none of them shows an exception or blames the farmer. "Try again" and the snackbar's "Retry" both call refresh(); a tap while one is running is still ignored, module 7's guard.
With the server answering 503 to a cleared app, the screen said "The prices server has a problem (error 503). Try again later." above "Try again"; with the server fixed, one tap brought the prices. With half a file, the snackbar said "The prices server sent prices this app cannot read. Try again later, or update the app." over the saved prices.
Testing failures with MockWebServer
Producing a 503, a trickle or half a file from a real server on demand is hard; MockWebServer is an HTTP server inside the test that answers with whatever you queue. Add testImplementation(libs.okhttp.mockwebserver3), versioned by the same BOM. RetrofitPriceNetworkDataSourceTest builds the real Retrofit and client against it, and reads the committed api/v1/prices.json from the repository, so the real file is tested too:
@Test
fun aServerErrorKeepsItsCode() = runTest {
respond(code = 503, body = "Service Unavailable")
assertEquals(RefreshFailure.Server(503), failureOf { source.fetchLatestPrices() })
}
Its thirteen tests cover the table at the top: the committed files parsing, an unknown field ignored, a 503 and a 404, half a file, a missing field, a dd/MM/yyyy date, a server that never answers (headersDelay), the trickle, a stopped server with the phone online and offline, and cancellation passing through. The repository tests use a fake network to check the policy: two 503s then success (three calls, and testScheduler.currentTime is exactly 3,000 ms of virtual waiting), a 404 tried once, three Unreachables reported after three tries. ./gradlew testDebugUnitTest runs 117.
Check your work
Why a 500 crashed module 8's build. HttpException is not an IOException, and the ViewModel caught only IOException.
Why the trace had no Mandi Bhav line. The exception was created on an OkHttp thread and crossed into the coroutine; the stack is where it was made.
Why the timeout branch comes before IOException. SocketTimeoutException is an IOException; caught later, every timeout would say "no connection".
What a call timeout adds. A limit on the whole request; the read timeout alone would have let the trickle run for about 14 seconds.
Why timeouts are not retried at once. On a slow link the retry probably times out too, costing time and data.
What the first launch shows on a slow connection. A spinner and "Downloading today's prices…", then after 5 seconds a sentence saying it is slow and worth waiting for.
Practice 4. With the server stopped, "Could not reach the prices server. Try again in a minute.", after three tries a second and two seconds apart; in airplane mode, "No connection. Showing the prices saved on this phone.", after one try. The exception was the same ConnectException; NetworkMonitor.isOnlineNow() was not.
Practice
- Add
RefreshFailure, the translatingrequest, the timeouts, the retry, the empty-state composable and the snackbar's "Retry". Run the 117 tests. - Reproduce the crash: catch only
IOExceptionin theViewModel, letHttpExceptionthrough the data source, serve a 500 and refresh. Then put both back. - Swap the order of the
InterruptedIOExceptionandIOExceptionbranches and runaServerThatNeverAnswersTimesOut. Read the failure. - On the emulator, refresh once with the local server stopped and once in airplane mode. Predict the two messages and the number of requests in logcat before you look.
- Write a MockWebServer test for a 429 followed by a 200, and one for a 200 with an empty body. Decide what each should be called.
- Harder: Retrofit can return
Response<NetworkPrices>instead of throwing on an HTTP error. RewritefetchLatestPricesthat way and compare: which failures still throw, and which version reads better to someone new to the code?
Official documentation
- OkHttp: calls — What a call is, retries and redirects, and cancelling.
- OkHttp recipes — Timeouts, per-call configuration, and the other settings used here.
- MockWebServer — Queuing responses, delays, throttling and recorded requests.
- UI events — Errors from the data layer becoming UI state, and the user message pattern.
- Progress indicators — Material 3 on when to show a spinner or a bar.
Next: pictures on the price cards, and what one wrong image size costs on mobile data and in memory.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship