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)" }
}
public struct RpcError: Error, Sendable, Equatable {
public let code: Int
public let message: String
public let type: String?
public let data: JSONValue?
}
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.