spicyapiDocs
Main content

Idempotency

Retry after a timeout without generating twice or paying twice.

createTask accepts an Idempotency-Key header. Submitting twice with the same key from the same account and the same API key returns the task created the first time: no second queue entry, hold or charge.

POST /api/v1/jobs/createTask
Idempotency-Key: order-8814-render-1

Why you want it

A network timeout leaves you in the worst kind of uncertainty: the request may never have arrived, or it may have arrived and only the response was lost. Without an idempotency key you get two bad options — don't retry (the user waits for nothing) or retry (possibly generating twice and paying twice).

With a key, retrying is safe: either the task is created, or you get back the one created a moment ago.

Only task-creating endpoints read this header

Both createTask and retry accept Idempotency-Key. For retry, the key is scoped to the source task and the retry action, so keys used with different source tasks do not collide. Read endpoints and short-lived credential issuers ignore the header.

The window is 24 hours

A key stays valid for 24 hours. After that, the same key is treated as a fresh request and creates a new task.

Twenty-four hours is a compromise. Client retries finish in seconds or minutes, so the window only needs to cover that; but the longer the window, the longer a key cannot be reused — with a hard-coded key you would find you can only submit one task per day.

Choosing keys

  • One key per logical request, not per HTTP call. Retries must reuse the same key or the whole thing is pointless.
  • Derive it from an identifier you already have: order-8814-render-1 beats a random UUID, because you can recompute it after a process restart.
  • When there is nothing to derive from, generate a UUID before the first attempt and persist it; retries read the stored value.
  • Keys are claimed within your account and need not be globally unique, but every retry for one logical request must also use the same API key. Maximum length is 128 characters.

A sibling API key cannot replay it

The idempotency mapping is claimed account-wide while task visibility is isolated by API key. A sibling key using an already claimed idempotency key receives 409 Conflict, and the response does not reveal the original task ID. This prevents one key from probing another integration's tasks through replay.

Never generate the key inside the retry loop

// Wrong: a new key per attempt, which defeats the whole mechanism
for (let i = 0; i < 3; i++) {
  await create({ 'Idempotency-Key': crypto.randomUUID() });
}

// Right: generate once, outside the loop
const key = crypto.randomUUID();
for (let i = 0; i < 3; i++) {
  await create({ 'Idempotency-Key': key });
}

What a replayed request looks like

Identical to the first response — same taskId, same estimatedCost, and state still queued, because that is the snapshot taken at creation.

There is no header or field telling you that you hit a replay. To learn the task's current state, take the taskId to recordInfo.

Concurrent submissions with the same key

When two requests from the same API key carrying the same idempotency key arrive at once, exactly one creates a task and the other receives that same taskId. The decision rests on a database uniqueness constraint rather than a check-then-insert — under concurrency, both checks would come back empty, both tasks would be created and both holds would stand.

In rare cases you will see:

{ "code": 409, "msg": "幂等键冲突,请稍后重试" }

That means the key is taken but the corresponding task cannot be found. Back off a few hundred milliseconds and retry with the same key.

Full example

JavaScript
import crypto from 'node:crypto';

async function createTaskWithRetry(payload, { attempts = 4 } = {}) {
  // Outside the loop. This is the whole point of the snippet.
  const idempotencyKey = crypto.randomUUID();
  // These are business codes from the response body, not HTTP statuses.
  const retryable = new Set([409, 429, 500, 50301]);

  for (let i = 0; i < attempts; i++) {
    let body;
    try {
      const res = await fetch('https://api.spicyapi.ai/api/v1/jobs/createTask', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.SPICY_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(payload),
      });
      body = await res.json();
    } catch {
      // Transport failure: the request may well have landed.
      // Retrying with the same key is safe.
      await sleep(2 ** i * 500 + Math.random() * 300);
      continue;
    }

    if (res.status === 202 && body.code === 200) return body.data;
    if (!retryable.has(body.code)) {
      throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
    }
    await sleep(2 ** i * 500 + Math.random() * 300);
  }
  throw new Error('out of retries');
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
Python
import os, random, time, uuid, requests

# These are business codes from the response body, not HTTP statuses.
RETRYABLE = {409, 429, 500, 50301}


def create_task_with_retry(payload: dict, attempts: int = 4) -> dict:
    # Outside the loop. This is the whole point of the snippet.
    idempotency_key = str(uuid.uuid4())

    for i in range(attempts):
        try:
            res = requests.post(
                "https://api.spicyapi.ai/api/v1/jobs/createTask",
                headers={
                    "Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
                    "Content-Type": "application/json",
                    "Idempotency-Key": idempotency_key,
                },
                json=payload,
                timeout=30,
            )
            body = res.json()
        except requests.RequestException:
            # Transport failure: the request may well have landed.
            # Retrying with the same key is safe.
            time.sleep(2**i * 0.5 + random.random() * 0.3)
            continue

        if res.status_code == 202 and 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")})')
        time.sleep(2**i * 0.5 + random.random() * 0.3)

    raise RuntimeError("out of retries")

Transport failures deserve the same key too

A thrown fetch, a connect timeout, a read timeout — in all of these you do not know whether the request arrived. That is precisely what the idempotency key is for: retry with the same key and the worst outcome is getting the earlier task back.

On this page