Comprehensive guide to handling HTTP status codes, network errors, and API failures in Android apps with proper user feedback and recovery strategies
Proper HTTP status code handling is crucial for creating robust Android applications that gracefully manage network failures, server errors, and API issues. Poor error handling leads to crashes, confusing user experiences, and frustrated users who don't understand what went wrong.
Figure: HTTP status code error handling flow in Android applications showing proper error categorization and user feedback.
1xx Informational Responses
2xx Success Responses
200 OK
- Request succeeded201 Created
- Resource created successfully204 No Content
- Success with no response body3xx Redirection
301 Moved Permanently
- Resource permanently moved302 Found
- Temporary redirect4xx Client Errors (Most Important for Mobile Apps)
400 Bad Request
- Invalid request syntax401 Unauthorized
- Authentication required403 Forbidden
- Server refuses to authorize404 Not Found
- Resource doesn't exist409 Conflict
- Request conflicts with server state422 Unprocessable Entity
- Validation errors429 Too Many Requests
- Rate limiting5xx Server Errors
500 Internal Server Error
- Generic server error502 Bad Gateway
- Invalid response from upstream503 Service Unavailable
- Server temporarily unavailable504 Gateway Timeout
- Upstream server timeoutclass ApiService {
suspend fun fetchUserData(userId: String): Result<User> {
return try {
val response = apiInterface.getUser(userId)
if (response.isSuccessful) {
response.body()?.let { user ->
Result.success(user)
} ?: Result.failure(Exception("Empty response body"))
} else {
handleHttpError(response.code(), response.errorBody()?.string())
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun handleHttpError(code: Int, errorBody: String?): Result<Nothing> {
val error = when (code) {
400 -> BadRequestException("Invalid request: $errorBody")
401 -> UnauthorizedException("Authentication required")
403 -> ForbiddenException("Access denied")
404 -> NotFoundException("Resource not found")
409 -> ConflictException("Request conflicts with current state")
422 -> ValidationException("Validation failed: $errorBody")
429 -> RateLimitException("Too many requests")
in 500..599 -> ServerException("Server error (HTTP $code)")
else -> UnknownHttpException("HTTP error: $code")
}
return Result.failure(error)
}
}
sealed class ApiException(message: String) : Exception(message) {
// Client errors (4xx)
class BadRequestException(message: String) : ApiException(message)
class UnauthorizedException(message: String) : ApiException(message)
class ForbiddenException(message: String) : ApiException(message)
class NotFoundException(message: String) : ApiException(message)
class ConflictException(message: String) : ApiException(message)
class ValidationException(message: String) : ApiException(message)
class RateLimitException(message: String) : ApiException(message)
// Server errors (5xx)
class ServerException(message: String) : ApiException(message)
// Network errors
class NetworkException(message: String) : ApiException(message)
class TimeoutException(message: String) : ApiException(message)
// Unknown errors
class UnknownHttpException(message: String) : ApiException(message)
}
class UserRepository(
private val apiService: ApiService,
private val localDataSource: UserLocalDataSource
) {
suspend fun getUser(userId: String, forceRefresh: Boolean = false): Result<User> {
// Try local cache first if not forcing refresh
if (!forceRefresh) {
localDataSource.getUser(userId)?.let { cachedUser ->
if (cachedUser.isValid()) {
return Result.success(cachedUser)
}
}
}
return when (val apiResult = apiService.fetchUserData(userId)) {
is Result.success -> {
// Cache successful response
localDataSource.saveUser(apiResult.getOrNull()!!)
apiResult
}
is Result.failure -> {
val exception = apiResult.exceptionOrNull()
when (exception) {
is ApiException.NetworkException,
is ApiException.TimeoutException,
is ApiException.ServerException -> {
// Fallback to cached data for network/server errors
localDataSource.getUser(userId)?.let { cachedUser ->
Result.success(cachedUser)
} ?: apiResult
}
is ApiException.UnauthorizedException -> {
// Clear cached credentials and trigger re-auth
authManager.clearCredentials()
apiResult
}
else -> apiResult
}
}
}
}
}
class RetryInterceptor : Interceptor {
companion object {
private const val MAX_RETRIES = 3
private val RETRYABLE_STATUS_CODES = setOf(408, 429, 500, 502, 503, 504)
}
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
var response = chain.proceed(request)
var retryCount = 0
while (!response.isSuccessful &&
RETRYABLE_STATUS_CODES.contains(response.code) &&
retryCount < MAX_RETRIES) {
response.close()
retryCount++
// Exponential backoff: 1s, 2s, 4s
val delay = (1000 * Math.pow(2.0, (retryCount - 1).toDouble())).toLong()
Thread.sleep(delay)
// Add retry headers
request = request.newBuilder()
.addHeader("X-Retry-Count", retryCount.toString())
.build()
response = chain.proceed(request)
}
return response
}
}
class CircuitBreaker(
private val failureThreshold: Int = 5,
private val timeoutMillis: Long = 60000L
) {
private var failureCount = 0
private var lastFailureTime = 0L
private var state = State.CLOSED
enum class State { CLOSED, OPEN, HALF_OPEN }
suspend fun <T> execute(operation: suspend () -> T): T {
when (state) {
State.OPEN -> {
if (System.currentTimeMillis() - lastFailureTime > timeoutMillis) {
state = State.HALF_OPEN
} else {
throw CircuitBreakerOpenException("Circuit breaker is open")
}
}
State.HALF_OPEN -> {
try {
val result = operation()
reset()
return result
} catch (e: Exception) {
recordFailure()
throw e
}
}
State.CLOSED -> {
try {
return operation()
} catch (e: Exception) {
recordFailure()
throw e
}
}
}
return operation()
}
private fun recordFailure() {
failureCount++
lastFailureTime = System.currentTimeMillis()
if (failureCount >= failureThreshold) {
state = State.OPEN
}
}
private fun reset() {
failureCount = 0
state = State.CLOSED
}
}
data class ApiErrorResponse(
val error: ErrorDetail,
val timestamp: String,
val path: String
)
data class ErrorDetail(
val code: String,
val message: String,
val details: Map<String, Any>? = null,
val validationErrors: List<ValidationError>? = null
)
data class ValidationError(
val field: String,
val message: String,
val rejectedValue: Any?
)
class ErrorResponseParser {
private val gson = Gson()
fun parseError(errorBody: ResponseBody?): ApiErrorResponse? {
return try {
errorBody?.string()?.let { json ->
gson.fromJson(json, ApiErrorResponse::class.java)
}
} catch (e: Exception) {
null
}
}
fun createUserFriendlyMessage(
statusCode: Int,
errorResponse: ApiErrorResponse?
): String {
return when (statusCode) {
400 -> errorResponse?.error?.message ?: "Invalid request. Please check your input."
401 -> "Please log in again to continue."
403 -> "You don't have permission to perform this action."
404 -> "The requested information could not be found."
409 -> errorResponse?.error?.message ?: "This action conflicts with the current state."
422 -> {
val validationErrors = errorResponse?.error?.validationErrors
if (validationErrors?.isNotEmpty() == true) {
"Please fix the following errors:\n" +
validationErrors.joinToString("\n") { "• ${it.message}" }
} else {
"Please check your input and try again."
}
}
429 -> "Too many requests. Please wait a moment and try again."
in 500..599 -> "Server is experiencing issues. Please try again later."
else -> "An unexpected error occurred. Please try again."
}
}
}
sealed class UiState<T> {
class Loading<T> : UiState<T>()
data class Success<T>(val data: T) : UiState<T>()
data class Error<T>(
val exception: Exception,
val userMessage: String,
val canRetry: Boolean = true
) : UiState<T>()
}
class UserViewModel(
private val userRepository: UserRepository,
private val errorHandler: ErrorHandler
) : ViewModel() {
private val _uiState = MutableLiveData<UiState<User>>()
val uiState: LiveData<UiState<User>> = _uiState
fun loadUser(userId: String, forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.value = UiState.Loading()
userRepository.getUser(userId, forceRefresh)
.onSuccess { user ->
_uiState.value = UiState.Success(user)
}
.onFailure { exception ->
val errorState = errorHandler.handleError(exception)
_uiState.value = UiState.Error(
exception = exception,
userMessage = errorState.userMessage,
canRetry = errorState.canRetry
)
// Track error for analytics
Appxiom.trackError("user_load_failed", mapOf(
"user_id" to userId,
"error_type" to exception::class.simpleName,
"error_message" to exception.message,
"force_refresh" to forceRefresh
))
}
}
}
}
class ErrorDialogFragment : DialogFragment() {
companion object {
fun newInstance(
title: String,
message: String,
canRetry: Boolean = true,
onRetry: (() -> Unit)? = null
): ErrorDialogFragment {
return ErrorDialogFragment().apply {
arguments = Bundle().apply {
putString("title", title)
putString("message", message)
putBoolean("canRetry", canRetry)
}
this.onRetry = onRetry
}
}
}
private var onRetry: (() -> Unit)? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val title = arguments?.getString("title") ?: "Error"
val message = arguments?.getString("message") ?: "An error occurred"
val canRetry = arguments?.getBoolean("canRetry") ?: true
return AlertDialog.Builder(requireContext())
.setTitle(title)
.setMessage(message)
.setPositiveButton("OK") { _, _ -> dismiss() }
.apply {
if (canRetry && onRetry != null) {
setNeutralButton("Retry") { _, _ ->
onRetry?.invoke()
dismiss()
}
}
}
.create()
}
}
class ErrorHandler(private val context: Context) {
fun showError(
view: View,
exception: Exception,
onRetry: (() -> Unit)? = null
) {
val errorInfo = getErrorInfo(exception)
val snackbar = Snackbar.make(view, errorInfo.userMessage, Snackbar.LENGTH_LONG)
if (errorInfo.canRetry && onRetry != null) {
snackbar.setAction("Retry") { onRetry.invoke() }
}
// Style based on error severity
when (errorInfo.severity) {
ErrorSeverity.HIGH -> {
snackbar.setBackgroundTint(ContextCompat.getColor(context, R.color.error_high))
}
ErrorSeverity.MEDIUM -> {
snackbar.setBackgroundTint(ContextCompat.getColor(context, R.color.error_medium))
}
ErrorSeverity.LOW -> {
snackbar.setBackgroundTint(ContextCompat.getColor(context, R.color.error_low))
}
}
snackbar.show()
}
private fun getErrorInfo(exception: Exception): ErrorInfo {
return when (exception) {
is ApiException.NetworkException -> ErrorInfo(
userMessage = "No internet connection. Please check your network.",
canRetry = true,
severity = ErrorSeverity.HIGH
)
is ApiException.UnauthorizedException -> ErrorInfo(
userMessage = "Please log in again to continue.",
canRetry = false,
severity = ErrorSeverity.HIGH
)
is ApiException.ServerException -> ErrorInfo(
userMessage = "Server is experiencing issues. Please try again later.",
canRetry = true,
severity = ErrorSeverity.MEDIUM
)
is ApiException.ValidationException -> ErrorInfo(
userMessage = exception.message ?: "Please check your input.",
canRetry = false,
severity = ErrorSeverity.LOW
)
else -> ErrorInfo(
userMessage = "An unexpected error occurred.",
canRetry = true,
severity = ErrorSeverity.MEDIUM
)
}
}
}
data class ErrorInfo(
val userMessage: String,
val canRetry: Boolean,
val severity: ErrorSeverity
)
enum class ErrorSeverity { LOW, MEDIUM, HIGH }
@Test
fun `test 401 unauthorized response handling`() = runTest {
// Arrange
val mockResponse = mockk<Response<User>>()
every { mockResponse.isSuccessful } returns false
every { mockResponse.code() } returns 401
every { mockResponse.errorBody()?.string() } returns """
{
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid token"
}
}
""".trimIndent()
coEvery { apiInterface.getUser(any()) } returns mockResponse
// Act
val result = apiService.fetchUserData("123")
// Assert
assertTrue(result.isFailure)
val exception = result.exceptionOrNull()
assertTrue(exception is ApiException.UnauthorizedException)
assertEquals("Authentication required", exception.message)
}
@Test
fun `test network error fallback to cache`() = runTest {
// Arrange
val cachedUser = User("123", "John Doe")
coEvery { localDataSource.getUser("123") } returns cachedUser
coEvery { apiService.fetchUserData("123") } returns Result.failure(
ApiException.NetworkException("No internet connection")
)
// Act
val result = userRepository.getUser("123")
// Assert
assertTrue(result.isSuccess)
assertEquals(cachedUser, result.getOrNull())
}
@Test
fun `test rate limiting with retry`() {
// Arrange
mockWebServer.enqueue(MockResponse().setResponseCode(429).setBody("Rate limited"))
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("""{"id": "123", "name": "John"}"""))
// Act
val result = runBlocking { apiService.fetchUserData("123") }
// Assert
assertTrue(result.isSuccess)
assertEquals(2, mockWebServer.requestCount) // Original + 1 retry
}
class ApiErrorTracker {
fun trackHttpError(
endpoint: String,
statusCode: Int,
errorBody: String?,
userId: String? = null
) {
Appxiom.trackError("http_error", mapOf(
"endpoint" to endpoint,
"status_code" to statusCode,
"error_category" to getErrorCategory(statusCode),
"error_body" to (errorBody ?: ""),
"user_id" to (userId ?: "anonymous"),
"timestamp" to System.currentTimeMillis(),
"app_version" to BuildConfig.VERSION_NAME,
"device_info" to getDeviceInfo()
))
}
fun trackRetryAttempt(
endpoint: String,
attemptNumber: Int,
statusCode: Int
) {
Appxiom.trackEvent("http_retry", mapOf(
"endpoint" to endpoint,
"attempt_number" to attemptNumber,
"original_status_code" to statusCode,
"retry_strategy" to "exponential_backoff"
))
}
private fun getErrorCategory(statusCode: Int): String {
return when (statusCode) {
in 400..499 -> "client_error"
in 500..599 -> "server_error"
else -> "other"
}
}
}
class ErrorRateMonitor {
private val errorCounts = mutableMapOf<String, Int>()
private val totalRequests = mutableMapOf<String, Int>()
fun recordRequest(endpoint: String, isError: Boolean) {
totalRequests[endpoint] = totalRequests.getOrDefault(endpoint, 0) + 1
if (isError) {
errorCounts[endpoint] = errorCounts.getOrDefault(endpoint, 0) + 1
}
checkErrorRate(endpoint)
}
private fun checkErrorRate(endpoint: String) {
val errors = errorCounts.getOrDefault(endpoint, 0)
val total = totalRequests.getOrDefault(endpoint, 0)
if (total >= 10) { // Only check after minimum requests
val errorRate = errors.toDouble() / total.toDouble()
if (errorRate > 0.5) { // 50% error rate threshold
Appxiom.trackCriticalIssue("high_error_rate", mapOf(
"endpoint" to endpoint,
"error_rate" to errorRate,
"total_requests" to total,
"error_count" to errors
))
}
}
}
}
❌ Generic error messages: "Something went wrong"
❌ Ignoring error responses: Not parsing error details
❌ Blocking UI indefinitely: No timeout handling
❌ Endless retry loops: Without backoff or limits
❌ Exposing sensitive errors: Showing internal errors to users
❌ Not clearing stale data: After authentication errors
❌ Poor offline handling: App becomes unusable without network
data class GraphQLResponse<T>(
val data: T?,
val errors: List<GraphQLError>?
)
data class GraphQLError(
val message: String,
val locations: List<Location>?,
val path: List<String>?,
val extensions: Map<String, Any>?
)
class GraphQLErrorHandler {
fun <T> handleResponse(response: GraphQLResponse<T>): Result<T> {
return when {
response.data != null && response.errors.isNullOrEmpty() -> {
Result.success(response.data)
}
response.errors?.isNotEmpty() == true -> {
val error = response.errors.first()
val exception = when (error.extensions?.get("code")) {
"UNAUTHENTICATED" -> ApiException.UnauthorizedException(error.message)
"FORBIDDEN" -> ApiException.ForbiddenException(error.message)
"NOT_FOUND" -> ApiException.NotFoundException(error.message)
"VALIDATION_ERROR" -> ApiException.ValidationException(error.message)
else -> Exception(error.message)
}
Result.failure(exception)
}
else -> {
Result.failure(Exception("Unknown GraphQL error"))
}
}
}
}
class WebSocketErrorHandler : WebSocketListener() {
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
when {
t is SocketTimeoutException -> {
// Handle connection timeout
handleConnectionTimeout()
}
response?.code == 401 -> {
// Handle authentication failure
handleAuthenticationFailure()
}
response?.code in 500..599 -> {
// Handle server errors with backoff
scheduleReconnect(calculateBackoffDelay())
}
else -> {
// Handle other failures
handleGenericFailure(t)
}
}
}
private fun handleConnectionTimeout() {
// Implement exponential backoff reconnection
lifecycleScope.launch {
delay(reconnectDelay)
attemptReconnection()
}
}
}
HTTP status code error handling is a critical aspect of Android development that directly impacts user experience and app reliability. By implementing proper error categorization, user-friendly messaging, retry strategies, and comprehensive monitoring, you can build robust applications that gracefully handle network failures and provide excellent user experiences even when things go wrong.
Remember to test all error scenarios thoroughly, monitor error rates in production, and continuously improve your error handling based on real user feedback and analytics data.