Robust Retrofit Error Handling with Sealed Results

AndroidKotlinBackend
Share on LinkedIn

The default way people call Retrofit — invoke the suspend function, wrap the whole thing in one try/catch (e: Exception), show a generic "Something went wrong" — throws away almost all the information the network layer just handed you. Robust Retrofit error handling means treating the three fundamentally different failure classes as different things: a transport failure (no connectivity, timeout), an HTTP failure (the server answered with 4xx/5xx), and a deserialization failure (the body didn't match your model). Each demands a different response, and the only way to enforce that is to make failure a typed value the compiler won't let you ignore.

I model outcomes as a sealed type and route every call through one converter. Here's how it holds up in production.

Model the outcome, don't throw it around

sealed interface ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>
    data class HttpError(val code: Int, val body: String?) : ApiResult<Nothing>
    data class NetworkError(val cause: IOException) : ApiResult<Nothing>
    data class UnknownError(val cause: Throwable) : ApiResult<Nothing>
}

Now a call site is forced to reckon with each branch:

when (val result = repo.loadProfile()) {
    is ApiResult.Success -> render(result.data)
    is ApiResult.HttpError -> when (result.code) {
        401 -> logout()
        404 -> showEmpty()
        in 500..599 -> showRetry()
        else -> showGeneric()
    }
    is ApiResult.NetworkError -> showOfflineBanner()
    is ApiResult.UnknownError -> reportBug(result.cause)
}

That when is exhaustive, so if I add a new variant later, every call site fails to compile until I handle it. That compiler pressure is the entire point — it's the opposite of a catch-all that silently degrades.

One place that classifies exceptions

The simplest way to produce that type is a runCatching-style helper that knows how to bucket exceptions:

suspend fun <T> apiCall(block: suspend () -> T): ApiResult<T> =
    try {
        ApiResult.Success(block())
    } catch (e: HttpException) {
        ApiResult.HttpError(e.code(), e.response()?.errorBody()?.string())
    } catch (e: IOException) {
        ApiResult.NetworkError(e)            // no connectivity, timeout, DNS
    } catch (e: Throwable) {
        ApiResult.UnknownError(e)            // parsing, unexpected
    }

Note the catch order: HttpException and IOException first, Throwable last. IOException is where connectivity, DNS (UnknownHostException), and timeouts (SocketTimeoutException) land — the retryable class. Serialization failures from Moshi/kotlinx.serialization fall through to UnknownError, which is correct: a body that won't parse is almost always a bug (backend contract drift), not something the user can fix by retrying. Reporting it as such is how you catch API contract breaks before your users do.

Parse the error body, don't just show the code

A 422 with a JSON body explaining which field failed is far more useful than "Error 422." Give yourself a helper to decode the error envelope your backend uses:

data class ApiErrorBody(val message: String, val field: String?)

fun ApiResult.HttpError.decoded(moshi: Moshi): ApiErrorBody? =
    body?.let {
        runCatching {
            moshi.adapter(ApiErrorBody::class.java).fromJson(it)
        }.getOrNull()
    }

I wrap the decode in runCatching because error bodies are exactly where servers get sloppy — an HTML 502 page from a load balancer where you expected JSON will otherwise throw a second exception while you're handling the first.

Scaling up: a custom CallAdapter

Wrapping every call in apiCall { } is fine for a small app but gets repetitive. For a larger codebase I use a CallAdapter.Factory so endpoints can just declare suspend fun loadProfile(): ApiResult<Profile> and the adapter does the classification centrally. It's more setup, but it guarantees no endpoint slips through with ad-hoc handling, and it composes cleanly with the OkHttp interceptor and Authenticator layer underneath — the Authenticator handles 401 refresh transparently, so by the time a HttpError(401) reaches your ApiResult, refresh has genuinely failed.

Timeouts and cancellation are not errors

A distinction I insist on with teams: when a user navigates away and the coroutine scope cancels, Retrofit throws CancellationException. Do not map that into your error type or report it — it's normal lifecycle behavior. runCatching and broad catch (Throwable) will swallow CancellationException, which breaks structured concurrency. Rethrow it explicitly:

} catch (e: CancellationException) {
    throw e            // let cancellation propagate
} catch (e: Throwable) {
    ApiResult.UnknownError(e)
}

Miss this and you'll see phantom "unknown error" toasts firing every time someone hits back at the wrong moment.

What good handling changes in practice

None of this requires a heavy framework. It requires refusing to collapse three different failures into one Exception, and putting the classification in exactly one place the compiler makes you respect.

Common production mistakes

Teams get retrofit error handling wrong in predictable ways:

Shipping retrofit error handling on Android fails quietly when you test only on flagship devices, skip process-death scenarios, or assume minSdk behavior matches latest API docs. Emulator-only validation misses OEM-specific battery optimizations and background execution limits.

Resources

Frequently asked questions

How should I handle errors from Retrofit suspend functions?

Wrap the call so that HTTP failures, IO/network failures, and serialization failures all surface as distinct, typed outcomes rather than exceptions you might forget to catch. A common approach is a sealed Result type produced by runCatching or a custom CallAdapter, so the compiler forces callers to handle the failure branch. Directly calling a suspend endpoint and only catching a generic Exception tends to swallow the difference between a 404, no connectivity, and a malformed body.

What exceptions does Retrofit throw?

For suspend functions, a non-2xx response throws HttpException carrying the status code and error body, connectivity or socket problems throw IOException subclasses like UnknownHostException and SocketTimeoutException, and a body that fails to parse throws a serialization exception from your converter such as JsonDataException for Moshi. Distinguishing these matters because the user-facing response differs: retry for IO, show a message for HTTP, and report parsing errors as bugs.

Should I use a custom CallAdapter or just wrap calls manually?

For a small app, wrapping each call in a runCatching helper that maps exceptions to your Result type is simple and explicit. For a larger codebase, a custom CallAdapter that makes every endpoint return your Result type removes the per-call boilerplate and guarantees consistency, at the cost of some setup. Both are valid; the important thing is that every call goes through one place that classifies errors.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →