Master API Error Handling in Jetpack Compose — With Real Examples & Q&A




Handling API errors might sound boring — but it’s where user trust is either won or lost.

Whether it’s a 404, a network drop, or an unknown crash, how your app responds matters. In Jetpack Compose, we often nail the UI but forget to handle these issues gracefully. That’s why today we’re building a robust, reusable error-handling system that:

  • Shows friendly messages instead of raw errors

  • Keeps your ViewModels clean

  • Is scalable for real-world apps

Let’s get into it.


Why Is This Important?

Imagine this:

"java.net.UnknownHostException: Unable to resolve host..."

Now compare:

"No internet connection. Please check your network."

With clean error handling, you get:

  • Happy users who understand what's wrong

  • Clean architecture without messy try-catch everywhere

  • Better maintainability when things grow


Step 1: Create a Flexible Result Wrapper

This gives us a consistent way to handle API state (loading, success, error).

sealed class Resource<out T> {
    data class Success<out T>(val data: T) : Resource<T>()
    data class Error(val error: AppError) : Resource<Nothing>()
    object Loading : Resource<Nothing>()
}

Step 2: Define a Reusable AppError Model

This allows for all kinds of error types: network, HTTP, unknown, and custom.

sealed class AppError {
    data class Network(val message: String) : AppError()
    data class Http(val code: Int, val message: String) : AppError()
    data class Unknown(val message: String) : AppError()
    data class Custom(val userMessage: String) : AppError()
}

Step 3: Create the ErrorHandler Utility

This is your single point of truth for all error handling logic.

object ErrorHandler {

    fun handleException(e: Throwable): AppError {
        return when (e) {
            is java.net.UnknownHostException -> AppError.Network("No internet connection.")
            is java.net.SocketTimeoutException -> AppError.Network("The request timed out.")
            is retrofit2.HttpException -> {
                val code = e.code()
                val message = parseHttpErrorMessage(e)
                AppError.Http(code, message)
            }
            else -> AppError.Unknown(e.localizedMessage ?: "Unexpected error occurred.")
        }
    }

    private fun parseHttpErrorMessage(e: retrofit2.HttpException): String {
        return try {
            val errorBody = e.response()?.errorBody()?.string()
            val json = JSONObject(errorBody ?: "")
            json.optString("message", "Server error")
        } catch (ex: Exception) {
            "Server error"
        }
    }

    fun getUserFriendlyMessage(error: AppError): String {
        return when (error) {
            is AppError.Network -> error.message
            is AppError.Http -> when (error.code) {
                401 -> "Unauthorized access. Please log in again."
                403 -> "Permission denied."
                404 -> "Requested resource not found."
                500 -> "Internal server error. Please try again later."
                else -> error.message
            }
            is AppError.Unknown -> error.message
            is AppError.Custom -> error.userMessage
        }
    }
}

Step 4: ViewModel Integration (Clean & Simple)

class MyViewModel(private val repository: MyRepository) : ViewModel() {

    var result by mutableStateOf<Resource<List<MyData>>>(Resource.Loading)
        private set

    fun fetchData() {
        viewModelScope.launch {
            result = Resource.Loading
            try {
                val response = repository.getData()
                if (response.isSuccessful) {
                    result = Resource.Success(response.body() ?: emptyList())
                } else {
                    val error = ErrorHandler.handleException(retrofit2.HttpException(response))
                    result = Resource.Error(error)
                }
            } catch (e: Exception) {
                val error = ErrorHandler.handleException(e)
                result = Resource.Error(error)
            }
        }
    }
}

Step 5: Display in Compose UI

@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    when (val state = viewModel.result) {
        is Resource.Loading -> CircularProgressIndicator()
        is Resource.Success -> LazyColumn {
            items(state.data) { item -> Text(item.name) }
        }
        is Resource.Error -> Column {
            Text(ErrorHandler.getUserFriendlyMessage(state.error))
            Button(onClick = { viewModel.fetchData() }) {
                Text("Retry")
            }
        }
    }
}

Benefits Recap

1) Clean Code

No repeated try-catch or string resources all over.

2) Reusable

Drop-in utility that works across any feature/module.

3) Extendable

Add logging, analytics, or localization without touching your ViewModel.

4) Great UX

Users understand what went wrong and what to do next.


Bonus: 10+ Q&A for All Experience Levels

Beginner

  1. Q: Why not just show e.localizedMessage directly?
    A: It’s often too technical or meaningless for users.

  2. Q: What’s the point of sealed class Resource<T>?
    A: It helps model API states: loading, success, error — in a type-safe way.

  3. Q: What does mutableStateOf do in ViewModel?
    A: It makes your state observable in Composables.


Intermediate

  1. Q: How do I localize these messages?
    A: Inject Context or use stringResource(R.string.error_xxx) in ErrorHandler.

  2. Q: Can I log these errors?
    A: Absolutely. Add Firebase Crashlytics, Sentry, or Timber logging inside ErrorHandler.

  3. Q: How do I test this?
    A: Inject fake data and simulate errors using Resource.Error(AppError.Network(...)).


Advanced

  1. Q: Can I extract error codes from a custom JSON response?
    A: Yes. Use JSONObject or Gson/Moshi to parse error bodies in parseHttpErrorMessage().

  2. Q: How can I handle GraphQL or WebSocket errors?
    A: Create new subclasses in AppError like GraphQLError, SocketError, etc.

  3. Q: Will this work in a multiplatform project (KMM)?
    A: With tweaks, yes. Keep AppError in shared code, and create platform-specific handlers.

  4. Q: How do I decouple ErrorHandler from Compose and Retrofit?
    A: Make ErrorHandler take interfaces or data models and inject error parsers for full testability.

  5. Q: Can I map errors to UI actions (like logout on 401)?
    A: Yes — add side-effect handlers in the ViewModel or handle 401 explicitly in ErrorHandler.


Conclusion

Error handling isn’t glamorous — but it’s essential. By building a centralized, flexible error-handling system with AppError and ErrorHandler, you’ve created a foundation that scales as your app grows.

Make your code cleaner. Make your UX friendlier.
Handle errors like a pro.


Clap & Follow if this post helped you build more reliable apps!
Drop a comment if you want to see this extended with localization or analytics integration.



Comments

Popular posts from this blog

Jetpack Compose based Android interview and questions

Kotlin Some important unsorted interview questions and answers

Null safety based Kotlin interview questions and answers