Skip to main content

Overview

TecMeli implements a comprehensive error handling strategy that transforms low-level technical exceptions into user-friendly messages. The system separates network errors, timeout issues, server responses, and unexpected failures into distinct categories.

AppError Hierarchy

All application errors extend from the AppError sealed class:
sealed class AppError : Exception() {
    /** Error de conectividad de red (sin internet o problemas de conexión). */
    class Network : AppError()

    /** Tiempo de espera agotado al conectar con el servidor. */
    class Timeout : AppError()

    /**
     * Error retornado por el servidor (4xx, 5xx).
     * @property code Código HTTP de la respuesta.
     * @property msg Mensaje de error retornado por el servidor o descripción técnica.
     */
    data class Server(val code: Int, val msg: String) : AppError()

    /**
     * Error no clasificado o excepción inesperada durante la ejecución.
     * @property throwable La causa original del error para propósitos de depuración.
     */
    data class Unknown(val throwable: Throwable) : AppError()
}
See core/network/AppError.kt:10

Network

No internet connection or DNS failures

Timeout

Server took too long to respond

Server

HTTP 4xx/5xx errors from the API

Unknown

Unexpected exceptions

ErrorMapper Interface

The ErrorMapper transforms raw exceptions into domain errors:
interface ErrorMapper {
    /**
     * Transforms a low-level exception into an AppError object
     *
     * @param exception The exception thrown during an operation
     * @return An application-specific error representation
     */
    fun mapException(exception: Exception): AppError
}
See core/network/mapper/ErrorMapper.kt:12

DefaultErrorMapper Implementation

class DefaultErrorMapper : ErrorMapper {
    override fun mapException(exception: Exception): AppError = when (exception) {
        is SocketTimeoutException -> AppError.Timeout()
        is IOException -> AppError.Network()
        else -> AppError.Unknown(exception)
    }
}
See core/network/mapper/DefaultErrorMapper.kt:14
The mapper follows the Open/Closed Principle: you can extend error types by creating a custom ErrorMapper implementation without modifying existing code.

Error Flow

Error Handling in SafeApiCallExecutor

The SafeApiCallExecutor catches exceptions and delegates mapping to the ErrorMapper:
class SafeApiCallExecutor(
    private val responseHandler: HttpResponseHandler,
    private val errorMapper: ErrorMapper,
    private val logger: NetworkLogger
) {
    suspend fun <T, R> execute(
        call: suspend () -> Response<T>,
        transform: (T) -> R
    ): Result<R> = withContext(Dispatchers.IO) {
        try {
            val response = call()
            val result = responseHandler.handleResponse(response, transform)

            // Log server errors
            if (result.isFailure && response.isSuccessful.not()) {
                val error = result.exceptionOrNull()
                if (error is AppError.Server) {
                    logger.logServerError(error.code, error.message)
                }
            }

            result
        } catch (e: Exception) {
            val appError = errorMapper.mapException(e)
            logger.logUnexpectedError(appError.javaClass.simpleName, e)
            Result.failure(appError)
        }
    }
}
See core/network/executor/SafeApiCallExecutor.kt:24
1

Try Block

Attempts to execute the API call
2

Response Validation

HttpResponseHandler validates the HTTP response
3

Server Error Detection

Logs server errors (4xx/5xx) for monitoring
4

Exception Catching

Catches any exceptions thrown during the call
5

Error Mapping

ErrorMapper transforms the exception into AppError
6

Result Wrapping

Returns Result.failure with the mapped error

HTTP Response Errors

The DefaultHttpResponseHandler creates AppError.Server for non-successful responses:
class DefaultHttpResponseHandler : HttpResponseHandler {
    override fun <T, R> handleResponse(
        response: Response<T>,
        transform: (T) -> R
    ): Result<R> = when {
        response.isSuccessful -> {
            val body = response.body()
            if (body != null) {
                Result.success(transform(body))
            } else {
                Result.failure(
                    AppError.Server(response.code(), "Cuerpo de respuesta vacío")
                )
            }
        }
        else -> {
            Result.failure(
                AppError.Server(response.code(), response.message())
            )
        }
    }
}
See core/network/handler/DefaultHttpResponseHandler.kt:13
Even 2xx responses can fail if the response body is null. Always check for null bodies when dealing with nullable API responses.

ViewModel Error Handling

ViewModels transform AppError into user-friendly UiState.Error:
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getProductsUseCase: GetProductsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState<List<Product>>>(UiState.Empty)
    val uiState: StateFlow<UiState<List<Product>>> = _uiState.asStateFlow()

    fun searchProducts(query: String) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            
            getProductsUseCase(query)
                .onSuccess { products ->
                    _uiState.value = if (products.isEmpty()) {
                        UiState.Empty
                    } else {
                        UiState.Success(products)
                    }
                }
                .onFailure { error ->
                    _uiState.value = UiState.Error(
                        message = error.localizedMessage ?: "Ocurrió un error inesperado",
                        exception = error
                    )
                }
        }
    }
}
See ui/screen/home/HomeViewModel.kt:24

