Errors
The typed errors the SDK raises, the three different 402s, and every status and code the API answers with.
The SDK raises three kinds of error.
PaymentRequiredError
Thrown when the proxy or getBalance() answers 402. Carries the top-up link, when
there is one.
import { PaymentRequiredError } from "@fleeex/sdk";
try {
await client.chat.completions.create({ model, messages });
} catch (err) {
if (err instanceof PaymentRequiredError) {
if (err.topupUrl) redirect(err.topupUrl);
else showSupportMessage(); // no link, see below
}
}| Property | Type | Notes |
|---|---|---|
status | 402 | Always 402. |
code | string | undefined | Which of the three 402s this is. |
topupUrl | string | undefined | Signed, short-lived link to send the end user through Checkout. Genuinely optional, so always check it before redirecting. |
correlationId | string | undefined | Trace id for support and debugging. |
message | string | Generic by design, and never reveals whether a wallet exists. |
The onPaymentRequired hook (see options) fires
for the same condition before the error is thrown.
The three 402s
402 is not one situation. The code says which, and the three have different remedies.
Branching on it is the difference between telling a user to add funds and telling them
something they can't fix by paying.
| Code | Meaning | topupUrl | What to do |
|---|---|---|---|
PAYMENT_REQUIRED | The (app, user) has no authorized wallet, or the balance can't cover the call. | always | Send the end user to topupUrl to authorize or fund. |
APP_SPEND_CAP_EXCEEDED | Your app reached the monthly spend cap the user set on it, and their wallet may still be full. | usually | Tell them this app hit the cap you set, which they can raise, rather than "add funds". The cap is checked once up front and re-checked when the charge is written; the second one loses a race rarely, and raises the same code without a link. |
WALLET_SUSPENDED | The wallet is blocked after money went back out (a refund or chargeback the balance couldn't cover). | never | Stop retrying and route the end user to support. Funding the wallet does not lift the block. |
So treat topupUrl as optional on any 402, whatever the code, because two of the three
can arrive without one.
The first two are deliberately indistinguishable from each other in the message: "no wallet" and "empty wallet" return an identical body, because telling them apart would reveal whether a given user has a Fleeex wallet at all. A user who revoked your app is indistinguishable from one who never authorized it, for the same reason.
WALLET_SUSPENDED carries no amount, no shortfall and no dispute wording. Your app isn't
a party to it, and the end user's payment history isn't its business.
FleeexApiError
Any other non-2xx from a Fleeex billing helper (getConnection(), getUsageSummary(),
or a non-402 from getBalance()), for example a 400 when a redirectUri isn't on the
app's allow-list.
| Property | Type | Notes |
|---|---|---|
status | number | HTTP status. |
code | string | undefined | Error code from the envelope. |
correlationId | string | undefined | Trace id. |
OpenAI errors
For the proxy path, any error other than 402 is raised by the OpenAI SDK as
OpenAI.APIError (and its subclasses). Handle these exactly as you would with the OpenAI
SDK, but read the code, because several are Fleeex-specific and mean different things.
import OpenAI from "openai";
import { PaymentRequiredError } from "@fleeex/sdk";
try {
await client.chat.completions.create({ model, messages });
} catch (err) {
if (err instanceof PaymentRequiredError) {
if (err.topupUrl) redirect(err.topupUrl);
} else if (err instanceof OpenAI.APIError) {
console.error(err.status, err.code, err.message);
} else {
throw err;
}
}Every status and code
The whole data-plane surface: proxied calls, GET /v1/models, and the billing helpers.
| Status | Code | When |
|---|---|---|
400 | BAD_REQUEST | An unsupported or disabled model (the message echoes what you sent, not what an alias resolved to). An unsupported parameter, named, with the accepted ones listed. A malformed body, or one nesting more than 32 levels deep. A missing or malformed x-fleeex-user. A redirectUri outside the app's allow-list. |
401 | UNAUTHORIZED | The API key is missing, invalid or revoked. |
402 | PAYMENT_REQUIRED | No authorized wallet, or insufficient balance. Always carries topupUrl. |
402 | APP_SPEND_CAP_EXCEEDED | The per-app monthly cap. Usually carries topupUrl, see above. |
402 | WALLET_SUSPENDED | The wallet is blocked after a claw-back. Never carries topupUrl. |
403 | APP_SUSPENDED | Your app was suspended by a Fleeex operator. Deliberately not a 401: the credential is valid and correctly names your app, so rotating a key or retrying changes nothing. Stop calling. An in-flight stream is not cut, but the next request is refused. |
403 | MODEL_NOT_ENTITLED | The model is real but reserved for other apps, see Choosing a model. It reaches no provider and reserves nothing, and GET /v1/models simply omits it, so a caller that lists first never meets this. |
404 | NOT_FOUND | GET /v1/models/{model} when no model or alias by that name is served. |
413 | PAYLOAD_TOO_LARGE | The request body exceeds 1 MB. |
429 | TOO_MANY_REQUESTS | Fleeex's per-app rate limit. It buckets on your app, so every key of the app, a sandbox key included, shares one limit. |
429 | CONCURRENT_STREAM_LIMIT | This end user already has the maximum number of streams open. Sent with a Retry-After header, and refused before any reservation or provider call, so it costs nothing. Retry when one of that user's streams finishes; the remedy is fewer streams in flight, not a slower request rate. |
429 | UPSTREAM_THROTTLED | The model provider throttled the call. Retriable, unlike UPSTREAM_PROVIDER_ERROR. |
502 | UPSTREAM_PROVIDER_ERROR | The provider rejected the call (model access, an unsupported invocation, a schema outside its subset, a timeout, an outage). The reservation is released and nothing is charged. |
500 | INTERNAL_SERVER_ERROR | A Fleeex fault. The detail is logged server-side against the correlationId; the response says nothing more. |
A refused call costs nothing: a 400, 402, 403 or the stream limit short-circuits
before the provider is reached, so no completion is ever generated and then discarded.
The error envelope
Fleeex's backend emits a consistent body, exposed as the FleeexErrorBody type.
interface FleeexErrorBody {
error?: { code?: string; message?: string; correlationId?: string };
topupUrl?: string;
}correlationId is the one thing worth logging on your side: it's the handle that ties
your failed call to the server-side record of it. It's echoed on every response, not only
errors, and you can supply your own with an x-correlation-id request header.
A message is never a stack trace, a cloud SDK error or an internal identifier, and for a
500, never anything but the generic text above.