Skip to main content

02 - Exception

Concept​

1. Why​

The Error Model says what failures look like and where they're caught. This package is the code that does it:

  • the concrete exception types;
  • the handler mechanism;
  • the shared guard core every guard in Sinew is built on;
  • the use-case guard;
  • the crash reporter;
  • the user-facing message: who provides it, and how it's localized.

The HTTP and storage guards live in Network and Storage. They're thin layers over this package's guard core, each with its own handler list.

2. Shape​

The AppException base, ExceptionLayer and ErrorMessage live in sinew_models, so ViewState and Result can carry them without a dependency cycle. Everything else about errors lives here.

Who provides the message​

A failure's text comes from one of two places, and Sinew never mixes them:

SourceWhenLocalized bySinew's part
The backendthe failure arrived as a response with a readable message: success: false, an error body with a message, validation field messagesthe backend, from the request's Accept-Language headersend Accept-Language from the app's current locale (Network), and show the backend's text unchanged
LocalSinew or the app decided it: no connection, timeout, parse error, storage error, polling or retry gave up, an unexpected bug, an HTTP status with no readable message, a domain rulethe app's UI layer, from a message key and argumentsprovide the key and arguments. sinew_l10n ships English and Indonesian for every built-in key.
// in sinew_models
ErrorMessage =
Server(text: String) // shown as is
| Local(key: String, args: Map<String, Any> = {}, fallback: String) // resolved at render time

AppException { …, message: ErrorMessage }

Why keys, resolved at render time. An exception is created in the data layer, which has no locale and no UI context. If it carried finished text, that text would be frozen in the page state: switching the app's language would leave an error on screen in the old language. So a local message stays a key plus arguments until the page renders it. ViewState.Failed and Result.failure keep the AppException, never pre-rendered text.

Arguments and plurals. Keys take named arguments, { "seconds": 30 } or { "attempts": 3 }. Translations use each platform's plural rules (ICU messages in Flutter ARB files, plurals in Compose resources), so "1 second" and "30 seconds" both read correctly.

The built-in types​

