On this page
"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
Typed errors at the data boundary
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
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
@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
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-Afterheader. - 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.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
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.