Handle errors
One body for every failure. Branch on its code, never on its sentence.
errors-handling.sh
# A failure answers its HTTP status and { error, code, details, requestId }: branch on the status# and the code, quote requestId to support. An order nobody paid yet is not an error here: the# page has no item.curl "https://api.qbitflow.app/v2/payments?reference=order-1042" \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: a page of Payment ({ items, hasMore }), items empty when nobody paid that ordererrors-and-retries.ts
try { const payment = await client. payments. getByReference( 'order-1042'); console.log(`order-1042 paid: ${payment.amountUsd} USD`);} catch (err) { if (err instanceof NotFoundError) { console.log('no payment for order-1042 yet'); } else if (err instanceof ValidationError) { // Refused before sending (status undefined) or by the API (400). for (const f of err.fieldErrors) console.log(`${f.field}: ${f.message}`); } else if (err instanceof ApiError) { // Branch on status and code, never on the message. Quote requestId to support. console.log(`QBitFlow error ${err.status} ${err.code} (request ${err.requestId})`); if (isRetryable(err)) console.log('transient: try again later'); } else { throw err; }}errors_and_retries.py
try: payment = client. payments. get_by_reference( "order-1042") print("paid:", payment.uuid)except qbitflow.ValidationError as exc: # status None: refused before sending for field_error in exc.field_errors: print(f"{field_error.field}: {field_error.message}")except qbitflow.NotFoundError: print("no payment for order-1042 yet")except qbitflow.ApiError as exc: # the base of every error the SDK raises print(f"QBitFlow error {exc.status} {exc.code} (request {exc.request_id})") if qbitflow.is_retryable(exc): # network, 5xx, 429: worth trying again later print("transient: try again later")errors_and_retries/main.go
payment, err := client. Payments. GetByReference( ctx, "order-1042")var ( validation *qbitflow.ValidationError notFound *qbitflow.NotFoundError apiErr *qbitflow.APIError)switch {case err == nil: fmt.Println("order-1042 paid by", payment.UUID)case errors.As(err, &validation): // StatusCode 0: refused before sending; 400: refused by the API. for _, f := range validation.FieldErrors { fmt.Printf("%s: %s\n", f.Field, f.Message) }case errors.As(err, ¬Found): fmt.Println("no payment for order-1042")case errors.As(err, &apiErr): // Every SDK error embeds an *APIError: branch on Code, quote RequestID to support. fmt.Printf("QBitFlow error %d %s (request %s), retryable: %v\n", apiErr.StatusCode, apiErr.Code, apiErr.RequestID, qbitflow.IsRetryable(err))default: log.Fatal(err)}errors-and-retries.php
try { $payment = $client->payments->getByReference( 'order-1042'); echo "order-1042 was paid by {$payment->uuid}\n";} catch ( \QBitFlow\Exceptions\ValidationException $e) { foreach ($e->fieldErrors as $fieldError) { // refused before sending (status 0), or a 400 echo "{$fieldError->field}: {$fieldError->message}\n"; }} catch ( \QBitFlow\Exceptions\NotFoundException) { echo "No payment for order-1042 yet\n";} catch ( \QBitFlow\Exceptions\ApiException $e) { // Branch on apiCode, never on the message; quote the request id to support. printf("QBitFlow error %d %s (request %s), retryable: %s\n", $e->status, $e->apiCode, $e->requestId, $e->isRetryable() ? 'yes' : 'no');}The SDKs raise one typed error per kind. Underneath, every failure answers this body:
An error body
{ "error": "Invalid request", "code": "validation_failed", "details": { "errors": [{"field":"name","message":"name must be at least 2 characters"}] }, "requestId": "0b9f6f3e-7c1a-4d8e-9a51-2f4c8e7d1a90"}error: A sentence, safe to show a person. Never branch on it.code: What to branch on, with the HTTP status.details.errors: Structured data for some codes: here, every failing field.requestId: Also theX-Request-Idheader. Log it; quote it to support.
The codes you'll handle
Most integrations only ever meet these:
validation_HTTP 400failed path/query/body failed binding or validation, or a business rule about one input (a field named in
errors)What to do: Fix the inputs
details.errorslists: eachfieldis the name you sent (dotted when nested), and every failing input is reported at once.details:
errors: [{ field, message, reason? }]; with some,max/min(decimal strings for money)forbiddenHTTP 403authenticated but not allowed (role, peer resource)
What to do: The key can't do this: the route needs an admin key, the
On-Behalf-Ofvalue isn't a member this key may act for, or a key wrote amembers.*policy (policies change in the dashboard).not_HTTP 404found unknown or out-of-scope resource; also any route hidden by a guard
What to do: Check the id and the key's mode (test or live): a resource of another space answers the same, so existence is never revealed.
unique_HTTP 409violation a unique value already exists (e.g. a duplicate
referenceoremail)What to do:
details.fieldnames the value already used in this space (the value itself is never echoed): pick another one.details:
field(never the value)merchant_HTTP 409not_ ready opening a checkout nobody could pay: the space's wallets (the organization's, while it holds the member's funds) accept no currency, or no token for a subscription; a subscription for a member whose funds the organization holds (
fundsHeld: one-time payments only until trusted) (Checkout sessions); at a payer's intent (POST /,hosted/ payments/ intent POST /), a member's checkout whose organization takes a fee on it and has no wallet on the paid currency's chain anymore (hosted/ subscriptions/ intent organizationFeeNoWallet: a guard, the fee never dropped from a new payment; removing the organization's wallet of a chain already removes that chain's currencies from its members, so an open checkout meetscurrency_first; a subscription signed before keeps billing without the fee, Members and held funds)not_ accepted details:
reason:noWallet,organizationNoWallet,noTokenWallet,fundsHeld,organizationFeeNoWalletrate_HTTP 429limit_ exceeded rate limit hit (see below)
What to do: Wait
details.(also sent as theretryAfterSeconds Retry-Afterheader), then send the same request again, with the sameIdempotency-Key: the refused one did nothing.details:
limit,periodSeconds,retryAfterSeconds;limitedBy(and what it counts) on a route with several limitsinternalHTTP 500server error
What to do: Retry a read or an idempotent create; quote
requestIdwhen asking support.timeoutHTTP 504the request ran past the server's deadline: retry (idempotent reads) or check the resource before retrying a write
What to do: Retry a read; check the resource before retrying a write.
Three rules that save debugging time
404hides existence. Another space's resource looks like none, and a test key never sees live data: check the key's mode before the id.- Every failing field comes at once in
details.errors(frequency.unitwhen nested): map them onto your form in one pass. - Retry only what's transient: a timeout, a
5xx, a429.
Avoid
Retry any write blindly
504 timeout may have done the work; a 4xx comes back until you change the request.Do
Retry creates with their key
Idempotency-Key. Any other write: read the resource first.