spicyapiDokumentacja
Budowanie z API
Treść główna

Idempotencja

Ponawiaj po przekroczeniu czasu oczekiwania bez podwójnego generowania i podwójnej opłaty.

Klucz idempotencji to etykieta przypisana do jednej operacji, na przykład „wyrenderuj wideo dla zamówienia 8814”. Jeśli to samo oznaczone żądanie dotrze do nas dwa razy, wykonamy pracę tylko raz.

createTask przyjmuje go w nagłówku Idempotency-Key. Dwukrotne wysłanie żądania z tym samym kluczem z tego samego konta i z tym samym kluczem API zwraca zadanie utworzone za pierwszym razem: nie powstaje drugi wpis w kolejce, druga blokada środków ani druga opłata.

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

Klucz jest powiązany z przesłaną treścią: modelem, całym input i callBackUrl. Kolejność pól i białe znaki nie mają znaczenia, ale każda rzeczywista zmiana już tak. Użycie tego samego klucza z innym żądaniem zwraca 409 z Idempotency-Key has already been used for a different request. Ponowienie takiego 409 nigdy nie zakończy się powodzeniem: jeśli żądanie rzeczywiście się zmieniło, jest nową operacją i wymaga nowego klucza.

Dlaczego warto go używać

Przekroczenie czasu oczekiwania w sieci pozostawia cię w wyjątkowo kłopotliwej sytuacji: żądanie mogło w ogóle nie dotrzeć albo dotarło, a utracona została tylko odpowiedź. Bez klucza idempotencji masz dwie niekorzystne możliwości — nie ponawiać (użytkownik czeka bez skutku) albo ponowić (z ryzykiem podwójnego generowania i podwójnej opłaty).

Z kluczem ponowienie jest bezpieczne: albo zadanie zostanie utworzone, albo otrzymasz zadanie utworzone chwilę wcześniej.

Które endpointy odczytują nagłówek

EndpointCo robi powtórzenie z tym samym kluczem
POST /api/v1/jobs/createTaskZwraca pierwotne zadanie w jego aktualnym stanie
POST /api/v1/jobs/retryZwraca zadanie utworzone przez ponowienie. Klucz jest przypisany do zadania źródłowego, więc ten sam klucz użyty dla różnych zadań źródłowych nie powoduje konfliktu
POST /v1/videosZwraca pierwotne zadanie
POST /api/v1/jobs/stream i endpointy tekstowe w /v1 oraz /v1betaZwraca 400 — szczegóły poniżej

Endpointy odczytu oraz endpointy wydające linki do przesyłania i pobierania plików ignorują ten nagłówek. POST /api/v1/jobs/purge nie wymaga klucza: powtórzenie tej operacji dla tego samego taskId jest już samo w sobie nieszkodliwe.

W endpointach tekstowych i strumieniowych klucz zapobiega podwójnej opłacie, ale nie odzyskuje utraconej odpowiedzi

Odpowiedź na żądanie tekstowe jest wysyłana podczas generowania i nie jest przechowywana. Ponowne użycie klucza zakończonego żądania z tą samą treścią zwraca 400 (This Idempotency-Key was already used for a completed request, and its answer cannot be replayed. Use a new Idempotency-Key to send a new request) i nie nalicza kolejnej opłaty; tekstu nie można nigdzie pobrać. Jeśli wcześniejsze żądanie się nie powiodło, powtórzenie zwraca ten sam błąd, aby umożliwić decyzję, czy spróbować ponownie z nowym kluczem. Przy innej treści żądania klucz zwraca 409.

Dlatego w tych endpointach używaj nowego klucza dla każdej nowej wiadomości, a istniejącego klucza używaj ponownie tylko przy dokładnie tym samym żądaniu po błędzie sieci, akceptując utratę odpowiedzi. Jeśli chcesz ponownie uzyskać odpowiedź, wyślij żądanie z nowym kluczem i zapłać za nie. Zobacz Tekst i strumieniowanie.

Okres ważności wynosi 24 godziny

Klucz jest ważny przez 24 godziny od momentu pierwszej akceptacji. Powtórzenia nie przedłużają tego okresu. Później ten sam klucz jest traktowany jak nowe żądanie i tworzy nowe, płatne zadanie, więc zakończ automatyczne ponawianie odpowiednio wcześniej.

Dwadzieścia cztery godziny to kompromis. Klient kończy ponowienia w ciągu sekund lub minut, więc okres ważności musi obejmować tylko ten czas; jednak im jest dłuższy, tym dłużej nie można ponownie wykorzystać klucza — przy kluczu wpisanym na stałe okazałoby się, że da się wysłać tylko jedno zadanie dziennie.

Wybór kluczy

  • Jeden klucz na logiczne żądanie, nie na każde wywołanie HTTP. Ponowienia muszą używać tego samego klucza, inaczej cały mechanizm traci sens.
  • Wyprowadź klucz z identyfikatora, który już masz: order-8814-render-1 jest lepszy od losowego UUID, ponieważ można go odtworzyć po ponownym uruchomieniu procesu.
  • Jeśli nie ma takiego identyfikatora, wygeneruj UUID przed pierwszą próbą i zapisz go (w bazie danych, przy powiązanym zamówieniu); ponowienia odczytują zapisaną wartość.
  • Klucze są rezerwowane w obrębie twojego konta i nie muszą być globalnie unikalne, ale każde ponowienie tego samego logicznego żądania musi też używać tego samego klucza API. Maksymalna długość wynosi 128 znaków.

