Errors
Every business code, its HTTP status, and which retries are worth making.
The envelope
Every endpoint shares one shape:
{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }codeis a business code, not an HTTP status.200means success.- On failure
datais absent (notnull) — you getcode,msgandrequest_id. msgis for humans and its wording changes. Never branch on it; branch oncode.request_idis also returned in theX-Request-Idheader. Quote it when reporting a problem.
The HTTP status is set correctly too. Branch on either — but only one: the two diverge on the specific codes (an insufficient balance is code: 40201 with HTTP 402), and mixing them means eventually missing a case.
We recommend branching on code. 404 can only say "not found"; 40305 says this key has disabled mature capability.
`msg` is currently Chinese-only
The message strings come from the API and are not yet localised. Treat them as diagnostic text for your logs, and render your own copy to users based on code.
General codes
code | HTTP | Meaning | Retry? |
|---|---|---|---|
200 | 200 | Success | — |
400 | 400 | Malformed request or invalid parameters | No. Fix and resend |
401 | 401 | Credential invalid or expired | No. Change keys |
403 | 403 | Not permitted to access this resource | No |
404 | 404 | Does not exist (or is not yours) | No |
409 | 409 | Conflicts with current state | Sometimes — see below |
429 | 429 | Too many requests | Yes. Back off per Retry-After |
500 | 500 | Something broke on our side | Yes. Back off and retry |
Balance and caps
code | HTTP | Meaning | What to do |
|---|---|---|---|
40201 | 402 | Balance does not cover the estimated cost | Top up. Check GET /api/v1/chat/credit |
40202 | 402 | A key or account spend cap was reached | Raise the cap in the console, or wait for the daily reset |
Neither is worth retrying — balances and caps do not change because you asked again.
Permissions and compliance
code | HTTP | Meaning | What to do |
|---|---|---|---|
40301 | 403 | This key may not call this model | Widen the key's model allowlist in the console |
40302 | 403 | Source address is not on the key's IP allowlist | Add it, or drop the IP restriction on that key |
40303 | 403 | Service is not offered in this region | Cannot be worked around |
40304 | 403 | Compatibility code for the legacy age-verification submission flow; task creation does not return it | Do not build a mature-model prerequisite around it |
40305 | 403 | Mature capability is disabled on this key | Re-enable it only for lawful, adult use; see Content policy |
40306 | 403 | The request content is against platform policy | Change the content. A retry gets the same answer |
None of these are retryable.
Supply
code | HTTP | Meaning | Retry? |
|---|---|---|---|
50301 | 503 | No upstream route currently available for this model | Yes. Retry later, or pick another model |
50301 means no route could be selected at submission time: the model is unlisted, has no active pricing, or every route is tripped. One retry usually clears it, and switching to a comparable model works too.
Upstream generation failures appear as state: failed
An upstream failure after createTask has returned a task ID appears in recordInfo as state: "failed" with errorCode / errorMessage; the hold is released in full. Do not add synchronous business-code branches that are absent from the public OpenAPI contract.
Which errors cost money
In one line: only a task reaching succeeded is billed.
Everything else — synchronous error codes, failed tasks, timeouts, and historical canceled records — costs nothing. Newly accepted tasks cannot be canceled.
| Situation | Charged? |
|---|---|
createTask returns anything other than 202 | No. The hold and the task row share one transaction; no task means no hold |
createTask returns 202, task later failed / expired | No. The hold is released in full |
A historical task is canceled | No. This is a compatibility state, not a currently available action |
createTask returns 202, task succeeded | Yes, settled on actual usage but capped at the accepted hold. Unused funds are released; the platform absorbs provider overage |
Any error from recordInfo, chat/credit or common/* | No. Those endpoints are never billed |
Rate limited (429), or deduplicated by an idempotency key | No. The first was never accepted; the second returns the original task, charged once |
Failed tasks not being billed is a commitment, not best effort: even if our process dies mid-flight, a sweeper releases the held funds. You never need to reconcile it — see Billing.
When 409 is worth retrying
| Case | Retry? |
|---|---|
Rare concurrent createTask conflict with the same key and unchanged payload | Yes. Back off a few hundred milliseconds and retry with the same key. See Idempotency |
| The same key was already used for another payload or API key | No. Stop and fix key ownership; retrying cannot change the conflict |
| Artefact still being copied | Yes. Wait a few seconds and ask for the download link again |
Writing the retry
Do not retry blindly
400, 401, 40201, 40306 and friends give the same answer however many times you ask. Every attempt marked "No" above is pure waste — and each one eats into your own rate limit.
Exponential backoff with jitter, so a batch of failures does not stampede back at the same instant:
// 409 is operation-specific and must be handled at the call site.
const RETRYABLE = new Set([429, 500, 50301]);
async function callWithRetry(path, init, { attempts = 4 } = {}) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(`https://api.spicyapi.ai/api/v1${path}`, init);
const body = await res.json();
if (body.code === 200) return body.data;
if (!RETRYABLE.has(body.code)) {
throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
}
// When rate-limited the server already told you how long to wait
const retryAfter = Number(res.headers.get('retry-after'));
const backoff = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 2 ** i * 1000 + Math.random() * 500;
await new Promise((r) => setTimeout(r, backoff));
}
throw new Error('out of retries');
}import random, time, requests
# 409 is operation-specific and must be handled at the call site.
RETRYABLE = {429, 500, 50301}
BASE = "https://api.spicyapi.ai/api/v1"
def call_with_retry(method: str, path: str, attempts: int = 4, **kwargs) -> dict:
for i in range(attempts):
res = requests.request(method, f"{BASE}{path}", **kwargs)
body = res.json()
if body["code"] == 200:
return body["data"]
if body["code"] not in RETRYABLE:
raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
# When rate-limited the server already told you how long to wait
retry_after = res.headers.get("retry-after")
backoff = float(retry_after) if retry_after else 2**i + random.random() * 0.5
time.sleep(backoff)
raise RuntimeError("out of retries")package main
import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"strconv"
"time"
)
// retryable lists the codes worth another attempt. Anything else returns
// the same answer however many times you ask — and each ask eats your own
// rate limit.
// Read a real upstream generation failure from recordInfo's state.
var retryable = map[int]bool{429: true, 500: true, 50301: true}
type envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
RequestID string `json:"request_id"`
}
func callWithRetry(newReq func() (*http.Request, error), attempts int, out any) error {
for i := 0; i < attempts; i++ {
req, err := newReq()
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
// Transport-level failure: the request may well have arrived.
// On createTask, send the same Idempotency-Key or this retry
// turns into a second generation and a second charge.
time.Sleep(backoff(i, ""))
continue
}
var env envelope
decErr := json.NewDecoder(res.Body).Decode(&env)
io.Copy(io.Discard, res.Body)
res.Body.Close()
if decErr != nil {
return decErr
}
if env.Code == 200 {
return json.Unmarshal(env.Data, out)
}
if !retryable[env.Code] {
return fmt.Errorf("%d %s (request_id=%s)", env.Code, env.Msg, env.RequestID)
}
// When rate-limited the server already told you how long to wait
time.Sleep(backoff(i, res.Header.Get("Retry-After")))
}
return fmt.Errorf("out of retries")
}
// backoff trusts Retry-After when present, otherwise backs off
// exponentially with jitter so a batch of failures does not come back
// as one thundering herd.
func backoff(attempt int, retryAfter string) time.Duration {
if s, err := strconv.Atoi(retryAfter); err == nil && s > 0 {
return time.Duration(s) * time.Second
}
base := time.Duration(1<<attempt) * time.Second
return base + time.Duration(rand.Intn(500))*time.Millisecond
}When retrying createTask, always send the same Idempotency-Key — otherwise "it actually succeeded, we just lost the response" becomes a duplicate generation and a duplicate charge.
Debugging
When an error makes no sense, work down this list:
request_id— give us this and we can find the exact request.code— the tables above.- The request log in the console — every call's parameters, response, duration and cost.
The msg for 500 is always a generic "service temporarily unavailable" with no internal detail: no structure, no upstream names, no stack traces ever appear in a response. Those can only be traced through request_id.

