Android – Error Handling That Users Can Act On

September 7, 20268 min readUpdated 10/11/2026

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 of Api on 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.