Every non-2xx response and every transport failure raises a CryptohopperError. Same shape across every SDK in every language.
import { CryptohopperError } from "@cryptohopper/sdk";
try {
await ch.hoppers.get(999999);
} catch (err) {
if (err instanceof CryptohopperError) {
console.error({
code: err.code, // "NOT_FOUND"
status: err.status, // 404
message: err.message, // "Hopper not found"
serverCode: err.serverCode, // Cryptohopper numeric code, e.g. 4100
ipAddress: err.ipAddress, // server-reported caller IP
retryAfterMs: err.retryAfterMs, // only set for 429
});
}
}code |
HTTP | When you'll see it | Recover by |
|---|---|---|---|
VALIDATION_ERROR |
400, 422 | Missing or malformed parameter | Fix the request; the message says which param |
UNAUTHORIZED |
401 | Token missing, wrong, or revoked | Re-auth; your refresh flow kicks in |
DEVICE_UNAUTHORIZED |
402 | Internal Cryptohopper device-auth flow rejected you | You shouldn't see this via the public API; contact support if you do |
FORBIDDEN |
403 | Scope missing, or IP not allowlisted | Check err.ipAddress; add to allowlist or grant the scope on the app |
NOT_FOUND |
404 | Resource or endpoint doesn't exist | Check the ID; check you're using the latest SDK |
CONFLICT |
409 | Resource is in a conflicting state (e.g. starting a new backtest while one is queued) | Cancel the existing job or wait |
RATE_LIMITED |
429 | Bucket exhausted | The SDK auto-retries; see Rate Limits |
SERVER_ERROR |
500–502, 504 | Cryptohopper's end | Retry with back-off; report if persistent |
SERVICE_UNAVAILABLE |
503 | Planned maintenance or downstream outage | Respect Retry-After; retry |
NETWORK_ERROR |
— | DNS failure, TCP reset, TLS handshake failure | Retry; check your network |
TIMEOUT |
— | Hit the client-side timeoutMs |
Retry; bump timeoutMs if the operation is legitimately slow |
UNKNOWN |
any | Anything else the SDK didn't recognise | Check err.status and err.message |
These strings are stable across SDK versions — don't parse them at runtime, compare with ===.
- 429 retries are automatic up to
maxRetries(default 3). The SDK parsesRetry-Afterand honours it. See Rate Limits for the exact algorithm. - Everything else you handle yourself.
SERVER_ERRORandNETWORK_ERRORare often transient and benefit from retry;UNAUTHORIZED/VALIDATION_ERROR/NOT_FOUNDnever do.
A pragmatic pattern:
async function withRetry<T>(
fn: () => Promise<T>,
opts: { maxAttempts?: number; baseMs?: number } = {},
): Promise<T> {
const { maxAttempts = 5, baseMs = 500 } = opts;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (!(err instanceof CryptohopperError)) throw err;
const transient = ["SERVER_ERROR", "SERVICE_UNAVAILABLE", "NETWORK_ERROR", "TIMEOUT"].includes(err.code);
if (!transient || attempt === maxAttempts) throw err;
const wait = err.retryAfterMs ?? baseMs * 2 ** (attempt - 1);
await new Promise((r) => setTimeout(r, wait));
}
}
throw new Error("unreachable");
}Don't include RATE_LIMITED in the list — the SDK already retries it internally.
When you're piping SDK errors through a logger that wants plain objects:
function errToObject(err: unknown) {
if (err instanceof CryptohopperError) {
return {
kind: "cryptohopper",
code: err.code,
status: err.status,
message: err.message,
server_code: err.serverCode,
ip_address: err.ipAddress,
retry_after_ms: err.retryAfterMs,
};
}
return {
kind: "unknown",
message: err instanceof Error ? err.message : String(err),
};
}