๐Ÿ”Œ APIs, Databases, and Backend Integration

Handling API Errors and Network Failures

Users on trains, lifts and patchy 4G will hit every failure path your app has. Good error handling turns those moments into clear messages and automatic recovery instead of spinners that never stop. Here's a complete, layered approach.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. An error taxonomy
  2. Typed errors at the data boundary
  3. Applying it in the repository
  4. Mapping errors to UI
  5. Observing connectivity
  6. Retry policy
  7. UX patterns
  8. Monitoring
  9. Frequently asked questions
  10. Conclusion

"Something went wrong" is the least helpful message an app can show. Was the user offline? Did their session expire? Is the server down? Did they enter invalid data? Each case needs a different message and a different recovery. The fix is to classify errors at the boundary and carry that meaning up to the UI.

An error taxonomy

Classifying API errors and their recovery Connectivity errors: offline or timeout, retry when online. Server errors 5xx: retry later with backoff. Auth errors 401 or 403: refresh token or sign in again. Client and validation errors 4xx: show field-specific messages. Parsing errors: log and show generic error, fix in code. Connectivityoffline, timeoutโ†’ retry whenonline Server 5xxoutage, bugโ†’ backoff retry,"try later" Auth 401/403expired sessionโ†’ refresh orsign in again Client 4xxvalidation, 404โ†’ specificmessage, no retry Parsingcontract mismatchโ†’ log, genericerror, fix code
Different causes need different recoveries. Classifying early makes the UI's job simple.

Typed errors at the data boundary

AppError.ktKotlin
sealed interface AppError {
    data object Offline : AppError
    data object Timeout : AppError
    data object Unauthorized : AppError
    data object NotFound : AppError
    data class Validation(val fieldErrors: Map<String, String>) : AppError
    data class Server(val code: Int) : AppError
    data class Unexpected(val cause: Throwable) : AppError
}

class AppException(val error: AppError) : Exception(error.toString())

@Serializable data class ApiErrorBody(val errors: Map<String, String> = emptyMap())

fun Throwable.toAppError(json: Json): AppError = when (this) {
    is AppException -> error
    is SocketTimeoutException -> AppError.Timeout
    is IOException -> AppError.Offline                    // includes UnknownHostException
    is HttpException -> when (code()) {
        401, 403 -> AppError.Unauthorized
        404 -> AppError.NotFound
        422 -> AppError.Validation(
            response()?.errorBody()?.string()
                ?.let { runCatching { json.decodeFromString<ApiErrorBody>(it).errors }.getOrNull() }
                .orEmpty()
        )
        in 500..599 -> AppError.Server(code())
        else -> AppError.Unexpected(this)
    }
    else -> AppError.Unexpected(this)
}

Note that SocketTimeoutException is a subclass of IOException, so it must be checked first.

Applying it in the repository

ProfileRepository.ktKotlin
class ProfileRepository(private val api: ProfileApi, private val json: Json) {
    suspend fun updateProfile(input: ProfileInput): Profile =
        try {
            api.update(input.toDto()).toDomain()
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            throw AppException(e.toAppError(json))      // callers see only AppError
        }
}

Mapping errors to UI

Error messages (UI layer)Kotlin
@StringRes
fun AppError.messageRes(): Int = when (this) {
    AppError.Offline -> R.string.error_offline            // "You're offline. We'll retry when you're back."
    AppError.Timeout -> R.string.error_timeout            // "This is taking longer than usual."
    AppError.Unauthorized -> R.string.error_session       // "Please sign in again."
    AppError.NotFound -> R.string.error_not_found
    is AppError.Validation -> R.string.error_check_fields
    is AppError.Server -> R.string.error_server           // "Our servers are having trouble."
    is AppError.Unexpected -> R.string.error_generic
}

Validation errors map to field-level messages in form state. Unauthorized triggers a sign-out flow. Offline shows an inline banner while cached content stays visible.

Observing connectivity

ConnectivityObserver.ktKotlin
fun Context.observeOnline(): Flow<Boolean> = callbackFlow {
    val cm = getSystemService(ConnectivityManager::class.java)
    val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { trySend(true) }
        override fun onLost(network: Network) { trySend(cm.activeNetwork != null) }
    }
    trySend(cm.activeNetwork != null)
    cm.registerDefaultNetworkCallback(callback)
    awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()

Use this to show an offline banner and trigger retries when connectivity returns. A network can still be unusable (captive portals, server outages), so always handle request failures too.

Retry policy

  • Retry automatically: I/O errors and 5xx on idempotent requests (GET, PUT with the same body), with exponential backoff and jitter, limited attempts.
  • Respect 429 (Too Many Requests) and any Retry-After header.
  • Never auto-retry validation errors or non-idempotent POSTs without idempotency keys.
  • Queue writes that must eventually succeed with WorkManager.

UX patterns

List screen offline

Keep showing cached items with an "Offline: showing saved data" banner, and refresh automatically when back online.

Form submission fails

Keep the user's input, highlight invalid fields, and offer retry. Never clear the form on error.

Session expired

Try a silent token refresh. If that fails, explain and take the user to sign-in, returning them afterwards.

Monitoring

Log unexpected and parsing errors to your crash or analytics tool (without personal data), and track error rates per endpoint. Spikes after a release often reveal API contract changes.

Frequently asked questions

Should repositories return Result instead of throwing?

Both are valid. Throwing a typed AppException keeps signatures simple, and Result or sealed types make failures explicit. Choose one convention for the codebase.

Is checking connectivity before each request useful?

Not as a gate: networks can report "available" and still fail. Use connectivity for UX hints and retries, and always handle actual request errors.

How do I test error handling?

Use MockWebServer to return error codes and malformed bodies, and fakes that throw IOException, then assert on the resulting UI state.

Conclusion

Classify failures at the data boundary into typed errors, map them to specific messages and recoveries in the UI, retry only what's safe, keep cached content visible offline, and monitor what slips through.

Next step: store data locally with the Room Database Tutorial.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Networking, persistence and offline sync are where real apps get complicated. Practise building a data layer that handles slow networks, failures and caching, not just the happy path.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp