Opening the problem
Your app just crashed when the network went down, and users had no idea why. Or worse, they saw a generic error message, tapped a button that did nothing, and left to use a competitor's app. Handling API errors gracefully: best practices explained is about ensuring your users stay in control when things go wrong. In production Android apps, network failures, timeouts, and server errors are not edge cases—they're the norm. We need strategies to catch them early, communicate clearly, and let users recover without frustration.
Map exceptions to UI states
The foundation of graceful error handling is mapping domain exceptions to meaningful UI states. Rather than letting raw network exceptions bubble up to your UI layer, we create a sealed class that represents all possible outcomes of an API call. This approach is clean, type-safe, and makes testing trivial.
Consider a typical result wrapper:
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val exception: ApiException) : ApiResult<Nothing>()
object Loading : ApiResult<Nothing>()
}
sealed class ApiException(override val message: String) : Exception(message) {
data class NetworkError(val throwable: Throwable) : ApiException("Network error: ${throwable.message}")
data class ServerError(val code: Int, val body: String) : ApiException("Server error: $code")
data class Timeout(val throwable: Throwable) : ApiException("Request timeout")
data class ParseError(val throwable: Throwable) : ApiException("Failed to parse response")
data class Unknown(val throwable: Throwable) : ApiException("Unknown error: ${throwable.message}")
}Your repository layer catches raw exceptions and converts them into this sealed hierarchy. This is where we distinguish between transient failures (network, timeout) and permanent ones (malformed JSON, invalid credentials):
class UserRepository(private val api: UserApi) {
suspend fun getUser(id: String): ApiResult<User> = try {
val response = api.fetchUser(id)
ApiResult.Success(response)
} catch (e: SocketTimeoutException) {
ApiResult.Error(ApiException.Timeout(e))
} catch (e: IOException) {
ApiResult.Error(ApiException.NetworkError(e))
} catch (e: HttpException) {
ApiResult.Error(ApiException.ServerError(e.code(), e.response()?.string() ?: ""))
} catch (e: JsonDataException) {
ApiResult.Error(ApiException.ParseError(e))
} catch (e: Exception) {
ApiResult.Error(ApiException.Unknown(e))
}
}Your UI layer then maps these exceptions to observable states that drive the composable or view:
sealed class UserScreenState {
object Loading : UserScreenState()
data class Success(val user: User) : UserScreenState()
data class Error(val message: String, val isRetryable: Boolean) : UserScreenState()
}
class UserViewModel(private val repo: UserRepository) : ViewModel() {
private val _state = MutableStateFlow<UserScreenState>(UserScreenState.Loading)
val state = _state.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
when (val result = repo.getUser(id)) {
is ApiResult.Success -> _state.value = UserScreenState.Success(result.data)
is ApiResult.Error -> _state.value = UserScreenState.Error(
message = result.exception.message ?: "Unknown error",
isRetryable = result.exception is ApiException.NetworkError ||
result.exception is ApiException.Timeout
)
is ApiResult.Loading -> _state.value = UserScreenState.Loading
}
}
}
}This layered approach keeps concerns separated: the repository handles network details, the ViewModel orchestrates retry logic, and the UI renders only what it needs to show. Each layer knows exactly what exceptions to expect and how to handle them.
Retry buttons for network failures
Network failures are often transient. A retry button with exponential backoff gives users agency while respecting backend resources. The key is distinguishing which errors deserve retry buttons and which don't.
Not all errors should be retried. A 401 Unauthorized will never succeed by retrying; a 500 Server Error might. A parse error means the API contract broke—no amount of retrying fixes that. But a timeout or connection refused? Absolutely worth retrying.
Here's a practical retry handler:
class RetryManager {
suspend fun <T> retryWithBackoff(
maxAttempts: Int = 3,
initialDelayMs: Long = 1000,
block: suspend () -> ApiResult<T>
): ApiResult<T> {
var delay = initialDelayMs
repeat(maxAttempts) { attempt ->
val result = block()
if (result is ApiResult.Success) return result
if (result is ApiResult.Error) {
val isRetryable = result.exception is ApiException.NetworkError ||
result.exception is ApiException.Timeout
if (!isRetryable || attempt == maxAttempts - 1) return result
}
delay(delay)
delay *= 2 // exponential backoff
}
return block() // final attempt
}
}
class UserViewModel(
private val repo: UserRepository,
private val retryManager: RetryManager
) : ViewModel() {
fun retryLoadUser(id: String) {
viewModelScope.launch {
_state.value = UserScreenState.Loading
val result = retryManager.retryWithBackoff {
repo.getUser(id)
}
when (result) {
is ApiResult.Success -> _state.value = UserScreenState.Success(result.data)
is ApiResult.Error -> _state.value = UserScreenState.Error(
message = result.exception.message ?: "Failed after retries",
isRetryable = result.exception is ApiException.NetworkError
)
is ApiResult.Loading -> {} // handled by initial state assignment
}
}
}
}On the UI side, show the retry button only when appropriate:
@Composable
fun UserScreen(viewModel: UserViewModel) {
val state by viewModel.state.collectAsState()
when (val current = state) {
is UserScreenState.Loading -> {
CircularProgressIndicator()
}
is UserScreenState.Success -> {
UserCard(current.user)
}
is UserScreenState.Error -> {
Column {
Text(current.message)
if (current.isRetryable) {
Button(onClick = { viewModel.retryLoadUser(userId) }) {
Text("Retry")
}
}
}
}
}
}This pattern respects the user's time and bandwidth while providing a clear path forward when networks are flaky.
Offline messaging
Detecting offline state separately from server errors improves clarity. When a device has no connectivity, we want to show "Check your internet connection" rather than a generic error. We can use Jetpack's Connectivity API to monitor this in real time:
class ConnectivityMonitor(context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _isOnline = MutableStateFlow(isCurrentlyOnline())
val isOnline = _isOnline.asStateFlow()
init {
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = updateState()
override fun onLost(network: Network) = updateState()
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
}
private fun updateState() {
_isOnline.value = isCurrentlyOnline()
}
private fun isCurrentlyOnline(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}Integrate this into your repository so it can distinguish between "no internet" and "server error":
class UserRepository(
private val api: UserApi,
private val connectivity: ConnectivityMonitor
) {
suspend fun getUser(id: String): ApiResult<User> = try {
if (!connectivity.isOnline.value) {
return ApiResult.Error(ApiException.NetworkError(IOException("Device offline")))
}
val response = api.fetchUser(id)
ApiResult.Success(response)
} catch (e: IOException) {
ApiResult.Error(ApiException.NetworkError(e))
} catch (e: Exception) {
ApiResult.Error(ApiException.Unknown(e))
}
}Now your UI can display context-specific messaging:
@Composable
fun UserScreen(viewModel: UserViewModel) {
val connectivity by connectivityMonitor.isOnline.collectAsState()
val state by viewModel.state.collectAsState()
if (!connectivity) {
Text(
"You're offline. Connect to the internet to load user data.",
color = Color.Red
)
} else when (state) {
// ... regular error handling
}
}This gives users immediate, actionable feedback without ambiguity.
Log errors for debugging
Graceful UI error handling is only half the battle. We need comprehensive logging to catch production issues before users file complaints. Log structured data that engineers can actually use:
class ErrorLogger {
fun logApiError(
endpoint: String,
exception: ApiException,
userId: String? = null,
requestBody: String? = null
) {
val logData = mapOf(
"type" to exception::class.simpleName,
"message" to exception.message,
"endpoint" to endpoint,
"userId" to userId,
"timestamp" to System.currentTimeMillis(),
"requestBody" to requestBody,
"deviceInfo" to "${Build.MANUFACTURER} ${Build.MODEL}",
"appVersion" to BuildConfig.VERSION_NAME
)
// Send to crash analytics (Crashlytics, Sentry, etc.)
Firebase.crashlytics.recordException(
Exception("${exception::class.simpleName}: ${exception.message}")
)
// Send to structured logging backend
logStructured(logData)
}
private fun logStructured(data: Map<String, Any?>) {
// Use Timber, or send to backend logging service
Log.e("ApiError", data.toString())
}
}Inject this into your repository:
class UserRepository(
private val api: UserApi,
private val logger: ErrorLogger
) {
suspend fun getUser(id: String): ApiResult<User> = try {
val response = api.fetchUser(id)
ApiResult.Success(response)
} catch (e: Exception) {
val apiException = when (e) {
is SocketTimeoutException -> ApiException.Timeout(e)
is IOException -> ApiException.NetworkError(e)
else -> ApiException.Unknown(e)
}
logger.logApiError(
endpoint = "GET /users/$id",
exception = apiException,
userId = id
)
ApiResult.Error(apiException)
}
}Over time, this structured data reveals patterns: which endpoints fail most often, which device types are affected, whether retries actually help. Use these insights to improve stability before users feel the pain.
Common mistakes
- Swallowing exceptions silently: Never catch an exception and do nothing. At minimum, log it. Better: log and emit a UI state.
- Retrying non-transient errors: Retrying a 401 or 403 wastes battery and data. Check the exception type before retrying.
- Showing raw exception names to users: "JsonDataException" means nothing to end users. Map technical errors to friendly messages like "We couldn't load your profile. Try again in a moment."
- Ignoring offline state: Offline errors are common on mobile. Detecting connectivity separately prevents confusing users with server error messages when the real issue is their connection.
- Logging sensitive data: Never log passwords, tokens, or PII. Sanitize request/response bodies before logging.
Key takeaways
- Use sealed classes to map raw exceptions into a hierarchy of domain-specific errors. This keeps your code type-safe and testable.
- Distinguish between transient errors (network, timeout) and permanent ones (parse errors, invalid credentials). Only retry transient failures with exponential backoff.
- Monitor device connectivity separately to show context-specific offline messaging rather than generic server errors.
- Log structured error data (endpoint, exception type, device info, app version) to catch production issues early and identify patterns over time.
- Always show users a clear, actionable message and a retry button only when retrying makes sense. Never silently fail or show raw exception names.






Comments
Loading comments…