android intermediate 12 min read

HTTP Status Code Error Handling in Android Applications

Comprehensive guide to handling HTTP status codes, network errors, and API failures in Android apps with proper user feedback and recovery strategies

#http#networking#error-handling#api#retrofit#okhttp#status-codes
Updated 2025-01-27 Appxiom Team

HTTP Status Code Error Handling in Android Applications

HTTP Error Handling

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.

Understanding HTTP Status Codes

Status Code Categories

1xx Informational Responses

  • Rarely encountered in mobile apps
  • Usually handled automatically by HTTP libraries

2xx Success Responses

  • 200 OK - Request succeeded
  • 201 Created - Resource created successfully
  • 204 No Content - Success with no response body

3xx Redirection

  • 301 Moved Permanently - Resource permanently moved
  • 302 Found - Temporary redirect
  • Usually handled automatically by HTTP clients

4xx Client Errors (Most Important for Mobile Apps)

  • 400 Bad Request - Invalid request syntax
  • 401 Unauthorized - Authentication required
  • 403 Forbidden - Server refuses to authorize
  • 404 Not Found - Resource doesn't exist
  • 409 Conflict - Request conflicts with server state
  • 422 Unprocessable Entity - Validation errors
  • 429 Too Many Requests - Rate limiting

5xx Server Errors

  • 500 Internal Server Error - Generic server error
  • 502 Bad Gateway - Invalid response from upstream
  • 503 Service Unavailable - Server temporarily unavailable
  • 504 Gateway Timeout - Upstream server timeout

Common Error Handling Patterns

1. Basic Retrofit Error Handling

class 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)
    }
}

2. Custom Exception Hierarchy

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)
}

3. Repository Pattern with Error Handling

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
                }
            }
        }
    }
}

Advanced Error Handling Strategies

1. Retry Logic with Exponential Backoff

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
    }
}

2. Circuit Breaker Pattern

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
    }
}

3. Error Response Parsing

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."
        }
    }
}

UI Error Handling

1. Error State Management with Sealed Classes

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
                    ))
                }
        }
    }
}

2. Error Dialog Component

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()
    }
}

3. Snackbar Error Handling

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 }

Testing Error Handling

1. Unit Tests for Error Scenarios

@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())
}

2. Integration Tests with MockWebServer

@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
}

Monitoring and Analytics

1. Error Tracking with Appxiom

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"
        }
    }
}

2. Error Rate Monitoring

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
                ))
            }
        }
    }
}

Best Practices

1. Error Handling Checklist

  • Categorize errors properly: Client vs server vs network
  • Provide meaningful user messages: Avoid technical jargon
  • Implement retry logic: For transient failures
  • Use fallback strategies: Cache, offline mode, default values
  • Log errors for debugging: Include context and user actions
  • Test error scenarios: Unit tests for all error paths
  • Monitor error rates: Track and alert on increasing failures
  • Handle authentication expiry: Refresh tokens or redirect to login

2. Common Pitfalls to Avoid

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

3. User Experience Guidelines

  • Be helpful: Explain what went wrong and what the user can do
  • Be specific: Provide actionable error messages
  • Be consistent: Use similar error patterns throughout the app
  • Be forgiving: Allow easy recovery from errors
  • Be transparent: Show progress and loading states
  • Be prepared: Have fallback content for critical features

Advanced Topics

1. GraphQL Error Handling

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"))
            }
        }
    }
}

2. WebSocket Error Handling

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.