Skip to main content

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:

FilePurpose
domain/errors/DomainExceptions.ktSealed exception hierarchy (27 types)
core/network/HttpStatusCode.ktHTTP status code constants
core/network/TokenAuthenticator.kt401 β†’ automatic token refresh
core/network/AuthInterceptor.ktBearer token injection
core/network/NetworkConstants.ktTimeout and retry constants
core/utils/Logger.ktStructured error logging with batched server delivery
data/repositories/*RepositoryImpl.ktHTTP β†’ domain exception mapping
ui/base/IntentViewModel.ktBase 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:

  1. HttpException is caught and mapped to a specific DomainException based on status code
  2. IOException is always wrapped in NetworkException with a contextual message
  3. The original exception is preserved as cause for debugging
  4. Some repositories also catch generic Exception for 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 CodeConstantException (where applicable)
400BAD_REQUESTInvalid2FACodeException (auth flows)
401UNAUTHORIZEDInvalidCredentialsException, SessionExpiredException, Invalid2FACodeException
403FORBIDDENAuthenticationException with server message
404NOT_FOUNDDeviceNotFoundException, GeofenceNotFoundException
422UNPROCESSABLE_ENTITYValidationException
429TOO_MANY_REQUESTSRateLimitException

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 detail messages from the server
  • Returns DomainException (not Exception), enabling sealed-class type safety
  • Maps 403 Forbidden to AuthenticationException with the server's message
  • Maps 422 to ValidationException for 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​

  1. Synchronous Retrofit Call<T>: Uses AuthApiSync (synchronous interface) instead of suspend functions to avoid runBlocking deadlocks when OkHttp's thread pool is exhausted.

  2. Single-attempt guard: The Authorization-Retry header prevents infinite refresh loops. If the retried request also returns 401, it fails immediately.

  3. Concurrency control: synchronized(this) with @Volatile isRefreshing prevents multiple simultaneous refresh attempts.

  4. Fail-safe cleanup: If refresh fails (network error, invalid refresh token), tokens are cleared and the original request fails β€” ultimately surfacing as a NetworkException or InvalidCredentialsException in 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:

ExceptionDefault 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:

  1. TokenAuthenticator.authenticate() is called
  2. Refreshes the token via synchronous AuthApiSync.refreshToken()
  3. Retries the original request with the new token
  4. If refresh fails β†’ clears tokens, returns null (request fails)
  5. If retried request also fails β†’ no further retry (Authorization-Retry header 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 #BackoffMax Retries per Log
160s3
2120s3
3240s3
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 to DomainException
  • IOException β†’ wrapped in NetworkException
  • 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 when expression can handle all cases, with compiler warnings for missing branches
  • Type-safe error handling: ViewModels can check is InvalidCredentialsException without 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 HttpException or IOException
  • 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., NullPointerException from 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_type for 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.