-
Notifications
You must be signed in to change notification settings - Fork 0
Error Handling
Every non-2xx response and every transport failure throws Cryptohopper\Sdk\Exceptions\CryptohopperException. Same shape as the Node/Python/Go/Ruby/Rust/Dart/Swift SDKs but laid out idiomatically as a PHP class with getter methods.
use Cryptohopper\Sdk\Exceptions\CryptohopperException;
try {
$client->hoppers->get(999_999);
} catch (CryptohopperException $e) {
$e->getErrorCode(); // 'NOT_FOUND'
$e->getStatus(); // 404
$e->getMessage(); // human-readable, from the server
$e->getServerCode(); // ?int — numeric Cryptohopper code (or null)
$e->getIpAddress(); // ?string — server-reported caller IP (or null)
$e->getRetryAfterMs(); // ?int — only set on 429
}CryptohopperException extends RuntimeException, so any catch on \Exception or \Throwable will catch it too — but typed catch (CryptohopperException $e) is clearer and lets you handle SDK errors separately from your own.
PHP's built-in Exception::getCode() is typed as int and reserved for legacy numeric error codes. The shared SDK taxonomy is string-based ('UNAUTHORIZED', 'RATE_LIMITED', etc.) so we expose it on getErrorCode(). getCode() on a CryptohopperException returns 0 (the parent's default) — don't read it.
getErrorCode() |
HTTP | When you'll see it | Recover by |
|---|---|---|---|
VALIDATION_ERROR |
400, 422 | Missing or malformed parameter | Fix the request; the message says which parameter |
UNAUTHORIZED |
401 | Token missing, wrong, or revoked | Re-auth |
DEVICE_UNAUTHORIZED |
402 | Internal Cryptohopper device-auth flow rejected you | Shouldn't happen via the public API; contact support |
FORBIDDEN |
403 | Scope missing, or IP not allowlisted | Check $e->getIpAddress(); add to allowlist or grant the scope |
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 | 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 |
SERVICE_UNAVAILABLE |
503 | Planned maintenance or downstream outage | Respect getRetryAfterMs(); retry |
NETWORK_ERROR |
— | DNS failure, TCP reset, TLS handshake failure | Retry; check your network |
TIMEOUT |
— | Hit the client-side timeout:, including cURL's CURLE_OPERATION_TIMEDOUT
|
Retry; bump timeout if legitimately slow |
UNKNOWN |
any | Anything else the SDK didn't recognise | Inspect $e->getStatus() and $e->getMessage()
|
These strings are stable across SDK versions and are also exposed as CryptohopperException::KNOWN_CODES — compare with ===, never substring-match.
Iter-16 fixed a subtle bug where Guzzle's ConnectException covered both real timeouts (cURL errno 28) and connection failures, but the SDK was labelling all of them NETWORK_ERROR. After the fix, errno 28 maps to TIMEOUT and other connect failures (errno 7 = couldn't connect, errno 6 = couldn't resolve host) map to NETWORK_ERROR.
The practical difference: a TIMEOUT is recoverable by raising your timeout: value and retrying; a NETWORK_ERROR typically requires fixing your network (DNS, firewall, outbound NAT). Treat them differently in your retry logic.
catch (CryptohopperException $e) {
match ($e->getErrorCode()) {
'UNAUTHORIZED', 'FORBIDDEN', 'DEVICE_UNAUTHORIZED' => $this->handleAuth($e),
'VALIDATION_ERROR' => $this->logger->warning('bad request', ['msg' => $e->getMessage()]),
'NOT_FOUND' => null, // expected
'CONFLICT' => $this->retryOrSkip(),
'RATE_LIMITED' => $this->backoffHard($e->getRetryAfterMs()),
'SERVER_ERROR', 'SERVICE_UNAVAILABLE' => $this->scheduleRetry(),
'NETWORK_ERROR', 'TIMEOUT' => $this->retryNow(),
default => throw $e, // future-proof: re-throw unknown codes
};
}Future-proof your handler with a default => throw $e arm — the server can return new codes the SDK predates, and they pass through as raw strings on getErrorCode().
use Cryptohopper\Sdk\Exceptions\CryptohopperException;
final class WithRetry {
/** @var array<string, true> */
private const TRANSIENT = [
'SERVER_ERROR' => true,
'SERVICE_UNAVAILABLE' => true,
'NETWORK_ERROR' => true,
'TIMEOUT' => true,
];
public static function call(callable $fn, int $maxAttempts = 5, int $baseMs = 500): mixed {
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
return $fn();
} catch (CryptohopperException $e) {
if (!isset(self::TRANSIENT[$e->getErrorCode()]) || $attempt === $maxAttempts) {
throw $e;
}
$waitMs = $e->getRetryAfterMs() ?? $baseMs * (2 ** ($attempt - 1));
usleep($waitMs * 1000);
}
}
throw new RuntimeException('unreachable'); // pacify static analysers
}
}
WithRetry::call(fn() => $client->hoppers->list());Don't include RATE_LIMITED in TRANSIENT — the SDK already retries 429s internally. Wrapping it here would multiply attempts unhelpfully.
For Monolog (Laravel, Symfony, plain PHP), pull individual fields:
use Psr\Log\LoggerInterface;
catch (CryptohopperException $e) {
$logger->error('Cryptohopper request failed', [
'code' => $e->getErrorCode(),
'status' => $e->getStatus(),
'server_code' => $e->getServerCode(),
'ip' => $e->getIpAddress(),
'retry_after_ms' => $e->getRetryAfterMs(),
'message' => $e->getMessage(),
]);
}When reporting to an exception tracker, attach the SDK metadata as context so it's queryable in the dashboard:
catch (CryptohopperException $e) {
\Sentry\configureScope(function (\Sentry\State\Scope $scope) use ($e): void {
$scope->setContext('cryptohopper', [
'code' => $e->getErrorCode(),
'status' => $e->getStatus(),
'server_code' => $e->getServerCode(),
'ip_address' => $e->getIpAddress(),
]);
$scope->setTag('cryptohopper.code', $e->getErrorCode());
});
\Sentry\captureException($e);
throw $e;
}The tag lets you filter Sentry events by cryptohopper.code to see, e.g., a spike of RATE_LIMITED after a deploy.
If you're surfacing SDK errors to API clients, register a custom renderer in app/Exceptions/Handler.php:
public function register(): void {
$this->renderable(function (CryptohopperException $e, Request $request) {
return response()->json([
'error' => [
'code' => $e->getErrorCode(),
'message' => $e->getMessage(),
],
], match (true) {
$e->getStatus() >= 400 && $e->getStatus() < 500 => 400,
default => 502, // upstream failure
});
});
}This propagates Cryptohopper's error semantics (4xx vs 5xx vs transport) without leaking server-side detail.
A common pattern is "retry only what's worth retrying":
use Cryptohopper\Sdk\Exceptions\CryptohopperException;
$retryable = ['SERVER_ERROR', 'SERVICE_UNAVAILABLE', 'NETWORK_ERROR', 'TIMEOUT'];
$fatal = ['UNAUTHORIZED', 'FORBIDDEN', 'VALIDATION_ERROR', 'NOT_FOUND', 'CONFLICT'];
catch (CryptohopperException $e) {
if (in_array($e->getErrorCode(), $retryable, true)) {
$this->dispatchRetryJob($payload, after: 30);
} elseif (in_array($e->getErrorCode(), $fatal, true)) {
$this->logger->warning('fatal cryptohopper error', ['code' => $e->getErrorCode()]);
$this->notifyUser($e->getMessage());
} else {
// RATE_LIMITED reaches here only if SDK retries exhausted.
// UNKNOWN / new codes also fall through — log and re-throw.
$this->logger->error('unexpected cryptohopper error', ['code' => $e->getErrorCode()]);
throw $e;
}
}CryptohopperException::getCode() (inherited) returns 0 always. The string error code is on getErrorCode(). Reading getCode() won't break anything, it just won't tell you what you want — easy to typo on autocomplete. The two existing because PHP's Exception constructor wants an int $code, and the parent class is \RuntimeException from PHP's stdlib.
Pages
Other SDKs
Resources