Inny klucz API nie może odtworzyć tego żądania

Powiązanie idempotencji jest rezerwowane na poziomie konta, natomiast widoczność zadań jest izolowana według klucza API. Inny klucz API, który użyje już zarezerwowanego klucza idempotencji, otrzyma 409 Conflict (Idempotency-Key is already in use by this account), a odpowiedź nie ujawni identyfikatora pierwotnego zadania. Zapobiega to odczytywaniu zadań innej integracji przez odtwarzanie żądań. Ponawianie nie pomoże; wyślij ponowienie kluczem API, którego użyto przy pierwszej próbie.

Nigdy nie generuj klucza wewnątrz pętli ponowień

// 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 });
}

Jak wygląda odpowiedź na powtórzone żądanie

W createTask odpowiedź zawiera to samo taskId i estimatedCost, ale state jest aktualnym stanem pierwotnego zadania. Może to być queued, running lub stan końcowy. Powtórzenie nie tworzy drugiego zadania, blokady środków ani opłaty.

Żaden nagłówek ani pole nie informuje, że żądanie zostało odtworzone. Aby odczytać pełny rekord zadania, przekaż taskId do recordInfo.

Równoczesne żądania z tym samym kluczem

Gdy dwa żądania z tego samego klucza API i z tym samym kluczem idempotencji docierają jednocześnie, dokładnie jedno tworzy zadanie, a drugie otrzymuje to samo taskId. Rozstrzyga o tym ograniczenie unikalności w bazie danych, a nie sprawdzenie przed wstawieniem rekordu — przy współbieżności oba sprawdzenia mogłyby zwrócić brak rekordu, powstałyby oba zadania i utrzymałyby się obie blokady środków.

W rzadkich przypadkach wyścig ujawnia się jako:

{ "code": 409, "msg": "Idempotency key conflict, please retry shortly" }

Odczekaj kilkaset milisekund i ponów z tym samym kluczem.

Dwa inne 409 z createTask nie uzasadniają ponawiania: Idempotency-Key has already been used for a different request (zmieniono żądanie) i Idempotency-Key is already in use by this account (inny klucz API na twoim koncie już użył tego klucza). Wszystkie trzy mają code: 409, a kod programu nie powinien rozgałęziać logiki według msg, więc dopuść najwyżej jedno lub dwa ponowienia 409, a następnie pokaż błąd człowiekowi do oceny.

Pełny przykład

Funkcja przyjmuje klucz jako argument. Wylicz go na podstawie własnych rekordów (tutaj identyfikatora zamówienia), aby po ponownym uruchomieniu proces wysłał ten sam klucz.

JavaScript
// These are business codes from the response body, not HTTP statuses.
const RETRYABLE = new Set([429, 500, 50301]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (attempt) => sleep(2 ** attempt * 500 + Math.random() * 300);

async function createTaskWithRetry(payload, idempotencyKey, { attempts = 4 } = {}) {
  let conflicts = 0;

  for (let i = 0; i < attempts; i++) {
    // Declared outside try so the checks below can read them.
    let res;
    let body;
    try {
      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 or unreadable body: the request may well have landed.
      // Retrying with the same key is safe.
      await backoff(i);
      continue;
    }

    if (res.status === 202 && body.code === 200) return body.data;

    // Only one of the three 409s is a transient race, so cap 409 retries at one.
    const retry = body.code === 409 ? ++conflicts <= 1 : RETRYABLE.has(body.code);
    if (!retry) {
      throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
    }
    await backoff(i);
  }
  // Still unknown whether the task exists. Try again later with the SAME key.
  throw new Error(`out of retries for Idempotency-Key ${idempotencyKey}`);
}

// payload is { model, input } built from the live catalog; order is your own record.
// The key is derived from the order, so it is identical after a restart.
const task = await createTaskWithRetry(payload, `order-${order.id}-render-1`);
console.log(task.taskId);
Python
import os, random, time, requests

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


def create_task_with_retry(payload: dict, idempotency_key: str, attempts: int = 4) -> dict:
    conflicts = 0

    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, ValueError):
            # Transport failure or unreadable body: 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"]

        # Only one of the three 409s is a transient race, so cap 409 retries at one.
        if body["code"] == 409:
            conflicts += 1
            retry = conflicts <= 1
        else:
            retry = body["code"] in RETRYABLE
        if not retry:
            raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
        time.sleep(2**i * 0.5 + random.random() * 0.3)

    # Still unknown whether the task exists. Try again later with the SAME key.
    raise RuntimeError(f"out of retries for Idempotency-Key {idempotency_key}")


# payload is {"model": ..., "input": ...} built from the live catalog; order_id is yours.
# The key is derived from the order, so it is identical after a restart.
task = create_task_with_retry(payload, f"order-{order_id}-render-1")
print(task["taskId"])

Po błędach transportu także używaj tego samego klucza

Wyjątek z fetch, przekroczenie czasu nawiązania połączenia, przekroczenie czasu odczytu — we wszystkich tych przypadkach nie wiesz, czy żądanie dotarło. Właśnie do tego służy klucz idempotencji: ponów z tym samym kluczem, a najgorszym wynikiem będzie otrzymanie wcześniejszego zadania. Jeśli wyczerpiesz liczbę prób, zachowaj klucz: późniejsze wywołanie z nim nadal zwróci to samo zadanie w 24-godzinnym okresie ważności.

Na tej stronie