TypeCodeRetryableExtra fieldsMessage sourceLocal key (when local)English fallback
NoInternetConnectionExceptionNICyeslocalsinew.error.noInternetYou're offline. Check your connection and try again.
RequestTimeOutExceptionRTOyeslocalsinew.error.timeoutThis is taking too long. Please try again.
CancelException (Flutter only)CNLnonever shown
ValidationExceptionVALnofieldErrors: Map<String, List<String>>server (form message and field messages)sinew.error.validation if the backend sent no form messagePlease check the highlighted fields.
UnauthorizedExceptionUNAnoserver if readable, else localsinew.error.unauthorizedYour session has ended. Please sign in again.
ForbiddenExceptionFORnoserver if readable, else localsinew.error.forbiddenYou don't have access to this.
NotFoundExceptionNTFnoserver if readable, else localsinew.error.notFoundThis item no longer exists.
ConflictExceptionCFLnoserver if readable, else localsinew.error.conflictThis was changed by someone else. Reload and try again.
RateLimitedExceptionRTLyesretryAfter: Duration?server if readable, else localsinew.error.rateLimited (seconds), or sinew.error.rateLimitedNoTimeToo many attempts. Try again in {seconds} seconds.
ServerExceptionSRVnostatus: Intserver if readable, else localsinew.error.serverSomething went wrong on our side.
ServiceUnavailableExceptionSUNyesstatus: Intserver if readable, else localsinew.error.unavailableThe service is busy. Please try again.
ApiErrorExceptionAEnoserver(the backend's message)
UndefinedErrorResponseExceptionUERnostatus: Intlocalsinew.error.genericSomething went wrong.
ParseExceptionDFnolocalsinew.error.genericSomething went wrong.
LocalStorageCorruptionExceptionLSCnolocalsinew.error.genericSomething went wrong.
StorageFullExceptionSFnolocalsinew.error.storageFullYour device is out of space.
StorageReadExceptionSRyeslocalsinew.error.genericSomething went wrong.
StorageWriteExceptionSWyeslocalsinew.error.genericSomething went wrong.
PollingTimeOutExceptionPTOyeslocalsinew.error.stillProcessingThis is still processing. Check again later.
UnexpectedExceptionGENnolocalsinew.error.genericSomething went wrong.

Every type takes module, layer, function and an optional cause from the guard that creates it. The codes and the sinew. key prefix are reserved: an app's own exceptions use other codes and their own key prefix.

"Server if readable." For an HTTP error status, the network guard reads the error body with the app's error-body reader (see Network). If it finds a message, the exception carries Server(text). If not (an empty body, an HTML error page from a proxy), it carries the local key.

Domain exceptions and their messages​

An app's domain exception is local by nature, so it carries a key in the app's own namespace and its own translations:

class OrderNotCancellableException(module, function, reason) : AppException(module, UseCase, function) {
typeCode = "ONC"
message = Local(key = "orders.error.notCancellable", args = { "reason": reason.name },
fallback = "This order can no longer be cancelled.")
}

Rendering a message​

interface Localizer { resolve(key: String, args: Map<String, Any>): String? }   // null → not translated

error.displayMessage(localizer: Localizer?): String =
when (error.message)
Server(text) -> text
Local(key, args, fallback) -> localizer?.resolve(key, args) ?: fallback
+ " (" + error.code + ")"
PlatformThe app's LocalizerSinew's translations (sinew_l10n)
Flutterreads the app's ARB-generated localizations from the current BuildContext, then Sinew'sSinewLocalizations.delegate, added to the app's localizationsDelegates. ARB files for en and id. An app overrides a key by defining it in its own ARB.
Kotlinreads Compose Multiplatform resources (composeResources/values-xx/strings.xml) or Android string resources for the current locale, then Sinew'svalues/ (English) and values-in/ (Indonesian) string resources in sinew-l10n. An app overrides a key with its own resource of the same name, resolved first by its Localizer.
RuleWhy
Pages render with displayMessage(localizer) every time they build, from the AppException in stateA language switch updates errors already on screen
The code ends every displayed messageA support ticket or screenshot points straight at the failing call
Validation field messages are shown beside their fields without a codeThe code on every field is noise. The form-level message carries it.
CancelException is never displayedCancelling isn't a failure
Server text is never translated or rewritten on the deviceThe backend owns it, and two translations of one message would disagree
A missing translation falls back to the English fallback, never to the raw keyUsers never see sinew.error.timeout

Handlers​

ExceptionHandler(
matches: (Throwable) -> Boolean, // "is this a timeout?"
transform: (Throwable) -> AppException, // "make a RequestTimeOutException for this call"
)

A guard receives its handler list already bound to its module and function, because each package builds its list with a function such as apiHandlers(module, function). The first match wins, so lists go from specific to general. This package contributes one handler that every list ends with: envelopeDataMissing, which turns EnvelopeDataMissing into ParseException.

The shared guard core​

Every guard is processCall with a layer and a handler list:

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 unchanged
catch Throwable e
mapped = try { handlers.first { it.matches(e) }?.transform(e) } catch any { null } // a broken handler counts as unmatched
?: UnexpectedException(module, layer, function, cause = e)
if mapped is UnexpectedException or ParseException:
try { CrashReporter.current.recordNonFatal(mapped, stackTrace of e) } catch any { } // reporting never crashes
Result.failure(mapped)

On Flutter, Result.failure carries error = mapped and the stack trace. On Kotlin it's Result.failure(mapped). Neither carries pre-rendered text.

processCall is public, so an app can guard calls Sinew doesn't know about, such as a third-party SDK call, with its own handler list and the same codes.

The use-case guard​

// Flutter
processUseCase(module, function, body: () -> T): Result<T> = processCall(module, function, UseCase, [], body)

// Kotlin
processResult(module, function, layer = UseCase, body: suspend () -> T): Result<T>

There are no handlers. A repository failure arrives as an AppException and passes through. A domain exception thrown by a rule passes through too. A bug in the use case's own code becomes UnexpectedException with layer UC and is reported.

Inside a use case, results are read in a straight line. requireData() (Flutter) and getOrThrow() (Kotlin) return the data or throw the failure's AppException, which the guard passes through unchanged:

placeOrder(cart) = processUseCase("ORD", "PO") {
stock = inventory.check(cart).requireData() // a failure here ends the use case, with its own code
if (!stock.allAvailable) throw OutOfStockException("ORD", "PO", stock.missing)
orders.create(cart).requireData()
}

Reporting​

interface CrashReporter { recordNonFatal(error: AppException, stackTrace) }

CrashReporter.none // the default: records nothing
CrashReporter.install(reporter) // once, at the app's composition root
CrashReporter.current // what the guards call

This is the one documented global in Sinew. Tests install a recording reporter and reset to none in teardown. Reports use the English fallback and the code, never a translated text.

3. API​

NameSignature (pseudocode)PackageFor
ErrorMessageServer(text) | Local(key, args = {}, fallback)modelswho provides the text
AppException.messagemessage: ErrorMessagemodelsevery exception's user-facing message
built-in typesXException(module, layer, function, cause = null, …extra fields); each has typeCode, message, retryableexceptionthe classification in the table
ExceptionHandlerExceptionHandler(matches: (Throwable) -> Boolean, transform: (Throwable) -> AppException)exceptionone rule in a handler list
envelopeDataMissingenvelopeDataMissing(module, function): ExceptionHandlerexceptionthe shared last handler
processCallprocessCall(module, function, layer, handlers, block): Result<T>exceptionthe shared guard core, public
processUseCase (Flutter)processUseCase(module, function, body): Future<Result<T>>exceptionthe use-case guard
processResult (Kotlin)suspend processResult(module, function, layer = UseCase, body): Result<T>exceptionthe use-case guard
requireData (Flutter)Result<T>.requireData(): Tmodelsstraight-line reading inside a use case
CrashReporterinterface, none, install(reporter), currentexceptionwhere bugs are reported
Localizerinterface { resolve(key, args): String? }exceptionhow the app resolves local keys
displayMessageAppException.displayMessage(localizer: Localizer?): Stringexceptionthe text a page shows, ending with the code
SinewLocalizations (Flutter)SinewLocalizations.delegate, SinewLocalizations.of(context)l10nEnglish and Indonesian for the sinew. keys
sinew-l10n resources (Kotlin)values/strings.xml, values-in/strings.xml, SinewStrings.resolve(key, args)l10nthe same, as Compose resources

4. Build steps​

  1. ErrorMessage and the message field on AppException (in sinew_models).
  2. The 20 built-in types with codes, retryable flags, extra fields, local keys and English fallbacks. A test checks that every code and every key is unique and that every key starts with sinew..
  3. ExceptionHandler and the envelopeDataMissing handler.
  4. processCall, test first. Tests:
    • success returns the value;
    • a lower-layer AppException passes through with its original code and message;
    • a matched error is transformed;
    • an unmatched error becomes UnexpectedException and is reported once;
    • a ParseException is reported once;
    • a handler whose transform throws counts as unmatched;
    • a crash reporter that throws doesn't break the guard;
    • Kotlin: CancellationException is rethrown, not returned, and not reported.
  5. CrashReporter with none, install and current, and a recording test reporter.
  6. The use-case guard, with three tests:
    • a domain exception passes through;
    • a requireData() failure keeps the repository's code;
    • a bug becomes ORD-UC-…-GEN and is reported.
  7. Localizer and displayMessage, with tests:
    • server text shown unchanged;
    • a local key resolved;
    • a missing translation falls back to English, never the raw key;
    • arguments substituted;
    • the code appended, except on validation field messages.
  8. sinew_l10n: English and Indonesian for every built-in key. A test checks that both languages define every key and the same arguments, and that plural forms exist for seconds and attempts.

Done when

  • Every built-in type exists with a unique code, a unique sinew. key (when local) and an English fallback with no technical detail.
  • processCall passes every test in step 4 on every target.
  • A failure from a repository keeps its R code and its message through a use case.
  • Switching the app's language while an error is on screen shows the error in the new language (local messages), or unchanged (server messages).
  • sinew_l10n covers every built-in key in English and Indonesian.
  • sinew_exception depends on no HTTP client, storage library or localization framework. Only sinew_l10n touches a localization framework.

5. Edge cases​

CaseDecided behavior
A 2xx envelope with success: falseNetwork's processApiCall throws ApiErrorException with Server(envelope.message), or ValidationException when errors is present. It's an AppException, so processCall passes it through.
A backend error with an empty messageTreated as "not readable": the type's local key is used. An ApiErrorException with an empty message falls back to sinew.error.generic.
The backend ignores Accept-LanguageIts text is shown as it arrives. Sinew doesn't translate server text.
The app's language changes while a request is in flightThe request carries the old Accept-Language. Its server message arrives in the old language; the next request uses the new one. Local messages render in the new language immediately.
A language Sinew doesn't ship (say, Japanese)The app's Localizer resolves the keys from its own translations. Any key it doesn't define falls back to English.
An app wants different wording for a built-in messageIt defines the same sinew. key in its own translations. Its Localizer checks the app's strings before Sinew's.
An envelope with no payloadIt throws EnvelopeDataMissing. The envelopeDataMissing handler turns it into ParseException (local, sinew.error.generic), which is reported.
A non-Exception throwable (Kotlin Error, Dart Error)Becomes UnexpectedException and is reported, except Kotlin's CancellationException.
An app exception reusing a built-in code or the sinew. prefixNot allowed. A debug-build check compares the app's codes and keys against the reserved list.
Secrets in messagesLocal fallbacks are fixed text. Server text is the backend's responsibility. Technical detail stays in cause, which only reaches the crash reporter.
Two tests run in parallel with different reportersCrashReporter is process-wide, so tests that assert on reporting run serially or share one recording reporter that they clear.

Implementation​

1. Why​

This note gives the Kotlin types for the base exception package:

  • the built-in exceptions;
  • processCall and processResult;
  • CrashReporter;
  • the Localizer.

It also covers sinew-l10n: the English and Indonesian translations of the local message keys, as Compose Multiplatform resources.

2. Shape​

Types​

public sealed interface ErrorMessage {
public data class Server(val text: String) : ErrorMessage
public data class Local(val key: String, val args: Map<String, Any> = emptyMap(), val fallback: String) : ErrorMessage
}

public class NoInternetConnectionException(module: String, layer: ExceptionLayer, function: String, cause: Throwable? = null) :
AppException(module, layer, function, cause) {
override val typeCode: String = "NIC"
override val retryable: Boolean = true
override val errorMessage: ErrorMessage = ErrorMessage.Local("sinew.error.noInternet", fallback = "You're offline. Check your connection and try again.")
}

public class RateLimitedException(module: String, layer: ExceptionLayer, function: String, public val retryAfter: Duration?,
override val errorMessage: ErrorMessage, cause: Throwable? = null) : AppException(module, layer, function, cause) {
override val typeCode: String = "RTL"
override val retryable: Boolean = true
}

Every built-in type follows one of these two shapes: a fixed local message, or an errorMessage passed in by the handler (server text when readable). CancelException doesn't exist on Kotlin: cancellation is rethrown, never a failure.

Handlers and guards​

public class ExceptionHandler(
public val matches: (Throwable) -> Boolean,
public val transform: (Throwable) -> AppException,
)

public fun envelopeDataMissing(module: String, function: String, layer: ExceptionLayer): ExceptionHandler =
ExceptionHandler({ it is EnvelopeDataMissing }) { ParseException(module, layer, function, cause = it) }

public suspend fun <T> processResult(module: String, function: String, layer: ExceptionLayer = ExceptionLayer.UseCase,
block: suspend () -> T): Result<T> = processCall(module, function, layer, emptyList(), block)

Inside a use case, results are read with getOrThrow(), which throws the failure's AppException for the guard to pass through:

override suspend fun invoke(cart: Cart): Result<Order> = processResult("ORD", "PO") {
val stock = inventory.check(cart).getOrThrow()
if (!stock.allAvailable) throw OutOfStockException("ORD", ExceptionLayer.UseCase, "PO", stock.missing)
orders.create(cart).getOrThrow()
}

Rendering and sinew-l10n​

public fun interface Localizer { public fun resolve(key: String, args: Map<String, Any>): String? }

public fun AppException.displayMessage(localizer: Localizer?): String = when (val m = errorMessage) {
is ErrorMessage.Server -> m.text
is ErrorMessage.Local -> localizer?.resolve(m.key, m.args) ?: m.fallback
} + " ($code)"

sinew-l10n ships the translations as Compose Multiplatform resources:

sinew-l10n/src/commonMain/composeResources/
├── values/strings.xml # English
├── values-in/strings.xml # Indonesian, Android's legacy qualifier "in"
└── values-id/strings.xml # Indonesian again, the ISO code "id": the same file, so every target resolves it
<!-- values/strings.xml (excerpt) -->
<string name="sinew_error_noInternet">You're offline. Check your connection and try again.</string>
<plurals name="sinew_error_rateLimited">
<item quantity="one">Too many attempts. Try again in %1$d second.</item>
<item quantity="other">Too many attempts. Try again in %1$d seconds.</item>
</plurals>

<!-- values-in/strings.xml (excerpt) -->
<string name="sinew_error_noInternet">Kamu sedang offline. Periksa koneksimu lalu coba lagi.</string>
<plurals name="sinew_error_rateLimited">
<item quantity="other">Terlalu banyak percobaan. Coba lagi dalam %1$d detik.</item>
</plurals>

Resource names replace the dots in keys with underscores (sinew.error.noInternet → sinew_error_noInternet). SinewStrings maps a key to its resource and formats the arguments:

@Composable public fun rememberSinewLocalizer(appStrings: Localizer? = null): Localizer   // the app's strings first, then Sinew's

Indonesian texts​

KeyIndonesian
sinew.error.noInternetKamu sedang offline. Periksa koneksimu lalu coba lagi.
sinew.error.timeoutProsesnya terlalu lama. Silakan coba lagi.
sinew.error.validationPeriksa kembali isian yang ditandai.
sinew.error.unauthorizedSesimu telah berakhir. Silakan masuk kembali.
sinew.error.forbiddenKamu tidak punya akses ke halaman ini.
sinew.error.notFoundData ini sudah tidak tersedia.
sinew.error.conflictData ini sudah diubah oleh orang lain. Muat ulang lalu coba lagi.
sinew.error.rateLimitedTerlalu banyak percobaan. Coba lagi dalam {seconds} detik.
sinew.error.rateLimitedNoTimeTerlalu banyak percobaan. Coba lagi sebentar lagi.
sinew.error.serverTerjadi kesalahan di sistem kami.
sinew.error.unavailableLayanan sedang sibuk. Silakan coba lagi.
sinew.error.genericTerjadi kesalahan.
sinew.error.storageFullPenyimpanan perangkatmu penuh.
sinew.error.stillProcessingMasih diproses. Coba cek lagi nanti.

The same texts are used by the Flutter ARB files, so both platforms read identically.

3. API​

NameKotlin signature
built-in typespublic class XException(module, layer, function, …, cause: Throwable? = null) : AppException
ExceptionHandlerpublic class ExceptionHandler(matches: (Throwable) -> Boolean, transform: (Throwable) -> AppException)
processCall, processResultpublic suspend fun <T>, see Error Model
CrashReporterpublic fun interface CrashReporter, CrashReporter.None, CrashReporter.install(r), CrashReporter.current
Localizerpublic fun interface Localizer
displayMessagepublic fun AppException.displayMessage(localizer: Localizer?): String
rememberSinewLocalizer@Composable public fun rememberSinewLocalizer(appStrings: Localizer? = null): Localizer (sinew-l10n)
SinewStringspublic object SinewStrings { public suspend fun resolve(key: String, args: Map<String, Any>): String? } (sinew-l10n, non-Compose callers)

4. Build steps​

  1. The 19 built-in types: the base table's 20 minus CancelException, which Kotlin doesn't have, with the uniqueness test for codes and keys.
  2. ExceptionHandler, envelopeDataMissing, processResult, tested as in the base note.
  3. CrashReporter with an AtomicReference-backed current, safe to read from any thread.
  4. Localizer and displayMessage, tested with a fake localizer.
  5. sinew-l10n: both strings.xml files, and a test that every key exists in both languages with the same arguments.

Done when

  • Every built-in type exists with its code, key and fallback, on all four targets.
  • sinew-l10n resolves every key in English and Indonesian, including plurals.

5. Edge cases​

CaseDecided behavior
Indonesian is in on older Android and id elsewhereBoth values-in/ and values-id/ ship with the same content, so no target depends on how its locale code is mapped. A test checks the two files are identical.
An app in a language Sinew doesn't shipIts own Localizer covers the keys, and anything missing falls back to English.
CrashReporter.install called twiceThe second call replaces the first. A debug-build warning is logged.
Decided by default (revisit during implementation)
  • Indonesian wording in the table above. It uses the informal "kamu", which is common in consumer apps. A product that needs the formal "Anda" overrides the keys in its own strings.
  • Resource names use underscores in place of the dots in keys, because resource names can't contain dots.