Error Model
Every failure is thrown as a subclass of MeroException
(com.calimero.mero.http.MeroException), an abstract base extending Exception.
Catch the specific subtypes you care about.
The exceptions
Section titled “The exceptions”abstract class MeroException(message: String, cause: Throwable? = null) : Exception(message, cause)
// Non-2xx HTTP response. bodyText is capped at ~64 KiB.open class HttpException( val status: Int, val bodyText: String, val headers: Map<String, String>, val url: String,) : MeroException("HTTP $status ($url)") { val isAuthError: Boolean // status == 401 || status == 403 val errorMessage: String? // body's "error", when the body is core's JSON shape val errorType: String? // body's "type", e.g. "FunctionCallError" (rc.83) val errorData: JsonElement? // body's "data" beside the type val authError: String? // the x-auth-error header}
// Terminal auth failure — the refresh-token family was reused or revoked.// Never retried; the SDK has already cleared its tokens. Prompt re-login.class AuthRevokedException( val reason: String, // the x-auth-error header, e.g. "token_reuse" | "token_revoked" status: Int, bodyText: String, headers: Map<String, String>, url: String,) : HttpException(status, bodyText, headers, url)
// JSON-RPC error returned by a WASM contract (from mero.rpc).open class RpcException( val code: Int, message: String, val type: String? = null, val data: JsonElement? = null,) : MeroException(message)
// core rc.83: this node's role in the context is read-only (ReadOnly, ReadOnlyTee,// RelayTee), so the write was discarded — nothing committed, signed or published.class ReadOnlyWriteRefusedException(val contextId: String?, …) : RpcException(…)
// Transport failure with no HTTP response (DNS, reset, timeout).class NetworkException(message: String, cause: Throwable? = null) : MeroException(message, cause)
// Invalid client state (no credentials to authenticate, no refresh token, …).class MeroStateException(message: String) : MeroException(message)AuthRevokedException extends HttpException, so catch (e: HttpException) also
catches it — catch AuthRevokedException first to distinguish it. Other
cases include EmptyResponseException-style states surfaced via MeroException
subtypes for empty bodies, failed authentication, and decode failures.
Typed refusals (core 0.11.0-rc.83)
Section titled “Typed refusals (core 0.11.0-rc.83)”Core stopped answering most refusals with a blanket 500. The body kept its
shape — {"error": "<message>"} — but the status now says what happened:
| Status | Means |
|---|---|
400 |
Invalid input, invitation or proof; TEE policy invalid; an empty rootProof |
403 |
Not a member or admin; owner-proof refused; a client key used outside its bindings |
404 |
Unknown device, upgrade or context; a namespace outside an account’s scope |
409 |
Already exists; stale nonce or state hash; stale owner-op counter (re-read, re-sign) |
503 |
Authority undecidable; join key delivery timed out |
HttpException.errorMessage reads the error text without parsing bodyText
yourself. The method-error routes — queryContext and performIntent — add
JSON-RPC’s pair beside it: {"error": …, "type": "FunctionCallError", "data": "<the method's message>"}, read as errorType / errorData. A 400 there is
the app refusing the call; the node is healthy.
JSON-RPC itself gained ReadOnlyWriteRefused, thrown as
ReadOnlyWriteRefusedException. Retrying on the same node cannot help: write
through a node holding a writing role, or through a relay under a warrant. A
scoped token also needs context:execute for the call since rc.83, or the
refusal is a FunctionCallError naming it.
Handling pattern
Section titled “Handling pattern”Catch most-specific first:
import com.calimero.mero.http.AuthRevokedExceptionimport com.calimero.mero.http.HttpExceptionimport com.calimero.mero.http.NetworkExceptionimport com.calimero.mero.rpc.ReadOnlyWriteRefusedExceptionimport com.calimero.mero.rpc.RpcException
try { val result: MyResult = mero.rpc.execute(contextId = ctx, method = "get_value") return result} catch (e: ReadOnlyWriteRefusedException) { // this node may read the context but not write it println("read-only here: ${e.contextId}")} catch (e: RpcException) { // contract-level error — the WASM returned an error object println("contract error ${e.code}: ${e.message}")} catch (e: AuthRevokedException) { // refresh token reused/revoked — session is over promptSignIn()} catch (e: HttpException) { // transport-level error — 4xx/5xx, typed since rc.83 when (e.status) { 403 -> println("permission denied: ${e.errorMessage}") 409 -> println("conflict — re-read and retry: ${e.errorMessage}") else -> println("HTTP ${e.status}: ${e.errorMessage ?: e.bodyText}") }} catch (e: NetworkException) { // offline / DNS / reset}How 401s are handled
Section titled “How 401s are handled”The SDK handles 401 token_expired internally: it refreshes the token and
retries the request once (see
the token lifecycle). You
only see a 401 HttpException if the refresh itself fails. If the token was
reused or revoked, you get an AuthRevokedException instead.
A 401 from /auth/refresh itself means the stored pair can never be refreshed
— the typical case is a session minted before the node moved to core rc.83, whose
tokens lack the now-required key_id. The SDK clears the store when that happens,
so mero.isAuthenticated turns false; send the user to login.
Retries & timeouts
Section titled “Retries & timeouts”Each request has a per-request timeout of MeroConfig.timeoutMs (default
10 000 ms), applied to connect/read/write.
com.calimero.mero.http.withRetry is a public helper that wraps a whole logical
operation with backoff:
suspend fun <T> withRetry(attempts: Int = 3, baseDelayMs: Long = 250, block: suspend () -> T): TIt makes up to 3 attempts with exponential backoff (base 250 ms, ±20%
jitter). It retries transient failures only — NetworkException and
HttpException with status >= 500 || status == 429. It never retries other
4xx responses or an AuthRevokedException.
val contexts = withRetry { mero.admin.getContexts() }