OPS-015
Integration libraries that return results instead of throwing
A low balance, a daily cap and a rate limit are normal faucet flow. Wrapping every call in try/catch to branch on them reads worse than returning a result array.
An API client that throws on everything forces the caller to use exceptions for ordinary control flow. On a payout path, "insufficient balance" and "daily cap reached" are expected states that your code must handle calmly, several times a day.
A consistent result array makes that natural: ok, http, status, message, data, raw. The caller branches on ok and http and never writes a catch block for a condition that is not exceptional.
Keep the library stateless as well. It should not know about your database. Idempotency keys, webhook deduplication and order lookups belong to the application, which is the only part that knows what a duplicate means.
/**
* One shape from every call. Never throws for an API outcome.
* ok true only on confirmed success
* http HTTP status; 0 means the request never completed — retry, never refund
* status body status when present, else the HTTP code
* message human-readable, straight from the provider
* data decoded payload
* raw the body, for the log
*/
function result(bool $ok, int $http, int $status, string $message, mixed $data = null, string $raw = ''): array
{
return compact('ok', 'http', 'status', 'message', 'data', 'raw');
}
// The caller reads like ordinary code:
$r = $api->send($key, $address, $units, 'USDT');
if ($r['ok']) {
mark_paid($withdrawalId, $r['data']['payout_id']);
} elseif ($r['http'] === 0) {
hold_for_retry($withdrawalId, $r['message']); // unknown
} elseif ($r['http'] === 429) {
hold_for_retry($withdrawalId, 'cap or rate limit'); // expected, not an error
} else {
reject($withdrawalId, $r['message']); // a definite no
}
// The one place throwing IS right: a programmer error, caught in testing.
// toUnits('12.123456789') throws — nine decimals is a bug upstream, not an
// API outcome.
Using it
Keep the raw body in the result. When a provider changes a field name, the log of raw bodies is what tells you when it happened.
Write the library once and copy it verbatim into each build rather than regenerating it. A tested integration that drifts per project is three subtly different bugs.
Version the library and record which version a build carries, so a fix can be traced across sites.
What bites people
Returning false instead of a result array loses the distinction between refused and unreachable, which is exactly the distinction that decides whether to refund.
Do not let the library write to your database. The moment it does, it needs your schema and stops being portable.
Keep exceptions for malformed input. Swallowing those hides real bugs behind a false-ish return.