Error Handling
AFCore uses two primary error-handling patterns: AFResult for operations that return a success/failure result, and thrown AFException for operations that fail at the network or system level. This page covers both patterns, common error scenarios, and recommended handling approaches.
Two Error Patterns
Pattern 1: AFResult (Result-Based)
Some APIs return AFResult, which encapsulates both success and failure as a data object. The caller checks result.status to determine the outcome:
val result = AFCore.authentication().signInOrCreateMember(externalUserId = userId)
if (result.status) {
// Success
} else {
// Business logic failure
Log.w("Auth", result.statusMessage)
}
Pattern 2: Thrown AFException (Exception-Based)
All suspend functions can throw AFException (or its parent Exception) when a network, server, or unexpected error occurs. These must be caught with try/catch (Kotlin) or do/catch (Swift):
try {
val profile = AFCore.profile().get()
} catch (e: AFException) {
// Network error, server error, or unexpected error
Log.e("Profile", "Failed: ${e.message}")
}
Which APIs Use Which Pattern
| API Category | Pattern | Example |
|---|---|---|
Authentication (signInOrCreateMember, logout) | Both: Returns AFResult for business outcomes, throws AFException for network/system errors | AFResult.status == false for auth failure; AFException for network timeout |
Data retrieval (profile().get(), activities().get(), facilities().get()) | Exception-based: Returns data on success, throws on failure | Returns MemberProfile; throws AFException on error |
Data mutation (barcode().submitBarcodeId(), profile().update(), smartWalking().syncSteps()) | Exception-based: Returns data on success, throws on failure | Returns response; throws AFException on error |
Event submission (sendEvent, postEvent) | Exception-based: Completes silently on success, throws on failure | Throws AFException on error |
AFResult
AFResult is the standard response wrapper for operations that can fail for business logic reasons (not just technical failures):
public class AFResult(
public var status: Boolean = false,
public var statusMessage: String? = null,
public var messageCode: String? = null,
public var exception: AFException? = null
)
| Field | Type | Description |
|---|---|---|
status | Boolean | true if the operation succeeded, false if it failed |
statusMessage | String? | Human-readable description of the result (suitable for displaying to users) |
messageCode | String? | Machine-readable code for programmatic handling |
exception | AFException? | Populated if an AFException occurred during the operation |
Handling AFResult
- Android (Kotlin)
- iOS (Swift)
val result = AFCore.authentication().signInOrCreateMember(externalUserId = userId)
when {
result.status -> {
// Operation succeeded
navigateToHome()
}
result.exception != null -> {
// An error exception is attached
val ex = result.exception!!
Log.e("Auth", "Error: ${ex.message}")
Log.e("Auth", "Detail: ${ex.detail}")
showError(ex.message ?: "An error occurred")
}
else -> {
// Business logic failure (status = false, no exception)
showError(result.statusMessage ?: "Operation failed")
// Use messageCode for programmatic branching
when (result.messageCode) {
"MEMBER_NOT_ELIGIBLE" -> showEligibilityError()
else -> showGenericError(result.statusMessage)
}
}
}
let result = try await AFCore.shared.authentication().signInOrCreateMember(
externalUserId: userId
)
if result.status {
navigateToHome()
} else if let exception = result.exception {
print("Error: \(exception.message ?? "")")
print("Detail: \(exception.detail ?? "")")
showError(exception.message ?? "An error occurred")
} else {
showError(result.statusMessage ?? "Operation failed")
}
AFException
AFException is the SDK's extended exception type. It carries structured error information from the server:
public class AFException(
public override val message: String? = null,
public val isError: Boolean? = null,
public val detail: String? = null,
public val data: String? = null,
public val errors: List<String?>? = null,
public val httpCode: Int? = null
) : Exception()
| Field | Type | Description |
|---|---|---|
message | String? | Primary error message |
isError | Boolean? | Whether this represents an error condition (from server response) |
detail | String? | Additional details about the error |
data | String? | Additional data related to the error |
errors | List<String?>? | List of individual error messages (e.g., validation errors) |
httpCode | Int? | HTTP status code from the server response (e.g., 400, 404, 500) |
How AFException Is Created
The safeApiCall{} function wraps all API calls and converts Ktor exceptions into AFException:
| Source Exception | AFException.message | When It Occurs |
|---|---|---|
ClientRequestException (4xx) | Parsed from server error response body | Bad request, unauthorized, forbidden, not found |
ServerResponseException (5xx) | Parsed from server error response body | Internal server error, service unavailable |
JsonConvertException | "Invalid or unexpected response format: {details}" | Response body cannot be deserialized |
NoTransformationFoundException | "Invalid or unexpected response format" | No serializer found for the response type |
IOException | "Network error. Please try again later." | No internet, DNS failure, connection refused, timeout |
Other Exception | "Unexpected error: {message}" | Any other unexpected failure |
CancellationException | Re-thrown (not caught) | Coroutine was cancelled (normal behavior) |
For 4xx and 5xx errors, the safeApiCall{} function attempts to parse the server's error response body into a structured AFException with message, isError, detail, data, and errors fields populated from the JSON response.
Error Handling by API Category
Authentication Errors
Authentication methods return AFResult and can also throw exceptions:
- Android (Kotlin)
- iOS (Swift)
lifecycleScope.launch {
try {
val result = AFCore.authentication().signInOrCreateMember(externalUserId = userId)
if (result.status) {
navigateToHome()
} else {
// Business failure: member not eligible, etc.
showError(result.statusMessage ?: "Authentication failed")
}
} catch (e: AFException) {
// Network or server error
handleNetworkError(e)
}
}
private fun handleNetworkError(e: AFException) {
when {
e.message?.contains("Network error") == true ->
showError("No internet connection. Check your network and try again.")
e.message?.contains("Server error") == true ->
showError("The server is temporarily unavailable. Please try again later.")
else ->
showError("Something went wrong: ${e.message}")
}
}
Task {
do {
let result = try await AFCore.shared.authentication().signInOrCreateMember(
externalUserId: userId
)
if result.status {
navigateToHome()
} else {
showError(result.statusMessage ?? "Authentication failed")
}
} catch let error as AFException_ {
handleNetworkError(error)
} catch {
showError("An unexpected error occurred: \(error.localizedDescription)")
}
}
func handleNetworkError(_ error: AFException_) {
if error.message?.contains("Network error") == true {
showError("No internet connection. Check your network and try again.")
} else {
showError("Something went wrong: \(error.message ?? "")")
}
}
Data Retrieval Errors
Data retrieval APIs throw exceptions on failure. They do not return AFResult:
- Android (Kotlin)
- iOS (Swift)
lifecycleScope.launch {
try {
val profile = AFCore.profile().get()
// Use the profile
displayProfile(profile)
} catch (e: AFException) {
when {
e.message?.contains("Network error") == true -> {
// Show offline state, offer retry
showOfflineState()
}
e.detail != null -> {
// Server returned structured error details
Log.e("Profile", "Detail: ${e.detail}")
Log.e("Profile", "Errors: ${e.errors}")
showError(e.message ?: "Failed to load profile")
}
else -> {
showError(e.message ?: "Failed to load profile")
}
}
}
}
Task {
do {
let profile = try await AFCore.shared.profile().get()
displayProfile(profile)
} catch let error as AFException_ {
if error.message?.contains("Network error") == true {
showOfflineState()
} else {
showError(error.message ?? "Failed to load profile")
}
} catch {
showError("An unexpected error occurred")
}
}
Data Mutation Errors
Mutation operations (POST, PUT, DELETE) also throw exceptions on failure:
- Android (Kotlin)
- iOS (Swift)
lifecycleScope.launch {
try {
AFCore.barcode().submitBarcodeId(id = scannedValue, lat = null, lng = null)
showSuccess("Check-in successful!")
} catch (e: AFException) {
if (e.errors?.isNotEmpty() == true) {
// Server returned validation errors
val errorMessages = e.errors!!.filterNotNull().joinToString("\n")
showError("Validation failed:\n$errorMessages")
} else {
showError(e.message ?: "Submission failed")
}
}
}
Task {
do {
try await AFCore.shared.barcode().submitBarcodeId(id: scannedValue, lat: nil, lng: nil)
showSuccess("Check-in successful!")
} catch let error as AFException_ {
if let errors = error.errors, !errors.isEmpty {
let errorMessages = errors.compactMap { $0 }.joined(separator: "\n")
showError("Validation failed:\n\(errorMessages)")
} else {
showError(error.message ?? "Submission failed")
}
} catch {
showError("An unexpected error occurred")
}
}
Token Errors and Automatic Recovery
Token-related errors are handled automatically by the SDK. Consumer apps do not need to catch 401 errors or manage token refresh manually:
| Scenario | SDK Behavior | App Action Needed |
|---|---|---|
| Access token expired | Auth plugin triggers refresh automatically | None |
| Refresh token expired | Re-auth attempted with stored external ID | None (if re-auth succeeds) |
| Re-auth also fails | Tokens cleared, SESSION_EXPIRED emitted | Handle SESSION_EXPIRED in session observer |
| Rate limited (429) | Exponential backoff, retry later | None |
| Server error (5xx) during refresh | Exponential backoff, use existing token | None |
See Session Management for how to handle SESSION_EXPIRED.
Common Error Scenarios
Network Connectivity
AFException(message = "Network error. Please try again later.", isError = true)
This occurs when:
- The device has no internet connection.
- DNS resolution fails.
- The connection is refused.
- A timeout occurs (connect: 15s, socket: 30s, request: 30s).
Recommended handling: Show an offline indicator, offer a retry button.
Invalid Response Format
AFException(message = "Invalid or unexpected response format", isError = true)
This occurs when:
- The server returns HTML instead of JSON.
- The response body cannot be deserialized to the expected type.
- A proxy or firewall intercepts the request.
Recommended handling: Log the error with e.detail for debugging. Show a generic error to the user.
Client Errors (4xx)
AFException(
message = "Resource not found",
isError = true,
detail = "The requested facility does not exist",
errors = ["facilityId: invalid"]
)
These are parsed from the server's error response body and contain structured information:
message: Primary error message from the server.detail: Additional context.errors: List of specific validation or field-level errors.
Recommended handling: Display message to the user. Log detail and errors for debugging.
Server Errors (5xx)
AFException(
message = "Internal server error",
isError = true,
detail = "An unexpected error occurred on the server"
)
Recommended handling: Show a "try again later" message. These are typically transient.
Uninitialized SDK
IllegalStateException("AFCore has not been initialized. Call AFCore.initialize() first.")
This is thrown by all feature accessors if AFCore.initialize() has not been called. This is a programming error, not a runtime error.
Recommended handling: Ensure initialize() is called before any SDK usage. See Initialization.
Structured Error Handling Example
Here is a comprehensive error handling pattern that covers all scenarios:
- Android (Kotlin)
- iOS (Swift)
sealed class UiError {
data class Network(val retry: suspend () -> Unit) : UiError()
data class Server(val message: String) : UiError()
data class Validation(val errors: List<String>) : UiError()
data class Generic(val message: String) : UiError()
}
suspend fun <T> withErrorHandling(
onError: (UiError) -> Unit,
block: suspend () -> T
): T? {
return try {
block()
} catch (e: AFException) {
val uiError = when {
e.message?.contains("Network error") == true ->
UiError.Network(retry = { withErrorHandling(onError, block) })
e.errors?.filterNotNull()?.isNotEmpty() == true ->
UiError.Validation(e.errors!!.filterNotNull())
e.isError == true && e.detail != null ->
UiError.Server(e.detail!!)
else ->
UiError.Generic(e.message ?: "An error occurred")
}
onError(uiError)
null
}
}
// Usage
lifecycleScope.launch {
val profile = withErrorHandling(
onError = { error ->
when (error) {
is UiError.Network -> {
showRetrySnackbar(error.retry)
}
is UiError.Validation -> {
showValidationErrors(error.errors)
}
is UiError.Server -> {
showError(error.message)
}
is UiError.Generic -> {
showError(error.message)
}
}
}
) {
AFCore.profile().get()
}
profile?.let { displayProfile(it) }
}
enum UIError {
case network
case server(String)
case validation([String])
case generic(String)
}
func withErrorHandling<T>(
onError: @escaping (UIError) -> Void,
block: @escaping () async throws -> T
) async -> T? {
do {
return try await block()
} catch let error as AFException_ {
let uiError: UIError
if error.message?.contains("Network error") == true {
uiError = .network
} else if let errors = error.errors?.compactMap({ $0 }), !errors.isEmpty {
uiError = .validation(errors)
} else if let detail = error.detail {
uiError = .server(detail)
} else {
uiError = .generic(error.message ?? "An error occurred")
}
onError(uiError)
return nil
} catch {
onError(.generic(error.localizedDescription))
return nil
}
}
// Usage
Task {
let profile = await withErrorHandling(
onError: { error in
switch error {
case .network:
showRetryPrompt()
case .server(let message):
showError(message)
case .validation(let errors):
showValidationErrors(errors)
case .generic(let message):
showError(message)
}
},
block: {
try await AFCore.shared.profile().get()
}
)
if let profile = profile {
displayProfile(profile)
}
}