spicyapiDocs
Main content

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_…" }
  • code is a business code, not an HTTP status. 200 means success.
  • On failure data is absent (not null) — you get code, msg and request_id.
  • msg is for humans and its wording changes. Never branch on it; branch on code.
  • request_id is also returned in the X-Request-Id header. 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

codeHTTPMeaningRetry?
200200Success
400400Malformed request or invalid parametersNo. Fix and resend
401401Credential invalid or expiredNo. Change keys
403403Not permitted to access this resourceNo
404404Does not exist (or is not yours)No
409409Conflicts with current stateSometimes — see below
429429Too many requestsYes. Back off per Retry-After
500500Something broke on our sideYes. Back off and retry

Balance and caps

codeHTTPMeaningWhat to do
40201402Balance does not cover the estimated costTop up. Check GET /api/v1/chat/credit
40202402A key or account spend cap was reachedRaise 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

codeHTTPMeaningWhat to do
40301403This key may not call this modelWiden the key's model allowlist in the console
40302403Source address is not on the key's IP allowlistAdd it, or drop the IP restriction on that key
40303403Service is not offered in this regionCannot be worked around
40304403Compatibility code for the legacy age-verification submission flow; task creation does not return itDo not build a mature-model prerequisite around it
40305403Mature capability is disabled on this keyRe-enable it only for lawful, adult use; see Content policy
40306403The request content is against platform policyChange the content. A retry gets the same answer

None of these are retryable.

Supply

codeHTTPMeaningRetry?
50301503No upstream route currently available for this modelYes. 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.

SituationCharged?
createTask returns anything other than 202No. The hold and the task row share one transaction; no task means no hold
createTask returns 202, task later failed / expiredNo. The hold is released in full
A historical task is canceledNo. This is a compatibility state, not a currently available action
createTask returns 202, task succeededYes, 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 keyNo. 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

CaseRetry?
Rare concurrent createTask conflict with the same key and unchanged payloadYes. 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 keyNo. Stop and fix key ownership; retrying cannot change the conflict
Artefact still being copiedYes. 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:

JavaScript
// 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');
}
Python
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")
Go
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:

  1. request_id — give us this and we can find the exact request.
  2. code — the tables above.
  3. 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.

On this page