Handle errors
The SuqoError hierarchy in the SUQO PHP SDK.
Every failure the SDK raises derives from Suqo\Exception\SuqoError, which extends \RuntimeException. A single catch (SuqoError) is total — configuration faults included. Check the type, never the message string or the raw response shape; neither is part of the contract.
use Suqo\Exception\KycRequiredError;
use Suqo\Exception\RateLimitError;
use Suqo\Exception\SuqoError;
use Suqo\Exception\ValidationError;
try {
$suqo->subscriptions->create($params);
} catch (ValidationError $e) {
foreach ($e->fieldErrors as $field => $messages) {
echo $field, ': ', implode(', ', $messages), PHP_EOL;
}
} catch (KycRequiredError $e) {
echo $e->kycStatus; // wire `status_code`
} catch (RateLimitError $e) {
echo $e->retryAfter; // seconds, from Retry-After
} catch (SuqoError $e) {
error_log("suqo {$e->status} req={$e->requestId}: {$e->getMessage()}");
}Order matters: catch the specific types first, then SuqoError as the backstop.
The hierarchy
| Class | When |
|---|---|
AuthenticationError | 401 — a bad, revoked or wrong-environment key |
KycRequiredError | 403 with a KYC-shaped body; carries kycStatus |
PermissionDeniedError | 403 without one — the key is valid, the account may not use this endpoint |
ValidationError | 400 — the only type that populates fieldErrors |
NotFoundError | 404 — including an id from the other environment |
RateLimitError | 429 — retryAfter when the server sent one |
ServerError | 5xx, and any status the table does not name |
NetworkError | transport failure, DNS, TLS or timeout (status 0) |
CancelledError | your Cancellation token was tripped (status 0) |
SuqoConfigError | bad configuration, before any request exists (status 0) |
The Error suffix is kept rather than PHP's Exception idiom so the type names read the same across SUQO's SDKs. Every one is still a \Throwable.
What's on every error
| Property | Type | Notes |
|---|---|---|
status | int | HTTP status, or 0 for a non-HTTP failure. Also getCode(). |
requestId | string | The X-Request-Id actually sent on the failing attempt — quote it in a support ticket. Empty when the error predates a request. |
rawBody | mixed | The parsed body, wire names preserved. Null when there was none or it was not JSON. |
fieldErrors | array<string, list<string>> | Always present, empty when not applicable. |
retryAfter | ?float | Seconds, from the Retry-After header. |
getMessage() carries the server's message when the body supplied one, else a per-status default.
ValidationError — keys are wire names
fieldErrors is populated only for a 400 with a field-shaped body, and its keys are wire names (pbp_id, billing_email), not the SDK's surface names — the body is reported as it arrived.
$emailProblems = $e->fieldErrors['email'] ?? [];getMessage() is the first message in the body's own key order.
Two kinds of 403
KYC is the narrower case and is checked first, so a genuine KYC response raises KycRequiredError with kycStatus populated; anything else 403 raises PermissionDeniedError. Catch both if you want to handle every 403.
Configuration errors are inside the tree
SuqoConfigError is raised by new SuqoClient(...) and Config::resolve(...), and sits inside the SuqoError tree so one catch-all covers startup and runtime. status is 0, requestId is '' and rawBody is null, because no request exists — it is a deployment mistake rather than an API outcome.
Writes aren't automatically retried
GET requests are retried up to maxRetries times on a network failure, a timeout, a 429 or a 5xx. Writes — create, cancel, updateBillingCycle — are not, pending idempotency keys, because a blindly-retried write could double-act.
So a NetworkError on a write means the request may or may not have landed. Reconcile — list the subscription, check the webhook — rather than resending blindly. Not yet implemented covers what changes when idempotency keys ship.
CancelledError is never retried, and is deliberately distinct from the NetworkError a timeout produces: "I stopped it" and "the network stopped it" stay separable.