Skip to content

Error Model

Everything the SDK throws is a MeroError. It’s an enum, so you switch over the failure modes you care about.

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.

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)" }
}

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.

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.

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)
}
}

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; call await mero.clearToken() and re-authenticate.
  • x-auth-error: token_reuse | token_revoked → never retried; surfaces as MeroError.authRevoked(reason:http:) and the bundle is cleared for you.

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.

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.