Custom Error Messages

For better user experience, you can map AppError types to specific messages:
fun AppError.toUserMessage(): String = when (this) {
    is AppError.Network -> "No hay conexión a internet. Verifica tu red y vuelve a intentar."
    is AppError.Timeout -> "El servidor tardó demasiado en responder. Inténtalo de nuevo."
    is AppError.Server -> when (code) {
        401 -> "Tu sesión ha expirado. Por favor, inicia sesión nuevamente."
        404 -> "El recurso solicitado no fue encontrado."
        500 -> "Error del servidor. Inténtalo más tarde."
        else -> "Error del servidor: $msg"
    }
    is AppError.Unknown -> "Ocurrió un error inesperado: ${throwable.localizedMessage}"
}

// Usage in ViewModel
.onFailure { error ->
    _uiState.value = UiState.Error(
        message = (error as? AppError)?.toUserMessage() 
            ?: "Ocurrió un error inesperado",
        exception = error
    )
}
Show a message prompting the user to check their connection, with a retry button.
Inform the user the server is slow and offer a retry option.
Navigate the user to the login screen or trigger token refresh.
Show a friendly “not found” message with suggestions.
Display a generic server error message and log the details for debugging.

Error Logging

The NetworkLogger interface allows pluggable logging:
interface NetworkLogger {
    fun logServerError(code: Int, message: String?)
    fun logUnexpectedError(errorType: String, exception: Exception)
}

class DefaultNetworkLogger : NetworkLogger {
    override fun logServerError(code: Int, message: String?) {
        Log.e("NetworkError", "Server error $code: $message")
    }

    override fun logUnexpectedError(errorType: String, exception: Exception) {
        Log.e("NetworkError", "Unexpected error: $errorType", exception)
    }
}
In production, you might send these to Firebase Crashlytics:
class CrashlyticsNetworkLogger : NetworkLogger {
    override fun logServerError(code: Int, message: String?) {
        FirebaseCrashlytics.getInstance().apply {
            setCustomKey("error_type", "server")
            setCustomKey("http_code", code)
            recordException(Exception("Server error $code: $message"))
        }
    }

    override fun logUnexpectedError(errorType: String, exception: Exception) {
        FirebaseCrashlytics.getInstance().apply {
            setCustomKey("error_type", errorType)
            recordException(exception)
        }
    }
}

UI Error Display

Composables can display errors using the ErrorState component:
@Composable
fun ProductListScreen(
    viewModel: HomeViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    when (uiState) {
        is UiState.Error -> {
            val error = (uiState as UiState.Error)
            ErrorState(
                message = error.message,
                onRetry = { viewModel.searchProducts(lastQuery) }
            )
        }
        // ... other states
    }
}

@Composable
fun ErrorState(
    message: String,
    onRetry: () -> Unit
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Icon(
            imageVector = Icons.Default.Error,
            contentDescription = null,
            modifier = Modifier.size(64.dp),
            tint = MaterialTheme.colorScheme.error
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Text(
            text = message,
            style = MaterialTheme.typography.bodyLarge,
            textAlign = TextAlign.Center
        )
        
        Spacer(modifier = Modifier.height(24.dp))
        
        Button(onClick = onRetry) {
            Text("Reintentar")
        }
    }
}

Best Practices

Never Swallow Errors

Always handle errors explicitly, even if it’s just logging them

User-Friendly Messages

Transform technical errors into actionable messages for users

Preserve Original Error

Keep the original exception for debugging purposes

Provide Retry Logic

Allow users to retry failed operations

Testing Error Handling

@Test
fun `searchProducts handles network error`() = runTest {
    val errorMapper = DefaultErrorMapper()
    val networkException = IOException("No internet")
    val appError = errorMapper.mapException(networkException)
    
    // Verify mapping
    assertTrue(appError is AppError.Network)
}

@Test
fun `ViewModel emits Error state on failure`() = runTest {
    val fakeUseCase = FakeGetProductsUseCase(
        result = Result.failure(AppError.Network())
    )
    val viewModel = HomeViewModel(fakeUseCase)
    
    viewModel.searchProducts("phone")
    
    val state = viewModel.uiState.value
    assertTrue(state is UiState.Error)
    assertEquals("No hay conexión a internet", (state as UiState.Error).message)
}

Next Steps

Network Layer

Learn how errors are caught and mapped

State Management

Understand how errors flow to the UI

Build docs developers (and LLMs) love