02 - Error Model
Concept
1. Why
When errors are handled ad hoc, they end up in three bad places:
- Raw library errors leak upward. A Dio or Ktor exception reaches a page, and the page shows "SocketException: Failed host lookup".
- Failures get swallowed. A
catchreturns an empty list, and the bug never shows up. - Support can't trace a failure. A user reports "it says something went wrong", and nobody knows which call failed.
Sinew's error model fixes all three with four rules:
- Every failure is translated once, where it happens, into a small typed hierarchy (
AppException). - Every
AppExceptioncarries a code that names the module, layer, function and kind of failure:ORD-R-GO-NIC. Support can search for it, and the user can read it out. - Each layer hands a
Resultupward, never an exception. The view model folds results into state and effects and never needs atry/catch. - Bugs (unexpected and parse errors) are reported as non-fatal crashes as well as returned, so they're visible without taking the app down.
2. Shape
Classification
What kind of failure it is decides who can do something about it: the user (retry, fix input), the developer (a bug), or nobody (the backend is down).
| Source | Becomes (code) | Retryable | Typical UI |
|---|---|---|---|
| No connection, DNS failure, no route | NoInternetConnectionException (NIC) | yes | "You're offline", retry button |
| Connect, send or receive timeout | RequestTimeOutException (RTO) | yes, if the request was idempotent | retry button |
| Request cancelled | Kotlin: CancellationException rethrown, never a failure. Flutter: CancelException (CNL). | no | nothing: cancelling isn't a failure |
| 400 / 422 with field errors | ValidationException (VAL), with field → messages | no | errors next to fields |
| 401 after the refresh attempt failed | UnauthorizedException (UNA) | no | the session ends, back to sign-in |
| 403 | ForbiddenException (FOR) | no | "You don't have access" |
| 404 on a read that expects the item | NotFoundException (NTF) | no | "No longer exists" |
| 409 | ConflictException (CFL) | no | "Changed by someone else", reload |
| 429 | RateLimitedException (RTL), with retryAfter when the backend sends it | later | "Try again in a moment" |
| 500, 502 | ServerException (SRV) | no | generic error |
| 503, 504 | ServiceUnavailableException (SUN) | yes | generic error, retry |
A 2xx envelope with success: false, or an error body with a readable message | ApiErrorException (AE), with the backend's message | no | the backend's message |
| A non-2xx response whose body can't be read | UndefinedErrorResponseException (UER) | no | generic error |
JSON that doesn't fit the DTO, or a mapping error in toDomain() | ParseException (DF) | no | generic error, reported: the contract broke |
| Local storage can't be read or decoded | LocalStorageCorruptionException (LSC) | no | treat the cached data as absent |
| The device is out of space | StorageFullException (SF) | no | "Your device is full" |
| A storage read or write fails for another reason | StorageReadException (SR) / StorageWriteException (SW) | yes, once | generic error |
| A polling helper gives up | PollingTimeOutException (PTO) | yes | "Still processing", check later |
| A retry helper gives up | the last failure, unchanged (for example NoInternetConnectionException) | as that failure | as that failure |
| Anything no handler matches | UnexpectedException (GEN) | no | generic error, reported |
The type codes are short on purpose: they appear in messages users read out.
The code
<module>-<layer>-<function>-<type>
ORD - R - GO - NIC
| Part | Who sets it | Values |
|---|---|---|
| module | the app, per data or feature module | 2–4 uppercase letters, unique in the app: ORD, AUTH, PAY |
| layer | the guard | R repository (data), UC use case (domain), VM view model, UT utility (retry, polling) |
| function | the app, per guarded call | 2–4 uppercase letters, unique in the module: GO get orders, CO cancel order |
| type | the exception class | the codes in the table above, or an app's own for domain exceptions |
A failure while loading orders without a connection becomes NoInternetConnectionException with code ORD-R-GO-NIC. The message shown to the user ends with the code, so a support report points straight at the failing call.
Each layer catches its own failures
| Layer | Guard | Catches | Hands upward |
|---|---|---|---|
| Source (one HTTP or storage call) | processApiCall, processOptionalApiCall, the storage guard | library errors, success: false, parse and mapping errors | Result with a typed AppException |
| Data (repository) | none of its own; every step it runs is already guarded | nothing new | the source guard's Result, unchanged |
| Domain (use case, only when one exists) | the use-case guard | its own rule failures (domain exceptions) and bugs in its own code | Result |
| Presentation (view model) | none | nothing: nothing below it throws | state and effects |
Handlers: predicate + transform, first match wins
A guard runs its block. On failure it walks an ordered list of handlers. Each handler is a predicate ("is this a timeout?") and a transform ("make a RequestTimeOutException for this module and function"). The first match wins, so handlers go from specific to general. Each package contributes its own list: network brings the HTTP handlers, storage the storage handlers.
Rules
| Rule | Why |
|---|---|
An AppException from a lower layer passes through every guard unchanged | The code keeps pointing at where the failure really happened |
| Translate at the source guard. Nothing above the data layer sees a Dio, Ktor, JSON or storage error. | Low-level errors never leak upward |
The source guard runs toDomain() inside its try | A mapping bug becomes a failure of that one call, not a crash of the app |
Cancellation is never a failure. Kotlin guards rethrow CancellationException before anything else. Flutter guards return CancelException, which view models and the pager ignore. | A superseded search must not show an error, and a cancelled coroutine must stop |
UnexpectedException and ParseException are reported to CrashReporter and returned | Bugs are visible to developers without taking the app down |
The original error is kept as cause, with its stack trace | Context for crash reports |
The user-facing message is safe to show: the backend's text, or a local key with an English fallback. Technical detail stays in cause. | Users never see stack traces, payloads or tokens |
| A message never contains tokens, passwords, full payloads, SQL or stack traces | Error text ends up in screenshots and support tickets |
Validation errors keep their field → messages map | Forms can show each message beside its field |
Domain exceptions
A domain exception is an expected business outcome, not a technical error: "this order can't be cancelled any more". The app defines it as a subclass of AppException, with its own type code, named in business language, carrying the data the page needs.
class OrderNotCancellableException(module, function, reason: CancelBlockReason) : AppException(module, UseCase, function) {
typeCode = "ONC"
message = Local(key = "orders.error.notCancellable", fallback = "This order can no longer be cancelled.")
}
// code: ORD-UC-CO-ONC
| Rule | Why |
|---|---|
| One domain exception type per outcome the page treats differently | The view model maps each one to its own state or effect |
Carry structured data (reason, field, limit), not only text | The page can show a precise, localized message |
Translate a data exception only when the domain adds meaning (a 409 during cancel → OrderNotCancellableException(alreadyCancelled)) | A NoInternetConnectionException is already clear |
Reporting
CrashReporter.install(reporter) // once, at the app's composition root
// default: CrashReporter.none (records nothing)
The guards are plain functions called from every repository and use case. Passing a reporter into each call would add a parameter to hundreds of call sites, so the reporter is installed once, process-wide. This is the one global in Sinew (see the open question below).
3. API
Full signatures are in Exception. The model-level contract:
| Name | Signature (pseudocode) | For |
|---|---|---|
ExceptionLayer | enum { ViewModel("VM"), UseCase("UC"), Repository("R"), Utility("UT") } | the layer part of the code |
AppException | abstract class AppException(module: String, layer: ExceptionLayer, function: String, cause: Throwable? = null) with typeCode: String, message: ErrorMessage, retryable: Boolean = false, code = "$module-${layer.code}-$function-$typeCode" | the base of every Sinew and app exception |
ExceptionHandler | ExceptionHandler(matches: (Throwable) -> Boolean, transform: (Throwable) -> AppException) | one entry in a guard's handler list |
| shared guard core | processCall(module, function, layer, handlers, block): Result<T> | the logic every guard shares (public, so apps can guard their own calls) |
| use-case guard | Flutter processUseCase(module, function, block), Kotlin processResult(module, function, layer = UseCase, block) | guards a use case |
CrashReporter | interface { recordNonFatal(error: AppException, stackTrace) }, CrashReporter.none, CrashReporter.install(reporter) | where bugs are reported |
ErrorMessage, Localizer, displayMessage | Server(text) | Local(key, args, fallback); Localizer.resolve(key, args); error.displayMessage(localizer) | who provides the text, and how a page renders it (see Exception) |
The shared guard core, in pseudocode:
processCall(module, function, layer, handlers, block): Result<T> =
try
Result.success(block())
catch CancellationException (Kotlin only)
rethrow // never a failure
catch AppException e
Result.failure(e) // already translated below: pass through
catch Throwable e
mapped = handlers.first { it.matches(e) }?.transform(e)
?: UnexpectedException(module, layer, function, cause = e)
if mapped is UnexpectedException or ParseException: CrashReporter.current.recordNonFatal(mapped, stackTrace)
Result.failure(mapped)
4. Build steps
ExceptionLayer,AppExceptionwithcode,typeCode,messageandretryable.- The built-in exception types from the classification table, each with its type code and message (local key with English fallback, or the backend's text).
ExceptionHandlerand the shared guard core, with tests:- success;
- a lower-layer
AppExceptionpasses through with its original code; - a matched error is transformed;
- an unmatched error becomes
UnexpectedExceptionand is reported; - a
ParseExceptionis reported; - Kotlin:
CancellationExceptionis rethrown, not returned.
CrashReporterwithnoneandinstall, and a test that a reporter installed in one test doesn't leak into the next (reset in teardown).- The use-case guard, tested with a domain exception (passes through) and a bug in the body (becomes
UnexpectedExceptionwith layerUC).
Done when
- Every row of the classification table has a type and a code, and a test proves its handler produces it (the HTTP rows are tested in Network, the storage rows in Storage).
- A failure two layers down keeps its original code at the view model.
- Cancelling a running request produces no failure, no crash report and no state change.
- No guard returns a raw library error.
5. Edge cases
| Case | Decided behavior |
|---|---|
| A user leaves the page mid-request, or a new search supersedes a request | Kotlin: the guard rethrows CancellationException, the coroutine stops, nothing is reported. Flutter: the guard returns CancelException; view models and the pager drop it silently. No failed state, no snackbar. |
toDomain() throws (a bug in the mapper) | The source guard catches it inside its try: ParseException for the call, reported once. Other calls are unaffected. |
| A handler's predicate or transform throws | The guard treats it as unmatched: UnexpectedException with the original error as cause, reported. A broken handler never crashes the app. |
An app defines its own AppException subclass | Supported: it passes through every guard unchanged, like a built-in one. Its type code must not collide with a built-in code (the built-in codes are listed in Exception). |
| Two modules use the same module code | Their codes become ambiguous. The app keeps module codes in one list; a debug-build check at startup can assert uniqueness. |
A non-Exception error (Kotlin Error, Dart Error such as a StateError) | Caught like any other throwable and turned into UnexpectedException, except Kotlin's CancellationException. A real OutOfMemoryError is not something to recover from; it's caught only because there's no safe way to exclude it portably. |
| An error message would include a token or payload | Never: a local message is a fixed key with fixed fallback text, and server text is the backend's own. Technical detail goes in cause, which only reaches the crash reporter. |
Implementation
1. Why
The base error model has two Kotlin-specific traps:
kotlin.Resultcan hold anyThrowable, so the guarantee "the failure is always anAppException" has to come from the guards.- Coroutine cancellation is an exception. A guard that catches
Throwablewithout care swallowsCancellationException, and a cancelled search then shows an error or keeps running.
This note shows how the Kotlin guards keep both promises.
2. Shape
kotlin.Result, narrowed by the guards
| Rule | Why |
|---|---|
Repositories, use cases and guards return kotlin.Result<T> | One result type, from the standard library |
Only Sinew's guards build a failed Result, and always with an AppException | Callers can rely on exceptionOrNull() as AppException |
Never runCatching around suspend calls | It catches CancellationException and breaks cancellation |
User-facing text comes from (error as AppException).displayMessage(localizer) | kotlin.Result has no message field. The message lives on the exception. |
A small extension makes the narrowing explicit:
public val Result<*>.appException: AppException? get() = exceptionOrNull() as AppException?
Cancellation comes first
public suspend fun <T> processCall(
module: String, function: String, layer: ExceptionLayer,
handlers: List<ExceptionHandler>, block: suspend () -> T,
): Result<T> = try {
Result.success(block())
} catch (e: CancellationException) {
currentCoroutineContext().ensureActive() // the caller was cancelled: this throws, and cancellation propagates
classify(e) // the caller is still active: an engine-wrapped timeout, classified below
} catch (e: AppException) {
Result.failure(e) // already translated below
} catch (e: Throwable) {
classify(e)
}
// classify(e) = runHandlers(handlers, e) ?: UnexpectedException(…, cause = e), reported if unexpected or parse, as Result.failure
| Rule | Why |
|---|---|
A CancellationException is rethrown when the caller itself was cancelled (ensureActive() throws) | Structured concurrency stays intact: a cancelled job really stops |
A CancellationException while the caller is still active is classified like any failure | Some engines wrap a timeout in a CancellationException. Rethrowing it would make a timeout silently vanish; classifying it lets the network handlers turn it into RequestTimeOutException. |
runHandlers catches anything a handler throws and treats it as unmatched | A broken handler never crashes the app |
reportSafely catches anything the reporter throws | Reporting never turns a handled failure into a crash |
iOS errors
On iOS, Ktor's Darwin engine surfaces network failures as DarwinHttpRequestException wrapping an NSError. The network handlers read the NSError domain and code (NSURLErrorNotConnectedToInternet, NSURLErrorTimedOut, NSURLErrorCannotFindHost…), so the same classification holds on every platform. These predicates live in iosMain, behind a common isNoConnection(e) / isTimeout(e).
Uncaught errors
Sinew reports only what its guards catch. An exception that escapes everything (a bug in a launch with no handler) is the app's crash reporter's job. Devtools chains into the platform's uncaught-error hooks to show those as well (see DevTools).
3. API
| Name | Kotlin signature |
|---|---|
ExceptionLayer | public enum class ExceptionLayer(public val code: String) { ViewModel("VM"), UseCase("UC"), Repository("R"), Utility("UT") } |
AppException | public abstract class AppException(public val module: String, public val layer: ExceptionLayer, public val function: String, cause: Throwable? = null) : Exception(cause) with abstract val typeCode: String, abstract val message: ErrorMessage (named errorMessage to avoid Throwable.message), open val retryable: Boolean = false, val code: String |
appException | public val Result<*>.appException: AppException? |
processCall | as above, public suspend fun <T> |
processResult | public suspend fun <T> processResult(module: String, function: String, layer: ExceptionLayer = ExceptionLayer.UseCase, block: suspend () -> T): Result<T> |
4. Build steps
AppExceptionwitherrorMessage(notmessage:Throwable.messageis already aString?), andappException.processCall, tested incommonTestwithrunTest:- cancelling the caller's job: the
CancellationExceptionpropagates, and nothing is reported; - a
CancellationExceptionthrown by the block while the caller is active (an engine-wrapped timeout): classified, not rethrown; - cancelling the calling job stops the block;
- a handler that throws is treated as unmatched;
- a reporter that throws doesn't break the guard.
- cancelling the caller's job: the
- The common
isNoConnection/isTimeouthelpers, with platform tests: OkHttp'sUnknownHostExceptionandConnectException, the DarwinNSErrorcodes, and the Js engine's failure.
Done when
- No code path in Sinew uses
runCatchingaround a suspend call (a Detekt rule enforces it). - A cancelled search produces no failure, no report and no state change, on every target.
5. Edge cases
| Case | Decided behavior |
|---|---|
AppException.message clashes with Throwable.message | Kotlin's Throwable.message is a String?. The base contract's message: ErrorMessage is named errorMessage on Kotlin. Throwable.message returns the English fallback (or the server text), so logs read naturally. |
A TimeoutCancellationException from withTimeout inside a repository | It's a CancellationException, so it's rethrown as a cancellation. Repositories use Ktor's own timeouts instead of withTimeout. |
Errors on iOS without a recognised NSError code | Unmatched: UnexpectedException, reported. The report includes the domain and code, so the predicate list can grow. |
errorMessageinstead ofmessageon Kotlin'sAppException, because of the clash withThrowable.message.- A Detekt rule that bans
runCatchingin suspend contexts across Sinew.