Error handling shows whether you think beyond the happy path. These questions cover modeling errors cleanly, handling failures in coroutines, and building apps that degrade gracefully.
I use sealed classes to define a closed set of error types. The compiler enforces exhaustive when expressions, so I canât forget to handle a case.
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
data object Loading : NetworkResult<Nothing>()
}
fun handleResult(result: NetworkResult<User>) {
when (result) {
is NetworkResult.Success -> showUser(result.data)
is NetworkResult.Error -> showError(result.message)
is NetworkResult.Loading -> showLoading()
}
}
This is better than throwing exceptions because the return type makes errors explicit. The caller is forced to handle all cases. With exceptions, nothing in the function signature tells you what can go wrong.
In Kotlin, all exceptions are unchecked â thereâs no throws clause like Java. Exception represents recoverable conditions like network failures or invalid input. Error represents unrecoverable problems like OutOfMemoryError or StackOverflowError that I generally shouldnât catch.
The Kotlin philosophy is that exceptions should be used for logical errors (bugs), not for expected conditions. If a network call can fail, I return a Result or sealed class that models success and failure as regular values instead of throwing and catching.
Result<T> is a value class that wraps either a successful value or a Throwable. It provides getOrNull(), getOrDefault(), getOrElse(), map(), fold(), and onSuccess()/onFailure().
suspend fun fetchUser(id: String): Result<User> {
return runCatching {
api.getUser(id)
}
}
fetchUser("123")
.onSuccess { user -> showProfile(user) }
.onFailure { error -> showError(error.message) }
runCatching wraps any code block and catches exceptions into a Result. The limitation is that Result only carries a Throwable, so I canât model typed errors like ânot foundâ vs âunauthorizedâ without inspecting the exception class. For richer error modeling, sealed classes are more expressive.
try-catch works normally inside a suspend function. I wrap the suspending call and catch exceptions. The key thing â CancellationException should never be caught and swallowed. If I catch Exception broadly, I rethrow CancellationException to keep structured concurrency working.
suspend fun loadData(): Result<Data> {
return try {
val data = repository.fetchData()
Result.success(data)
} catch (e: CancellationException) {
throw e // Never swallow cancellation
} catch (e: Exception) {
Result.failure(e)
}
}
runCatching does catch CancellationException, which is a problem. In coroutine-heavy code, some teams write a custom runSuspendCatching that rethrows it.
coroutineScope cancels all children if any child fails. If one child throws, every sibling is cancelled and the parent rethrows the exception.
supervisorScope lets children fail independently. If one child throws, the others keep running.
// If fetchProfile fails, fetchSettings is also cancelled
coroutineScope {
val profile = async { fetchProfile() }
val settings = async { fetchSettings() }
}
// If fetchProfile fails, fetchSettings continues
supervisorScope {
val profile = async { fetchProfile() }
val settings = async { fetchSettings() }
}
I use supervisorScope when child operations are independent â like loading different sections of a dashboard where one failure shouldnât block the others. I use coroutineScope when the children are related and partial results are useless.
CoroutineExceptionHandler is a last-resort handler for uncaught exceptions in coroutines. It only works on root coroutines launched with launch (not async).
val handler = CoroutineExceptionHandler { _, exception ->
logger.error("Unhandled: ${exception.message}")
crashReporter.report(exception)
}
viewModelScope.launch(handler) {
repository.syncData()
}
It doesnât recover the coroutine â the coroutine is already failed. I use it for logging and crash reporting at the top level. Itâs not a replacement for proper error handling inside business logic.
I use the catch operator. It catches exceptions from all operators above it in the chain but not from downstream collectors.
fun observeMessages(): Flow<List<Message>> {
return messageDao.observeAll()
.map { entities -> entities.map { it.toDomain() } }
.catch { e ->
emit(emptyList())
logger.error("Failed to observe messages", e)
}
}
For retry logic, I use retry or retryWhen:
repository.fetchData()
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
delay(1000 * (attempt + 1))
true
} else {
false
}
}
.catch { emit(cachedData) }
.collect { data -> updateUi(data) }
catch transforms the error into an emission or an empty flow. retry re-executes the upstream flow from scratch. I place catch after retry to handle errors that exhaust all retries.
In unidirectional data flow, error is just another state. I model the UI state as a sealed class with loading, success, and error variants.
sealed class ProfileUiState {
data object Loading : ProfileUiState()
data class Success(val user: User) : ProfileUiState()
data class Error(val message: String, val canRetry: Boolean) : ProfileUiState()
}
class ProfileViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
val state: StateFlow<ProfileUiState> = _state.asStateFlow()
fun loadProfile(id: String) {
viewModelScope.launch {
_state.value = ProfileUiState.Loading
repository.getUser(id)
.onSuccess { _state.value = ProfileUiState.Success(it) }
.onFailure {
_state.value = ProfileUiState.Error(
it.toAppError().userMessage, canRetry = true
)
}
}
}
}
The UI observes one state flow and renders based on the current variant. I include a canRetry flag so the UI can show or hide a retry button. Transient errors like âfailed to like a postâ go through a Channel or SharedFlow as one-shot events instead of persistent state.
I donât show raw exceptions to users. I map technical errors to meaningful messages at the repository or use case layer.
sealed class AppError(val userMessage: String) {
data object NoInternet : AppError("No internet connection. Check your network settings.")
data object ServerDown : AppError("Something went wrong. Please try again later.")
data object Unauthorized : AppError("Your session has expired. Please log in again.")
data object NotFound : AppError("The content you're looking for is no longer available.")
data class Unknown(val cause: Throwable) : AppError("An unexpected error occurred.")
}
fun Throwable.toAppError(): AppError {
return when (this) {
is UnknownHostException, is ConnectException -> AppError.NoInternet
is HttpException -> when (code()) {
401 -> AppError.Unauthorized
404 -> AppError.NotFound
in 500..599 -> AppError.ServerDown
else -> AppError.Unknown(this)
}
else -> AppError.Unknown(this)
}
}
The ViewModel should receive domain-level errors, not raw HTTP exceptions. This also makes the ViewModel testable without knowing about Retrofit or OkHttp.
I use withTimeout or withTimeoutOrNull. withTimeout throws TimeoutCancellationException. withTimeoutOrNull returns null instead.
suspend fun fetchWithTimeout(id: String): User? {
return withTimeoutOrNull(5_000) {
api.getUser(id)
}
}
withTimeoutOrNull is safer because it doesnât throw. For network calls, I also set timeouts on the HTTP client â OkHttpâs connectTimeout, readTimeout, and writeTimeout. The coroutine timeout covers the overall operation including retries and mapping. The HTTP timeout covers a single network call.
Result<T> wraps a value or a Throwable. It works well for simple success/failure scenarios where I donât need typed errors.
Sealed classes give typed errors with custom data:
sealed class FetchError {
data class HttpError(val code: Int, val body: String) : FetchError()
data object NetworkError : FetchError()
data class ParseError(val field: String) : FetchError()
}
I use Result when I just need to know âdid it work or notâ and the exception message is enough. I use sealed classes when different error types require different handling â like retrying on network errors but showing a login screen on auth errors. Sealed classes also make exhaustive when checking possible, so the compiler reminds me when I add a new error type.
Exponential backoff increases the delay between retry attempts. First retry after 1 second, second after 2 seconds, third after 4 seconds.
suspend fun <T> retryWithBackoff(
maxRetries: Int = 3,
initialDelay: Long = 1000,
factor: Double = 2.0,
block: suspend () -> T
): T {
var currentDelay = initialDelay
repeat(maxRetries - 1) {
try {
return block()
} catch (e: IOException) {
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong()
}
}
return block()
}
I add jitter (random variation) to the delay so multiple clients donât retry at the same instant. I use exponential backoff for network retries, WorkManager retry policies, and any operation against a shared resource that can be temporarily unavailable.
Circuit breaker prevents an app from repeatedly calling a service thatâs down. It has three states:
class CircuitBreaker(
private val failureThreshold: Int = 5,
private val resetTimeout: Long = 30_000
) {
private var failureCount = 0
private var lastFailureTime = 0L
private var state = State.CLOSED
suspend fun <T> execute(block: suspend () -> T): T {
return when (state) {
State.OPEN -> {
if (System.currentTimeMillis() - lastFailureTime > resetTimeout) {
state = State.HALF_OPEN
tryCall(block)
} else throw CircuitOpenException()
}
else -> tryCall(block)
}
}
private suspend fun <T> tryCall(block: suspend () -> T): T {
return try {
val result = block()
reset()
result
} catch (e: Exception) {
recordFailure()
throw e
}
}
}
This saves battery and network resources on mobile. Instead of retrying a dead server every few seconds, the circuit breaker fails fast and tries again later. I combine it with local caching to serve stale data while the circuit is open.
Graceful degradation means the app still works when parts of the system fail. Instead of showing an error screen, I show what I can with what I have.
supervisorScope lets me load sections independently.The key is deciding whatâs critical and whatâs optional. A chat app must show existing messages offline. It can defer sending new messages until connectivity returns.
I set up a Thread.UncaughtExceptionHandler to catch crashes that escape all other handlers. I integrate with a crash reporting tool like Firebase Crashlytics or Sentry.
class CrashHandler(
private val defaultHandler: Thread.UncaughtExceptionHandler?
) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, throwable: Throwable) {
CrashReporter.log(throwable)
defaultHandler?.uncaughtException(thread, throwable)
}
}
// In Application.onCreate()
Thread.setDefaultUncaughtExceptionHandler(
CrashHandler(Thread.getDefaultUncaughtExceptionHandler())
)
For coroutines, I set a global CoroutineExceptionHandler on top-level scopes. For Flow, I use the catch operator. The goal is that no exception crashes the app silently â every crash should be reported with enough context to debug it.
CancellationException properly in coroutines? (Never catch and swallow it. Always rethrow. Catching it breaks structured concurrency and prevents the parent scope from knowing the child was cancelled)launch and async for error propagation? (launch propagates exceptions to the parent scope immediately. async stores the exception in the Deferred and throws it when you call await())Result.retry() from doWork(). WorkManager applies the BackoffPolicy you set â linear or exponential â with configurable initial delay)catch and onCompletion in Flow? (catch handles upstream errors and can emit fallback values. onCompletion runs when the flow completes, whether normally or with an error, but canât emit new values)supervisorScope to let independent operations fail independently. Wrap each async call in its own try-catch and collect partial results)