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.
ValidationError on a bare list of messages
Four write methods — subscriptions->cancel(), ->resume(), ->renew() and checkoutSessions->create()'s state-conflict cases — report an illegal state transition as a bare list of strings rather than a field-keyed object, because none of those calls has a request body to key a message on. The first message becomes getMessage(); fieldErrors stays empty, same as any 400 that isn't field-shaped.
use Suqo\Exception\ValidationError;
try {
$suqo->subscriptions->resume($id);
} catch (ValidationError $e) {
echo $e->getMessage(), PHP_EOL; // e.g. "This subscription is already cancelled."
// $e->fieldErrors is empty here — there was no field to key it on.
}subscriptions->create() has one case of the same shape:
["You already have a subscription to this product."], raised when the customer already holds a live subscription on another billing period of that product.
404 isn't always a mistake
Two places where a 404 is the routine end of something's life rather than an error to debug:
-
checkoutSessions->read()serves a session only while it is open. Once it is paid, or once it has expired, reading it answers 404 — and that body carries the session's ownreturn_urlbesidedetailwhenever the session existed, which is enough to send the buyer onward:use Suqo\Exception\NotFoundError; try { $detail = $suqo->checkoutSessions->read($publicId); } catch (NotFoundError $e) { $returnUrl = is_array($e->rawBody) ? ($e->rawBody['return_url'] ?? '') : ''; }rawBodykeeps wire names, so it isreturn_urlthere, notreturnUrl. -
subscriptions->updateBillingCycle()never 404s: it names the subscription in the body rather than the path, so an unknown or inactive id comes back as a 400 keyed onsubscription_idinstead.
Rate limiting is real for one endpoint
checkoutSessions->create() is the one rate-limited call in the SDK today — 20 sessions a minute per account by default. Past that, the API answers 429 and RateLimitError carries retryAfter from the response header:
use Suqo\Exception\RateLimitError;
try {
$session = $suqo->checkoutSessions->create($params);
} catch (RateLimitError $e) {
sleep((int) ceil($e->retryAfter ?? 1.0));
}It is a write, so it is not retried automatically — see Create checkout sessions. Nothing else in the SDK enforces a published quota yet; see Not yet implemented.
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, resume, renew, updateBillingCycle, and every checkoutSessions or webhooks call that changes state — 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.