|
| 1 | +# Error Handling |
| 2 | + |
| 3 | +Every non-2xx response and every transport failure raises `CryptohopperError`. Same shape across every official Cryptohopper SDK in every language. |
| 4 | + |
| 5 | +```python |
| 6 | +from cryptohopper.errors import CryptohopperError |
| 7 | + |
| 8 | +try: |
| 9 | + ch.hoppers.get(999_999) |
| 10 | +except CryptohopperError as err: |
| 11 | + print({ |
| 12 | + "code": err.code, # "NOT_FOUND" |
| 13 | + "status": err.status, # 404 |
| 14 | + "message": str(err), # human-readable |
| 15 | + "server_code": err.server_code, # numeric Cryptohopper code (or None) |
| 16 | + "ip_address": err.ip_address, # server-reported caller IP (or None) |
| 17 | + "retry_after_ms": err.retry_after_ms, # only set on 429 |
| 18 | + }) |
| 19 | +``` |
| 20 | + |
| 21 | +## Error code catalog |
| 22 | + |
| 23 | +| `code` | HTTP | When you'll see it | Recover by | |
| 24 | +|---|---|---|---| |
| 25 | +| `VALIDATION_ERROR` | 400, 422 | Missing or malformed parameter | Fix the request; the message says which parameter | |
| 26 | +| `UNAUTHORIZED` | 401 | Token missing, wrong, or revoked | Re-auth; your refresh flow kicks in | |
| 27 | +| `DEVICE_UNAUTHORIZED` | 402 | Internal Cryptohopper device-auth flow rejected you | You shouldn't see this via the public API; contact support if you do | |
| 28 | +| `FORBIDDEN` | 403 | Scope missing, or IP not allowlisted | Check `err.ip_address`; add to allowlist or grant the scope on the app | |
| 29 | +| `NOT_FOUND` | 404 | Resource or endpoint doesn't exist | Check the ID; check you're using the latest SDK | |
| 30 | +| `CONFLICT` | 409 | Resource is in a conflicting state | Cancel the existing job or wait | |
| 31 | +| `RATE_LIMITED` | 429 | Bucket exhausted | The SDK auto-retries; see [Rate Limits](Rate-Limits.md) | |
| 32 | +| `SERVER_ERROR` | 500–502, 504 | Cryptohopper's end | Retry with back-off; report if persistent | |
| 33 | +| `SERVICE_UNAVAILABLE` | 503 | Planned maintenance or downstream outage | Respect `Retry-After`; retry | |
| 34 | +| `NETWORK_ERROR` | — | DNS failure, TCP reset, TLS handshake failure | Retry; check your network | |
| 35 | +| `TIMEOUT` | — | Hit the client-side `timeout` | Retry; bump `timeout` if the operation is legitimately slow | |
| 36 | +| `UNKNOWN` | any | Anything else the SDK didn't recognise | Inspect `err.status` and `str(err)` | |
| 37 | + |
| 38 | +These strings are stable across SDK versions — compare with `==`, never substring-match. |
| 39 | + |
| 40 | +## Catching specific codes |
| 41 | + |
| 42 | +`CryptohopperError` is a single exception type with a discriminating `code` attribute. There are no per-code subclasses (deliberate — keeps the API small and matches every other Cryptohopper SDK): |
| 43 | + |
| 44 | +```python |
| 45 | +try: |
| 46 | + ch.hoppers.create(data) |
| 47 | +except CryptohopperError as err: |
| 48 | + if err.code == "VALIDATION_ERROR": |
| 49 | + # Missing field. Show the user. |
| 50 | + log.warning("Bad payload: %s", err) |
| 51 | + elif err.code in {"UNAUTHORIZED", "FORBIDDEN"}: |
| 52 | + # Token problem. Re-auth. |
| 53 | + refresh_and_retry() |
| 54 | + elif err.code == "RATE_LIMITED": |
| 55 | + # SDK already retried `max_retries` times. Back off harder. |
| 56 | + sleep_long_and_retry() |
| 57 | + else: |
| 58 | + # Not an SDK-known case — log and re-raise. |
| 59 | + log.exception("Unexpected Cryptohopper error") |
| 60 | + raise |
| 61 | +``` |
| 62 | + |
| 63 | +The literal type `KnownCryptohopperErrorCode` is exported if you want a `match` statement with an exhaustiveness check: |
| 64 | + |
| 65 | +```python |
| 66 | +from cryptohopper.errors import CryptohopperError, KnownCryptohopperErrorCode |
| 67 | +from typing import assert_never |
| 68 | + |
| 69 | +def handle(err: CryptohopperError) -> str: |
| 70 | + code = err.code # narrowed by mypy if you cast it |
| 71 | + match code: |
| 72 | + case "UNAUTHORIZED" | "FORBIDDEN": |
| 73 | + return "auth" |
| 74 | + case "RATE_LIMITED": |
| 75 | + return "throttled" |
| 76 | + case "VALIDATION_ERROR": |
| 77 | + return "bad-request" |
| 78 | + case "NETWORK_ERROR" | "TIMEOUT": |
| 79 | + return "transient" |
| 80 | + case _: |
| 81 | + return "other" |
| 82 | +``` |
| 83 | + |
| 84 | +Note: at runtime `err.code` is `str`, not the `Literal` union — the server can return codes the SDK doesn't recognise (unprefixed pass-through). Don't write code that crashes if a new code appears. |
| 85 | + |
| 86 | +## The retry surface |
| 87 | + |
| 88 | +- **429 retries are automatic** up to `max_retries` (default 3). The SDK parses `Retry-After` and honours it. See [Rate Limits](Rate-Limits.md) for the algorithm. |
| 89 | +- **Everything else you handle yourself.** `SERVER_ERROR` and `NETWORK_ERROR` are often transient and benefit from retry; `UNAUTHORIZED` / `VALIDATION_ERROR` / `NOT_FOUND` never do. |
| 90 | + |
| 91 | +## A robust retry wrapper |
| 92 | + |
| 93 | +```python |
| 94 | +import time |
| 95 | +from collections.abc import Callable |
| 96 | +from typing import TypeVar |
| 97 | +from cryptohopper.errors import CryptohopperError |
| 98 | + |
| 99 | +T = TypeVar("T") |
| 100 | +TRANSIENT = {"SERVER_ERROR", "SERVICE_UNAVAILABLE", "NETWORK_ERROR", "TIMEOUT"} |
| 101 | + |
| 102 | +def with_retry(fn: Callable[[], T], *, max_attempts: int = 5, base_ms: int = 500) -> T: |
| 103 | + for attempt in range(1, max_attempts + 1): |
| 104 | + try: |
| 105 | + return fn() |
| 106 | + except CryptohopperError as err: |
| 107 | + if err.code not in TRANSIENT or attempt == max_attempts: |
| 108 | + raise |
| 109 | + wait_ms = err.retry_after_ms or base_ms * (2 ** (attempt - 1)) |
| 110 | + time.sleep(wait_ms / 1000.0) |
| 111 | + raise RuntimeError("unreachable") |
| 112 | +``` |
| 113 | + |
| 114 | +Don't include `RATE_LIMITED` in `TRANSIENT` — the SDK already retries 429s internally. Wrapping `RATE_LIMITED` in another retry layer would multiply attempts unhelpfully. |
| 115 | + |
| 116 | +## JSON-friendly error serialization |
| 117 | + |
| 118 | +When you're piping SDK errors through a structured logger: |
| 119 | + |
| 120 | +```python |
| 121 | +def err_to_dict(err: BaseException) -> dict[str, object]: |
| 122 | + if isinstance(err, CryptohopperError): |
| 123 | + return { |
| 124 | + "kind": "cryptohopper", |
| 125 | + "code": err.code, |
| 126 | + "status": err.status, |
| 127 | + "message": str(err), |
| 128 | + "server_code": err.server_code, |
| 129 | + "ip_address": err.ip_address, |
| 130 | + "retry_after_ms": err.retry_after_ms, |
| 131 | + } |
| 132 | + return { |
| 133 | + "kind": type(err).__name__, |
| 134 | + "message": str(err), |
| 135 | + } |
| 136 | +``` |
| 137 | + |
| 138 | +Plays well with structlog, loguru, and the stdlib `logging` JSON formatters. |
0 commit comments