Error Handling Strategy
This document covers the complete error handling architecture in the Visla GPS Android app β from domain exception definitions through HTTP error mapping, ViewModel error state patterns, and network resilience.
Architecture Overviewβ
Key source files:
| File | Purpose |
|---|---|
domain/errors/DomainExceptions.kt | Sealed exception hierarchy (27 types) |
core/network/HttpStatusCode.kt | HTTP status code constants |
core/network/TokenAuthenticator.kt | 401 β automatic token refresh |
core/network/AuthInterceptor.kt | Bearer token injection |
core/network/NetworkConstants.kt | Timeout and retry constants |
core/utils/Logger.kt | Structured error logging with batched server delivery |
data/repositories/*RepositoryImpl.kt | HTTP β domain exception mapping |
ui/base/IntentViewModel.kt | Base ViewModel with error logging |
Domain Exception Hierarchyβ
All domain errors extend the sealed DomainException base class, which itself extends Exception. This enables exhaustive when matching and ensures every error carries a user-friendly message.
// domain/errors/DomainExceptions.kt
sealed class DomainException(
override val message: String,
override val cause: Throwable? = null
) : Exception(message, cause)
Complete Exception Treeβ
Design Rationaleβ
Each exception subtype carries contextual data (device IDs, email addresses, command types) alongside human-readable messages. This lets the UI display the message directly while giving error handlers access to structured data for logging or recovery logic.
Error Propagation Pathβ
Errors flow upward through three layers with clear responsibilities at each boundary.
Layer 1: Data Layer β Catch and Mapβ
Repositories are the only place that handles raw network exceptions. Every repository method follows the same pattern:
// data/repositories/DeviceRepositoryImpl.kt
override suspend fun getDevice(id: Int): Device = deviceCache.getOrPut(id) {
try {
val response = deviceApi.getDevice(id)
deviceMapper.toDomain(response)
} catch (e: HttpException) {
if (e.code() == HttpStatusCode.NOT_FOUND) {
throw DeviceNotFoundException("Device $id not found")
}
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error fetching device", e)
}
}
Rules:
HttpExceptionis caught and mapped to a specificDomainExceptionbased on status codeIOExceptionis always wrapped inNetworkExceptionwith a contextual message- The original exception is preserved as
causefor debugging - Some repositories also catch generic
Exceptionfor unexpected errors (e.g.,DeviceRepositoryImpl.getDevices())
Layer 2: Domain Layer β Pass-Throughβ
Use cases do not catch or transform exceptions. Domain exceptions propagate directly from repositories through use cases to ViewModels. The domain layer is intentionally transparent for errors.
Layer 3: UI Layer β Catch and Displayβ
ViewModels catch Exception at the top level and extract the message for UI display:
// ui/screens/DevicesViewModel.kt
try {
val devicesWithPos = sortDevices(devicesInteractor.refreshDevices())
_uiState.value = DevicesUiState.Success(
devices = devicesWithPos,
isWebSocketConnected = _isWebSocketConnected.value,
licenseStatus = null
)
} catch (e: Exception) {
Logger.error(
"DevicesViewModel: Failed to load devices",
mapOf(
"error" to (e.message ?: "unknown"),
"error_type" to e.javaClass.simpleName
)
)
_uiState.value = DevicesUiState.Error(
message = e.message ?: "Failed to load devices",
devices = devicesInteractor.devicesFlow.value
)
}
HTTP Error Mappingβ
Standard Mapping (Most Repositories)β
Most repositories use a simple mapHttpException() that wraps all HTTP errors as NetworkException:
// Used by DeviceRepositoryImpl, SharingRepositoryImpl, CommandRepositoryImpl,
// GeofenceRepositoryImpl, EventRepositoryImpl, PositionRepositoryImpl,
// SubscriptionRepositoryImpl
private fun mapHttpException(e: HttpException): Exception =
NetworkException("HTTP error: ${e.code()}", e)
Before reaching this fallback, repositories check for specific status codes that map to more precise exceptions:
| Status Code | Constant | Exception (where applicable) |
|---|---|---|
| 400 | BAD_REQUEST | Invalid2FACodeException (auth flows) |
| 401 | UNAUTHORIZED | InvalidCredentialsException, SessionExpiredException, Invalid2FACodeException |
| 403 | FORBIDDEN | AuthenticationException with server message |
| 404 | NOT_FOUND | DeviceNotFoundException, GeofenceNotFoundException |
| 422 | UNPROCESSABLE_ENTITY | ValidationException |
| 429 | TOO_MANY_REQUESTS | RateLimitException |
Status code constants are centralized in HttpStatusCode:
// core/network/HttpStatusCode.kt
object HttpStatusCode {
const val BAD_REQUEST = 400
const val UNAUTHORIZED = 401
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val UNPROCESSABLE_ENTITY = 422
const val TOO_MANY_REQUESTS = 429
}
Auth-Specific Mappingβ
AuthRepositoryImpl uses a richer mapAuthException() that parses the server error body for a detail field:
// data/repositories/AuthRepositoryImpl.kt
private fun mapAuthException(e: HttpException): DomainException {
val serverMessage = try {
e.response()?.errorBody()?.string()?.let { body ->
org.json.JSONObject(body).optString("detail", "")
}
} catch (_: Exception) {
null
}
return when (e.code()) {
HttpStatusCode.UNAUTHORIZED -> InvalidCredentialsException()
HttpStatusCode.FORBIDDEN -> AuthenticationException(
serverMessage?.takeIf { it.isNotBlank() } ?: "Access denied"
)
HttpStatusCode.UNPROCESSABLE_ENTITY -> ValidationException("Validation error")
HttpStatusCode.TOO_MANY_REQUESTS -> RateLimitException()
else -> NetworkException("HTTP error: ${e.code()}", e)
}
}
Key differences from standard mapping:
- Parses JSON error body for
detailmessages from the server - Returns
DomainException(notException), enabling sealed-class type safety - Maps
403 ForbiddentoAuthenticationExceptionwith the server's message - Maps
422toValidationExceptionfor form-level errors
Context-Specific Overridesβ
Several AuthRepositoryImpl methods add pre-mapping checks before falling through to mapAuthException():
// Login 2FA β 401 means invalid code, not invalid credentials
override suspend fun login2FA(tempToken: String, code: String): LoginResult = try {
val response = authApi.login2FA(Login2FARequest(tempToken, code))
userMapper.toLoginResult(response)
} catch (e: HttpException) {
if (e.code() == HttpStatusCode.UNAUTHORIZED) {
throw Invalid2FACodeException("Invalid verification code")
}
throw mapAuthException(e)
}
// Token refresh β 401 means session expired
override suspend fun refreshToken(refreshToken: String): LoginResult = try {
val response = authApi.refreshToken(RefreshTokenRequest(refreshToken))
userMapper.toLoginResult(response)
} catch (e: HttpException) {
if (e.code() == HttpStatusCode.UNAUTHORIZED) {
throw SessionExpiredException()
}
throw mapAuthException(e)
}
// Change password β 401 means wrong current password
override suspend fun changePassword(currentPassword: String, newPassword: String) {
try {
authApi.changePassword(ChangePasswordRequest(currentPassword, newPassword))
} catch (e: HttpException) {
if (e.code() == HttpStatusCode.UNAUTHORIZED) {
throw InvalidCredentialsException("Current password is incorrect")
}
throw mapAuthException(e)
}
}
This pattern allows the same HTTP status code to map to different domain exceptions depending on the operation context.
Token Expiry and Session Recoveryβ
Token refresh is handled transparently at the OkHttp layer, invisible to repositories and ViewModels.
See also: Authentication β Token Refresh for the full token lifecycle.
Automatic Refresh via TokenAuthenticatorβ
When any API call returns 401 Unauthorized, OkHttp's Authenticator interface triggers TokenAuthenticator:
// core/network/TokenAuthenticator.kt
override fun authenticate(route: Route?, response: Response): Request? {
// Prevent retry loops
if (response.request.header("Authorization-Retry") != null) {
return null
}
val refreshToken = tokenManager.refreshToken ?: return null
// Prevent multiple simultaneous refresh attempts
synchronized(this) {
if (isRefreshing) return null
isRefreshing = true
}
return try {
val call = authApiSync.refreshToken(RefreshTokenRequest(refreshToken))
val apiResponse = call.execute()
val newTokenResponse = if (apiResponse.isSuccessful) apiResponse.body() else null
val newAccessToken = newTokenResponse?.accessToken
if (!newAccessToken.isNullOrBlank() && newTokenResponse?.user != null) {
tokenManager.saveTokens(
accessToken = newAccessToken,
refreshToken = newTokenResponse.refreshToken ?: refreshToken,
userId = newTokenResponse.user.id,
email = newTokenResponse.user.email,
name = newTokenResponse.user.name
)
// Retry original request with new token
response.request.newBuilder()
.header("Authorization", "Bearer $newAccessToken")
.header("Authorization-Retry", "true")
.build()
} else {
tokenManager.clearTokens()
null // Fail the request β HttpException propagates up
}
} catch (e: Exception) {
Logger.error("Token refresh failed", mapOf("error" to (e.message ?: "unknown")))
tokenManager.clearTokens()
null
} finally {
isRefreshing = false
}
}
Key Design Decisionsβ
-
Synchronous Retrofit
Call<T>: UsesAuthApiSync(synchronous interface) instead of suspend functions to avoidrunBlockingdeadlocks when OkHttp's thread pool is exhausted. -
Single-attempt guard: The
Authorization-Retryheader prevents infinite refresh loops. If the retried request also returns 401, it fails immediately. -
Concurrency control:
synchronized(this)with@Volatile isRefreshingprevents multiple simultaneous refresh attempts. -
Fail-safe cleanup: If refresh fails (network error, invalid refresh token), tokens are cleared and the original request fails β ultimately surfacing as a
NetworkExceptionorInvalidCredentialsExceptionin the repository layer.
Token Expiry Detectionβ
TokenManager provides proactive expiry checking by decoding the JWT payload:
// core/network/TokenManager.kt
fun isTokenExpired(): Boolean {
val expirationTime = getTokenExpirationTime() ?: return true
return System.currentTimeMillis() >= expirationTime
}
fun isTokenExpiringSoon(withinSeconds: Long = 300): Boolean {
val expirationTime = getTokenExpirationTime() ?: return true
val timeRemaining = expirationTime - System.currentTimeMillis()
return timeRemaining < withinSeconds * MS_PER_SECOND
}
ViewModel Error State Patternsβ
ViewModels use three patterns to expose errors to the UI, all based on StateFlow.
Pattern 1: Sealed State with Error Variantβ
Used when the entire screen state changes on error (e.g., device list):
// ui/screens/DevicesUiState.kt
sealed class DevicesUiState {
object Loading : DevicesUiState()
data class Success(
val devices: List<DeviceWithPosition>,
val isWebSocketConnected: Boolean,
val licenseStatus: License?
) : DevicesUiState()
data class Error(
val message: String,
val devices: List<DeviceWithPosition> = emptyList()
) : DevicesUiState()
data class Refreshing(
val devices: List<DeviceWithPosition>,
val isWebSocketConnected: Boolean
) : DevicesUiState()
}
The Error state preserves existing device data so the UI can show stale data alongside the error banner. The ViewModel also exposes a derived errorMessage flow for backward compatibility:
val errorMessage: StateFlow<String?> = _uiState.map { state ->
if (state is DevicesUiState.Error) state.message else null
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
Pattern 2: Inline Error Fields in Data Classβ
Used when multiple independent sections of a screen can fail independently:
// ui/viewmodels/NotificationSettingsViewModel.kt
data class NotificationSettingsUiState(
val notificationsEnabled: Boolean = false,
val alarmCategories: List<AlarmCategoryDto> = emptyList(),
val isLoadingCategories: Boolean = false,
val categoriesError: String? = null, // β independent error
val availableSounds: List<SoundDto> = emptyList(),
val isLoadingSounds: Boolean = false,
val soundsError: String? = null, // β independent error
val soundPreferences: Map<String, SoundPreferenceDto> = emptyMap(),
val isLoadingPreferences: Boolean = false,
val preferencesError: String? = null, // β independent error
// ...
)
Each section has its own isLoading / error pair, allowing partial failures without blocking the rest of the screen.
Pattern 3: Sealed Operation Resultβ
Used for discrete user-initiated operations that need success/error feedback:
// ui/screens/DevicesViewModel.kt
sealed class DeviceOperationResult {
data object UnclaimSuccess : DeviceOperationResult()
data class UnclaimError(val message: String) : DeviceOperationResult()
}
// Exposed as nullable StateFlow, cleared after UI consumption
private val _operationResult = MutableStateFlow<DeviceOperationResult?>(null)
val operationResult: StateFlow<DeviceOperationResult?> = _operationResult.asStateFlow()
Pattern 4: Error Field in UI State Data Classβ
The most common pattern β a simple errorMessage: String? or error: String? field:
// ui/screens/RegisterViewModel.kt
data class RegisterUiState(
val name: String = "",
val email: String = "",
val password: String = "",
val confirmPassword: String = "",
val termsAccepted: Boolean = false,
val isLoading: Boolean = false,
val errorMessage: String? = null // β null means no error
)
ViewModels clear the error on the next user interaction:
private fun updateEmail(email: String) {
_uiState.value = _uiState.value.copy(email = email, errorMessage = null)
}
User-Facing Error Messagesβ
Error messages displayed to users come from two sources:
1. DomainException Default Messagesβ
Each DomainException subtype has a sensible default message that is shown directly:
| Exception | Default Message |
|---|---|
InvalidCredentialsException | "Invalid email or password" |
SessionExpiredException | "Session expired, please login again" |
DeviceNotFoundException | "Device not found" |
RateLimitException | "Too many requests, please try again later" |
NetworkException | "Network error" |
WeakPasswordException | "Password must be at least 8 characters" |
CannotShareWithSelfException | "Cannot share device with yourself" |
DeviceLimitReachedException | "Device limit reached" |
2. Fallback Messages in ViewModelsβ
When exceptions don't carry a useful message, ViewModels provide context-specific fallbacks:
_uiState.value = _uiState.value.copy(
errorMessage = e.message ?: "Failed to change password"
)
This pattern (e.message ?: "fallback") appears consistently across all ViewModels.
Network Error Handlingβ
Timeoutsβ
Network timeouts are configured centrally in NetworkConstants:
// core/network/NetworkConstants.kt
object NetworkConstants {
const val DEFAULT_TIMEOUT_SECONDS = 30L // Standard API calls
const val LIGHT_TIMEOUT_MS = 5000 // Lightweight operations (logging)
const val RETRY_DELAY_MS = 1000L // Delay between retries
const val WEBSOCKET_CLOSE_NORMAL = 1000 // WebSocket close code
}
OkHttp applies DEFAULT_TIMEOUT_SECONDS (30s) for connect, read, and write timeouts on the authenticated client.
See also: Network Configuration for the full OkHttp setup.
Connectivity Errors (IOException)β
Every repository catches IOException separately from HttpException:
} catch (e: IOException) {
throw NetworkException("Network error fetching devices", e)
}
IOException covers: no internet, DNS failure, connection refused, socket timeout, and SSL errors.
Graceful Degradation with Cached Dataβ
DeviceRepositoryImpl demonstrates a fallback-to-cache pattern when the network fails:
// data/repositories/DeviceRepositoryImpl.kt β getDevicesWithPositions()
} catch (e: HttpException) {
val cached = deviceDataStore.devicesWithPositions.value
if (cached.isNotEmpty()) {
Logger.warn("DeviceRepositoryImpl: API failed, returning ${cached.size} cached devices", emptyMap())
cached
} else {
throw mapHttpException(e)
}
} catch (e: IOException) {
val cached = deviceDataStore.devicesWithPositions.value
if (cached.isNotEmpty()) {
Logger.warn("DeviceRepositoryImpl: Network error, returning ${cached.size} cached devices", emptyMap())
cached
} else {
throw NetworkException("Network error fetching devices with positions", e)
}
}
This pattern returns stale data rather than an error when cached data is available, keeping the app usable during transient network outages.
WebSocket Error Handlingβ
WebSocketManager handles connection failures with automatic reconnection:
// data/network/WebSocketManager.kt
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket error: ${t.message}", t)
_isConnected.value = false
_connectionError.value = t.message
stopPingTimer()
scheduleReconnect()
}
private fun scheduleReconnect() {
reconnectJob?.cancel()
reconnectJob = scope.launch {
delay(RECONNECT_DELAY_MS) // 5 seconds
connect()
}
}
WebSocket errors are surfaced via connectionError: StateFlow<String?> rather than thrown as exceptions.
See also: Real-Time Communication for WebSocket message handling.
Retry Policiesβ
OkHttp Token Refresh Retryβ
Handled automatically by TokenAuthenticator. When a request returns 401:
TokenAuthenticator.authenticate()is called- Refreshes the token via synchronous
AuthApiSync.refreshToken() - Retries the original request with the new token
- If refresh fails β clears tokens, returns
null(request fails) - If retried request also fails β no further retry (
Authorization-Retryheader guard)
WebSocket Reconnectionβ
Fixed 5-second delay before reconnection attempt. On failure, the cycle repeats indefinitely (connect β fail β wait 5s β connect).
Logger Batch Retry with Exponential Backoffβ
The Logger implements server-side log delivery with exponential backoff:
// core/utils/Logger.kt
private fun handleSendFailure(logs: List<LogEntry>) {
consecutiveFailures++
val backoffMs = (INITIAL_BACKOFF_MS * (1 shl (consecutiveFailures - 1).coerceAtMost(4)))
.coerceAtMost(MAX_BACKOFF_MS)
nextRetryTime = System.currentTimeMillis() + backoffMs
val logsToRetry = logs.filter { it.retryCount < MAX_RETRIES }
.map { it.copy(retryCount = it.retryCount + 1) }
pendingLogs.addAll(logsToRetry)
}
| Failure # | Backoff | Max Retries per Log |
|---|---|---|
| 1 | 60s | 3 |
| 2 | 120s | 3 |
| 3 | 240s | 3 |
| 4+ | 300s (cap) | 3 |
No Application-Level Retry for API Callsβ
Repository methods do not implement retry logic. Each API call is attempted once. If it fails:
HttpExceptionβ mapped toDomainExceptionIOExceptionβ wrapped inNetworkException- The ViewModel surfaces the error; the user can retry manually (e.g., pull-to-refresh)
Error Loggingβ
All error paths use the structured Logger utility, which logs to both Android Logcat and the server:
Logger.error(
"DevicesViewModel: Failed to load devices",
mapOf(
"error" to (e.message ?: "unknown"),
"error_type" to e.javaClass.simpleName
)
)
Error-level logs trigger immediate flush to the server (bypassing the 30-second batch interval), ensuring critical errors are reported promptly.
The IntentViewModel base class also provides centralized error logging for async intent handling:
// ui/base/IntentViewModel.kt
protected fun handleAsync(intent: I, block: suspend () -> Unit) {
viewModelScope.launch {
try {
block()
} catch (e: Exception) {
Logger.error(
"${this.javaClass.simpleName}: Intent failed",
mapOf(
"intent" to intent.javaClass.simpleName,
"error" to (e.message ?: "unknown")
)
)
}
}
}
Design Decisionsβ
Why a Sealed Exception Hierarchy?β
A sealed DomainException enables:
- Exhaustive matching: Kotlin's
whenexpression can handle all cases, with compiler warnings for missing branches - Type-safe error handling: ViewModels can check
is InvalidCredentialsExceptionwithout string parsing - Contextual data: Subclasses carry typed properties (e.g.,
DeviceLimitReachedException.limit) instead of encoding data in message strings
Why Map at the Repository Layer?β
Repositories are the boundary between external APIs and the domain. Mapping errors here ensures:
- The domain and UI layers never see
HttpExceptionorIOException - The same HTTP error can map to different domain exceptions per endpoint
- Network-layer changes (e.g., switching from Retrofit to Ktor) don't affect upstream layers
Why Catch Exception in ViewModels (Not DomainException)?β
ViewModels catch Exception broadly because:
- Unexpected exceptions (e.g.,
NullPointerExceptionfrom a mapper bug) should produce a user-visible error, not a crash - The
e.message ?: "fallback"pattern provides a safe default for any exception type - Critical errors are still logged with
error_typefor debugging
Why No Application-Level Retry?β
The app relies on user-initiated retry (pull-to-refresh, button tap) rather than automatic retry because:
- GPS tracking operations are not idempotent in all cases (e.g., commands)
- Automatic retry on metered mobile connections could waste data
- The TokenAuthenticator already handles the most critical retry case (expired tokens)
Why Synchronous Token Refresh?β
AuthApiSync uses Retrofit's Call<T> (synchronous) instead of coroutines because OkHttp's Authenticator runs on OkHttp's dispatcher threads. Using runBlocking to bridge to coroutines can deadlock when all OkHttp threads are waiting for the token refresh response (which itself needs an OkHttp thread).
See also: Authentication β Synchronous Auth API for more details.