spicyapiDocumentazione
Sviluppare con l’API
Contenuto principale

Idempotenza

Riprova dopo un timeout senza generare due volte né pagare due volte.

Una chiave di idempotenza è un'etichetta che associ a un singolo lavoro, per esempio «genera il video per l'ordine 8814». Se la stessa richiesta etichettata arriva due volte, eseguiamo il lavoro una volta sola.

createTask la accetta nell'header Idempotency-Key. Inviare due volte la stessa chiave dallo stesso account e con la stessa chiave API restituisce l'attività creata la prima volta: nessuna seconda voce in coda, somma bloccata o addebito.

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

La chiave è legata a ciò che hai inviato: modello, intero input e callBackUrl. Ordine dei campi e spazi non contano, ma qualsiasi modifica effettiva sì. Inviare la stessa chiave con una richiesta diversa restituisce 409 con Idempotency-Key has already been used for a different request. Quel 409 non si risolve riprovando: se la richiesta è davvero cambiata, è un nuovo lavoro e richiede una nuova chiave.

Perché ti serve

Un timeout di rete ti lascia nell'incertezza peggiore: la richiesta potrebbe non essere mai arrivata oppure potrebbe essere arrivata e soltanto la risposta essersi persa. Senza chiave di idempotenza hai due cattive opzioni: non riprovare (l'utente aspetta inutilmente) oppure riprovare (rischiando di generare e pagare due volte).

Con una chiave, riprovare è sicuro: viene creata l'attività oppure ricevi quella creata poco prima.

Quali endpoint leggono l'header

EndpointCosa succede ripetendo la stessa chiave
POST /api/v1/jobs/createTaskRestituisce l'attività originale nel suo stato attuale
POST /api/v1/jobs/retryRestituisce l'attività creata dal nuovo tentativo. La chiave è limitata all'attività sorgente, quindi la stessa chiave usata con sorgenti diverse non crea collisioni
POST /v1/videosRestituisce l'attività originale
POST /api/v1/jobs/stream e gli endpoint di testo sotto /v1 e /v1betaRestituiscono 400: vedi sotto

Gli endpoint di lettura e quelli che emettono link di caricamento e download ignorano l'header. POST /api/v1/jobs/purge non richiede una chiave: ripeterlo per lo stesso taskId è già innocuo.

Negli endpoint di testo e streaming una chiave evita un doppio addebito, non recupera una risposta persa

La risposta a una richiesta di testo viene inviata mentre viene generata e non viene conservata. Ripetere la chiave di una richiesta completata con lo stesso corpo restituisce 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) senza altri addebiti; il testo non può essere recuperato da nessuna parte. Se la richiesta precedente è fallita, la ripetizione restituisce lo stesso errore, così puoi decidere se riprovare con una nuova chiave. Con un corpo diverso, la chiave restituisce 409.

Quindi, su questi endpoint usa una nuova chiave per ogni nuovo messaggio e riutilizzala soltanto per la richiesta identica dopo un errore di rete, accettando che la risposta sia persa. Se vuoi nuovamente una risposta, invia con una nuova chiave e pagala. Consulta Testo e streaming.

La finestra è di 24 ore

Una chiave resta valida per 24 ore dal momento della prima accettazione. Le ripetizioni non prolungano la finestra. Dopo, la stessa chiave viene trattata come una nuova richiesta e crea una nuova attività fatturabile, quindi interrompi i tentativi automatici con largo anticipo.

Ventiquattro ore sono un compromesso. I tentativi dei client si concludono in secondi o minuti, quindi la finestra deve coprire soltanto quel periodo; ma più è lunga, più a lungo una chiave non può essere riutilizzata: con una chiave fissa nel codice scopriresti di poter inviare una sola attività al giorno.

Scegliere le chiavi

  • Una chiave per richiesta logica, non per chiamata HTTP. I nuovi tentativi devono riutilizzare la stessa chiave, altrimenti il meccanismo non serve.
  • Derivala da un identificativo che hai già: order-8814-render-1 è meglio di un UUID casuale perché puoi ricalcolarla dopo il riavvio di un processo.
  • Quando non c'è nulla da cui derivarla, genera un UUID prima del primo tentativo e salvalo (nel database, accanto all'ordine a cui appartiene); i tentativi successivi leggono il valore salvato.
  • Le chiavi vengono riservate all'interno del tuo account e non devono essere globalmente univoche, ma ogni nuovo tentativo della stessa richiesta logica deve usare anche la stessa chiave API. La lunghezza massima è 128 caratteri.

Un'altra chiave API non può ripetere la richiesta

La corrispondenza di idempotenza viene riservata a livello di account, mentre la visibilità delle attività è isolata per chiave API. Un'altra chiave che usa una chiave di idempotenza già riservata riceve 409 Conflict (Idempotency-Key is already in use by this account) e la risposta non rivela l'ID dell'attività originale. Questo impedisce a una chiave di esplorare attraverso le ripetizioni le attività di un'altra integrazione. Riprovare non aiuta; invia il nuovo tentativo con la chiave API che ha effettuato il primo.

Non generare mai la chiave all'interno del ciclo dei tentativi

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

Come appare una richiesta ripetuta

Con createTask, la risposta ha gli stessi taskId ed estimatedCost, ma state è lo stato attuale dell'attività originale. Può essere queued, running o finale. Una ripetizione non crea una seconda attività, una somma bloccata o un addebito.

Non esiste un header o un campo che indichi che hai ottenuto una ripetizione. Per leggere la registrazione completa dell'attività, passa il taskId a recordInfo.

Invii simultanei con la stessa chiave

Quando arrivano contemporaneamente due richieste dalla stessa chiave API con la stessa chiave di idempotenza, esattamente una crea un'attività e l'altra riceve lo stesso taskId. La decisione si basa su un vincolo di unicità nel database anziché su una verifica seguita da inserimento: con richieste simultanee, entrambe le verifiche potrebbero risultare vuote, entrambe le attività verrebbero create ed entrambe le somme resterebbero bloccate.

In rari casi la concorrenza emerge come:

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

Attendi qualche centinaio di millisecondi e riprova con la stessa chiave.

Gli altri due 409 di createTask non meritano nuovi tentativi: Idempotency-Key has already been used for a different request (hai cambiato la richiesta) e Idempotency-Key is already in use by this account (un'altra chiave API dell'account ha già usato questa chiave). Tutti e tre condividono code: 409 e il tuo codice non dovrebbe basare la logica su msg, quindi consenti al massimo uno o due nuovi tentativi per 409, poi mostra l'errore a una persona che possa esaminarlo.

Esempio completo

La funzione riceve la chiave come argomento. Calcolala dalle tue registrazioni (qui, l'ID dell'ordine) in modo che un processo riavviato invii nuovamente la stessa chiave.

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"])

Anche gli errori di trasporto richiedono la stessa chiave

Un'eccezione di fetch, un timeout di connessione, un timeout di lettura: in tutti questi casi non sai se la richiesta sia arrivata. È esattamente lo scopo della chiave di idempotenza: riprova con la stessa chiave e l'esito peggiore è ricevere l'attività precedente. Se esaurisci i tentativi, conserva la chiave: una chiamata successiva con essa restituisce ancora la stessa attività entro la finestra di 24 ore.

In questa pagina