A network call on a phone fails constantly: in a lift, on a train, when the backend is redeploying, when the customer's session expired overnight. An app that handles errors well is not one where errors are rare. It is one where every failure turns into something the customer can act on — retry, sign in again, fix a field — and where the failures that need no action are not shown at all.
This lesson builds that in three layers: one error type whose cases are decisions, one place that produces it, and two small types that carry it to the screen.
Errors as decisions
Retrofit throws HttpException. OkHttp throws IOException subclasses.
The serialiser throws SerializationException. Letting those leak into the UI means every
screen re-learns three libraries' exception hierarchies. Instead, everything is mapped onto one
type:
sealed class ApiError(override val message: String) : Exception(message) {
class Api(val status: Int, message: String, val body: ApiErrorBody?) : ApiError(message)
data object Unauthorized : ApiError("Your session has expired. Please sign in again.")
class Network(message: String) : ApiError(message)
class Decoding(message: String) : ApiError(message)
The cases are not "kinds of exception". They are what a caller should do:
Api— the server answered and said no. Show its message, and render any field-level errors next to the fields.Unauthorized— split out ofApion purpose. A 401 does not mean "show this message", it means "sign in again", and a separate case means no screen can handle it by accident as a generic error.Network— the request never arrived. On a phone that is usually a lost signal, and the copy should say so.Decoding— we reached the server and could not read its answer. That is our bug, not the customer's connection, and it is logged as one.
Because the class is sealed, the properties that summarise it are exhaustive whens.
The most useful one decides whether a "Try again" button should exist at all:
val isRetryable: Boolean
get() = when (this) {
is Network -> true
is Api -> status >= 500
Unauthorized, is Decoding -> false
}
A 400 will fail again with exactly the same body, so offering to retry it is a lie. A dropped connection or a 503 might well succeed in a second.
One place that maps
Every repository call goes through ApiCaller, which runs the call and translates
whatever it throws:
suspend operator fun <T> invoke(block: suspend () -> T): T = try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
throw fromHttp(e)
} catch (e: SerializationException) {
Log.e(TAG, "Could not decode a response", e)
throw ApiError.Decoding("The server returned a response this app could not read.")
} catch (e: IOException) {
Log.w(TAG, "Transport failure: ${e.javaClass.simpleName}")
throw ApiError.Network(transportMessage(e))
}
operator fun invoke lets the instance be called like a function, so a repository reads
almost like the bare Retrofit call:
class RemoteCatalogRepository @Inject constructor(
private val api: PizzaApi,
private val call: ApiCaller,
) : CatalogRepository {
override suspend fun products() = call { api.products() }
override suspend fun toppings() = call { api.toppings() }
override suspend fun crusts() = call { api.crusts() }
}
Look at the order of the catch blocks, because it is the most important line in the
file. CancellationException is caught first and rethrown untouched.
The wrong way: swallowing cancellation
Coroutines are cancelled by throwing CancellationException at their next suspension
point. When a screen leaves, its viewModelScope is cancelled, and every request in flight
receives one. It is an Exception, so a broad catch catches it:
// ✗ the coroutine was told to stop — and carries on, showing an error for a screen that is gone
try {
_state.value = UiState.Loaded(repository.products())
} catch (e: Exception) {
_state.value = UiState.failure(e)
}
Two bugs at once. The coroutine keeps running after it was cancelled, because the signal was
swallowed. And it reports the cancellation as a failure — to a customer who simply navigated away. The
fix is a rule applied everywhere in this app: catch CancellationException first and
rethrow it, then handle the rest.
} catch (e: CancellationException) {
// Superseded by a newer load — not a failure, and the newer load owns the state now.
throw e
} catch (e: Exception) {
_state.value = UiState.failure(e, fallback = "Could not load the menu.")
}
The same trap hides in runCatching { }, which catches Throwable —
cancellation included. It is fine around code that cannot suspend; around a suspending call it has
the same problem as the broad catch.
When the error body is not JSON
private fun fromHttp(e: HttpException): ApiError {
val status = e.code()
if (status == 401) return ApiError.Unauthorized
val raw = e.response()?.errorBody()?.string()
val body = raw?.let { runCatching { json.decodeFromString<ApiErrorBody>(it) }.getOrNull() }
?: return ApiError.Api(status, "Request failed with $status.", null)
return ApiError.Api(status, body.message, body)
}
A proxy, a load balancer or a crashed server answers with an HTML error page. Parsing that as JSON would throw a parse error over the top of the real problem, and the customer would be told "Unexpected token <". So a body that cannot be read falls back to the status code, and a body that can be read contributes its message and its field errors.
Messages a person can use
private fun transportMessage(e: IOException): String = when (e) {
is SocketTimeoutException -> "The server took too long to respond. Check your connection."
is UnknownHostException -> "You appear to be offline. Check your connection and try again."
else -> "Could not reach the server at ${config.baseUrl}. Is the backend running?"
}
OkHttp's own message — "failed to connect to /10.0.2.2 (port 8085) after 15000ms" — is useless to a customer and only half useful to a developer. The mapped version names the likely cause. The fallback includes the URL it tried, which turns the most common development failure — the backend is not running — into a self-answering question.
Every branch of this mapping is pinned down by a test against a real local HTTP server: a 401 must
become Unauthorized, an HTML 502 must produce "Request failed with 502." and be
retryable, a malformed success body must be a decoding error rather than a network one, and a
cancelled call must arrive as a cancellation. Error handling that is not tested tends to be discovered
by customers; the testing lesson shows the suite.
Errors on screen: UiState
A screen that loads something is in one of five states, and the error is one of them — not a
separate error property that can be set at the same time as isLoading:
fun failure(error: Throwable, fallback: String = "Something went wrong."): Failed =
Failed(
message = ErrorPresenter.message(error, fallback),
isRetryable = (error as? ApiError)?.isRetryable ?: true,
)
UiState.failure carries the message and whether retrying makes sense, read off
the ApiError. An unknown exception is assumed retryable, because "try again" is a better
default than a dead end. The screen renders it with the shared component, which shows the retry button
only when it could help:
is UiState.Failed -> ErrorState(state.message, state.isRetryable, onRetry, modifier)
Because UiState is sealed and the screen's when has no else,
a screen that forgot the failure branch would not compile. The eternal spinner — the most common error
"handling" in mobile apps — cannot be written.
Errors from writes: ActionOutcome
Loading has a screen to show its error on. A write — delete an address, save a card — does not; it reports with a toast. Its result is a small sealed type of its own:
sealed interface ActionOutcome {
data class Succeeded(override val message: String) : ActionOutcome
data class Failed(override val message: String) : ActionOutcome
/**
* The customer backed out — of a payment sheet, of a confirmation. Not a success and not an
* error: nothing should be shown at all. Modelling it as `Failed("")` that every caller
* remembers to special-case is how an empty toast eventually reaches production.
*/
data object Cancelled : ActionOutcome
/** What to show, if anything. Overridden as non-null by the two cases that carry one. */
val message: String?
get() = null
}
Why not Kotlin's Result? Because Result carries a value or a
Throwable, and both cases here carry the same thing: a sentence. And because of the third
case. A customer who opens the card sheet and backs out has neither succeeded nor failed — nothing
should be shown at all. Modelling that as Failed(""), which every caller must remember to
special-case, is how an empty red toast eventually reaches production.
The profile's writes all share one shape — do it, reload quietly, report — written once:
private fun perform(success: String, failure: String, work: suspend () -> Unit) {
viewModelScope.launch {
val outcome = try {
work()
load(showsLoadingState = false)
ActionOutcome.Succeeded(success)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
ActionOutcome.Failed(ErrorPresenter.message(e, failure))
}
toasts.show(outcome)
}
}
fun ToastCenter.show(outcome: ActionOutcome) {
when (outcome) {
is ActionOutcome.Succeeded -> show(outcome.message, Toast.Style.SUCCESS)
is ActionOutcome.Failed -> show(outcome.message, Toast.Style.DANGER)
ActionOutcome.Cancelled -> Unit
}
}
The extension function maps each case to a style, and Cancelled to nothing. Callers
never decide colours.
Failures you should not show
Not every failure deserves a message. The cart is saved to the server, debounced, after every change. If a save fails, the cart still works for this session; it just will not survive a relaunch. A red toast on every tap while the signal is weak would be worse than the failure, so it is logged and nothing else:
private suspend fun persist() {
try {
val identifier = cartId ?: run {
if (_state.value.isEmpty) return
repository.createCart().id.also {
cartId = it
identifierStore.save(it)
}
}
val current = _state.value
repository.replaceCart(
identifier,
CartWriteRequest(current.orderType, current.items.map { it.toLineItemRequest() }),
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Could not persist the cart: ${e.message}")
}
}
Likewise, checkout loads the customer's saved addresses; if that fails, checkout falls back to a typed address without a word. Before surfacing an error, ask whether the customer can do anything about it. If not, log it.
And Unauthorized during launch is handled by doing the obvious thing silently: the
stored token is invalid, so it is discarded and the app starts signed out. Telling a customer "your
session has expired" on launch, before they have tried to do anything, is noise.
Next
Cancellation keeps coming up. The next lesson is coroutines and Flow properly: scopes and who owns
them, async for parallel requests, cancellation as a debounce, a polling loop that stops
itself, and StateFlow from store to screen.