Error Model
Everything the SDK throws is a MeroError. It’s an enum, so you switch over
the failure modes you care about.
MeroError
Section titled “MeroError”public enum MeroError: Error, Sendable { case http(HTTPError) // non-2xx HTTP response case authRevoked(reason: String, http: HTTPError) // token_reuse / token_revoked — terminal case rpc(RpcError) // JSON-RPC error from a contract case network(String) // transport/connectivity failure case emptyResponse(String) // expected a body, got none case authenticationFailed(String) case noCredentials // authenticate() with nothing to use case noRefreshToken case decoding(String) // response didn't match the expected type}MeroError also conforms to LocalizedError, so error.localizedDescription
gives a human-readable string.
HTTPError
Section titled “HTTPError”public struct HTTPError: Error, Sendable, Equatable { public let status: Int public let statusText: String public let url: String public let headers: [String: String] public let bodyText: String? // raw body if available (capped ~64 KiB) public var message: String { "HTTP \(status) \(statusText)" }}Typed refusals (core rc.83)
Section titled “Typed refusals (core rc.83)”Since core rc.83 most refusals carry a precise status instead of a blanket 500,
and the body is still {"error": "..."}. Method errors from
/contexts/{id}/query and /contexts/{id}/intents add type and data, the same
pair a JSON-RPC error carries. Read them without parsing bodyText yourself:
public struct ErrorRefusal: Sendable, Equatable { public let message: String? // "error" public let type: String? // e.g. "FunctionCallError" public let data: JSONValue?}
extension HTTPError { public var refusal: ErrorRefusal? { get } }extension MeroError { public var httpStatus: Int? { get } // .http / .authRevoked public var refusal: ErrorRefusal? { get } public var errorType: String? { get } // RPC `type`, or the refusal's `type`}| Status | Means (rc.83) |
|---|---|
400 |
Invalid input, invitation or proof; invalid TEE policy; a method error (FunctionCallError) |
403 |
Not a member or admin; client key out of scope; owner-proof refusal |
404 |
Unknown device, upgrade or context; namespace out of an account’s scope; a route the node does not serve |
409 |
Upgrade in progress; already exists; stale nonce, state hash or owner counter; write method on /query |
503 |
Authority undecidable; join key delivery timed out (was a 504 before rc.83) |
The cases of MeroError did not change, so an existing switch keeps compiling.
RpcError
Section titled “RpcError”public struct RpcError: Error, Sendable, Equatable { public let code: Int public let message: String public let type: String? public let data: JSONValue?
public var isReadOnlyWriteRefused: Bool { get } // type == "ReadOnlyWriteRefused" public var refusedContextId: String? { get } // data.context_id}ReadOnlyWriteRefused (core rc.83) means the node’s role in the context
(ReadOnly, ReadOnlyTee or RelayTee) discards its writes. Retrying the same
node never helps: send the write to a node with a writing role, or as an intent.
Since rc.83 an execute also needs context:execute in a scoped token, and an
account-authenticated session cannot execute at all.
Handling pattern
Section titled “Handling pattern”do { let post: Post = try await mero.rpc.execute( contextId: "…", method: "get_post", argsJson: ["id": "42"])} catch let error as MeroError { switch error { case .rpc(let rpc): // Contract-level error print("contract error \(rpc.code): \(rpc.message)", rpc.data as Any) case .authRevoked: // Refresh token reused/revoked — session is over promptSignIn() case .http(let http): if http.status == 403 { print("permission denied") } else { print(http.message) } case .network(let detail): print("network error: \(detail)") default: print(error.localizedDescription) }}How 401s are handled
Section titled “How 401s are handled”The transport handles 401 internally: it runs a single token refresh and
retries the original request (see
the token lifecycle). You only
see an error if the refresh itself fails:
- Access token expired but refresh works → transparent; no error surfaces.
- Refresh fails (expired refresh token) → surfaces as
MeroError.http/.authenticationFailed; callawait mero.clearToken()and re-authenticate. x-auth-error: token_reuse | token_revoked→ never retried; surfaces asMeroError.authRevoked(reason:http:)and the bundle is cleared for you.
Retries & timeouts
Section titled “Retries & timeouts”Each request has a per-request timeout of MeroConfig.timeout (default 10s).
The retry engine is internal (not caller-invokable), but it’s worth knowing what
it does: up to 3 attempts with exponential backoff (base 250 ms, ±20%
jitter). It retries transient failures only — network errors, HTTP 5xx/429,
and URLError timeouts / connection loss / DNS failures. It never retries 4xx
(including 401/403) or MeroError.authRevoked. A 401 token_expired is
handled separately by the refresh path below, not by this retry loop.
Transport
Section titled “Transport”The retry engine above lives inside the transport and isn’t part of the public API. The public transport surface:
public protocol HttpClient: Sendable { func send<T: Decodable>(_ req: HttpRequest) async throws -> T func sendVoid(_ req: HttpRequest) async throws func sendRaw(_ req: HttpRequest) async throws -> (Data, HeadResult) func head(_ path: String, headers: [String: String]) async throws -> HeadResult func download(_ req: HttpRequest, to fileURL: URL) async throws -> HeadResult}URLSessionHttpClient is the built-in implementation; reach it as mero.http.
Its TransportHooks (getAuthToken, refreshToken, onTokenRefresh,
onAuthRevoked) are what Mero wires into its own token state — you don’t set
these yourself when using Mero.