Errors
Every failure throws one RevenueError with a normalized code — how to branch on it, where the provider body lives, and what the client retries for you.
Every failure in revenue-sdk throws a single error type — RevenueError — with a normalized
code, so you branch on the failure without parsing provider-specific statuses or message strings.
Branch on code
import { RevenueError } from 'revenue-sdk';
try {
await client.subscriptions.changePlan({ id, product });
} catch (error) {
if (!(error instanceof RevenueError)) throw error;
switch (error.code) {
case 'unauthorized':
// credentials are wrong or revoked
break;
case 'not_found':
// the subscription or product no longer exists
break;
case 'payment_required':
// the customer's payment failed — surface it, don't retry
break;
case 'unsupported':
// this provider lacks the capability — check client.capabilities
break;
default:
if (error.retryable) {
// transient — retry with back-off, waiting error.retryAfter seconds if set
}
}
throw error;
}
RevenueErrorCode is a closed union of ten values: unauthorized, forbidden, not_found,
conflict, rate_limited, payment_required, validation, unsupported, provider_error,
network_error. Alongside code, every error carries provider, status, retryable, retryAfter,
cause, and responseBody. The full field reference, the HTTP-status mapping and the per-provider
quirks are in Error codes.
validation and unsupported are frequently raised before any HTTP request — a bad parameter, or a
capability the active provider does not have.
cause is the JS error, responseBody is the provider’s
cause is the ordinary Error chain: the TypeError fetch throws on a dead connection, or the
SyntaxError from a response that wasn’t JSON. The provider’s own error payload lives on
responseBody, installed as a non-enumerable property — console.error(error), util.inspect,
JSON.stringify(error) and reporters such as Sentry all skip it, so a provider body never lands in your
logs by accident, while error.responseBody reads it whenever you want it.
Known secrets are redacted from error.message before the error is constructed. responseBody is
deliberately not redacted — it holds customer PII no secrets list can enumerate, and
non-enumerability is what keeps it out of logs. The same caveat applies to the enumerable raw field on
every model, which serializes normally and appears on success too.
The client retries once
The client retries at most once per call: on rate_limited anywhere, and on any other retryable
error (network_error, 5xx) for reads only. A rate limit is safe to replay because the provider
rejected the request before doing any work; a transport failure is not, since the request may have
landed and no SDK write carries an idempotency key. The wait is retryAfter, or one second when the
provider sent no Retry-After, and it is skipped entirely above maxRetryAfterSeconds:
const client = createClient({
provider: polar({ accessToken: process.env.POLAR_ACCESS_TOKEN! }),
retry: { maxRetryAfterSeconds: 10 }, // 0 disables the retry entirely
});
The retry covers every page fetched inside listAll, and its wait is interrupted by the signal you
passed. usage.report is the one call it never covers — see
Usage-based billing.
Everything beyond that — exponential back-off, queues, dead-lettering — is yours to build on
retryable and retryAfter.