Skip to content

Error Handling

Pim Feltkamp edited this page Apr 27, 2026 · 1 revision

Error Handling

Every non-2xx response and every transport failure throws a CryptohopperError. Same shape across every SDK in every language.

The error type

public class CryptohopperError(
    public val code: String,           // "UNAUTHORIZED", "RATE_LIMITED", …
    message: String,
    public val status: Int,            // HTTP status; 0 for transport failures
    public val serverCode: Int? = null,
    public val ipAddress: String? = null,
    public val retryAfterMs: Long? = null,
) : RuntimeException(message)

It extends RuntimeException, so callers catch it like any other Kotlin exception. There are no checked exceptions.

Error codes

CryptohopperError.KNOWN_CODES // Set<String>
//   "VALIDATION_ERROR", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND",
//   "CONFLICT", "RATE_LIMITED", "SERVER_ERROR", "SERVICE_UNAVAILABLE",
//   "DEVICE_UNAUTHORIZED", "NETWORK_ERROR", "TIMEOUT", "UNKNOWN"
code HTTP Meaning
VALIDATION_ERROR 400 / 422 Invalid parameters
UNAUTHORIZED 401 Token missing or invalid
DEVICE_UNAUTHORIZED 402 Mobile device not authorized (internal flow)
FORBIDDEN 403 Missing scope or IP whitelist mismatch
NOT_FOUND 404 Resource or endpoint not found
CONFLICT 409 State conflict (e.g. duplicate create)
RATE_LIMITED 429 Rate limit exceeded — SDK auto-retries by default
SERVER_ERROR 500 / 502 / 504 Upstream error
SERVICE_UNAVAILABLE 503 Server in maintenance
NETWORK_ERROR Transport failure (DNS, connection refused)
TIMEOUT Total request deadline exceeded
UNKNOWN Anything else

Server-side codes the SDK doesn't know about pass through verbatim on code. Compare with ==, never substring-match.

Pattern matching

Three idiomatic ways to handle a typed error in Kotlin.

when for many codes

try {
    ch.hoppers.list()
} catch (e: CryptohopperError) {
    when (e.code) {
        "NOT_FOUND" -> println("no such hopper")
        "UNAUTHORIZED", "FORBIDDEN" ->
            println("auth problem; IP we sent: ${e.ipAddress ?: "unknown"}")
        "RATE_LIMITED" ->
            println("rate limited; retry after ${e.retryAfterMs ?: 0}ms")
        else -> throw e
    }
}

if for one specific code

try {
    ch.hoppers.get(999_999_999)
} catch (e: CryptohopperError) {
    if (e.code == "NOT_FOUND") {
        println("no such hopper")
    } else {
        throw e
    }
}

Smart catches with runCatching

val result = runCatching { ch.user.get() }
    .recoverCatching { e ->
        if (e is CryptohopperError && e.code == "UNAUTHORIZED") {
            // re-run OAuth, retry…
            null
        } else throw e
    }

Use runCatching when you want to thread the success/failure through a pipeline rather than wrap it in try.

Forward compatibility

If the server adds a new error code the SDK doesn't know about, it surfaces verbatim on code:

} catch (e: CryptohopperError) {
    if (e.code !in CryptohopperError.KNOWN_CODES) {
        // e.code is the literal server string, e.g. "MAINTENANCE_WINDOW"
        log.warn("unknown server code: ${e.code}")
    }
}

code is a stable string and matches what the server sends — never substring-match. Use == or set membership.

What serverCode is

The serverCode field is the numeric code integer from the JSON envelope. Cryptohopper uses this to identify rate-limit buckets and other server-side diagnostic codes. You usually don't need it — code (the string) is the right level of abstraction. It's exposed for advanced cases.

Transport vs server errors

status == 0 means no response was received — the request never made it to the server, or the response wasn't HTTP. This happens for:

  • NETWORK_ERROR — DNS failure, connection refused, TLS handshake error, etc.
  • TIMEOUT — total request deadline exceeded

For both, status == 0 and retryAfterMs == null. The exception's message field carries the underlying transport error.

OkHttp timeouts

The SDK configures OkHttp with connectTimeout, readTimeout, writeTimeout, and callTimeout all set to the configured timeout (default 30 seconds). callTimeout is the total deadline including retries — bytes dribbling slowly can't keep the connection open arbitrarily long, unlike a pure idle timeout.

If you bring your own OkHttpClient via Builder.httpClient(...), configure all four fields yourself. The default OkHttpClient.Builder() has no timeouts, which is rarely what you want.

Cancellation vs errors

Cancelling the surrounding coroutine scope cancels the in-flight request. The cancellation propagates as a kotlinx.coroutines.CancellationException — that's a special case in coroutines and should be re-thrown, not caught:

try {
    ch.hoppers.list()
} catch (e: CryptohopperError) {
    // handle SDK error
} catch (e: CancellationException) {
    throw e  // never swallow
}

A bare catch (e: Exception) catches CancellationException too — avoid it for this reason.

Next steps