Idempotence
Relancez après un timeout sans générer ni payer deux fois.
Une clé d’idempotence est un identifiant que vous associez à une opération, par exemple « produire la vidéo de la commande 8814 ». Si la même requête identifiée nous parvient deux fois, nous n’effectuons le travail qu’une fois.
createTask l’accepte dans l’en-tête Idempotency-Key. Deux envois avec la même clé, depuis le même compte et avec la même clé API, renvoient la tâche créée lors du premier envoi : aucune seconde mise en file, réservation ni facturation.
POST /api/v1/jobs/createTask
Idempotency-Key: order-8814-render-1La clé est liée à ce que vous avez envoyé : le modèle, l’intégralité d’input et callBackUrl. L’ordre des champs et les espaces ne comptent pas, mais toute modification réelle compte. Envoyer la même clé avec une requête différente renvoie 409 avec Idempotency-Key has already been used for a different request. Ce 409 ne peut pas réussir en réessayant : si la requête a réellement changé, il s’agit d’une nouvelle opération qui nécessite une nouvelle clé.
Pourquoi l’utiliser
Un timeout réseau laisse une incertitude difficile à gérer : la requête peut ne jamais être arrivée, ou être arrivée alors que seule la réponse a été perdue. Sans clé d’idempotence, vous avez deux mauvaises options : ne pas réessayer, laissant l’utilisateur attendre pour rien, ou réessayer, avec le risque de générer et payer deux fois.
Avec une clé, une nouvelle tentative est sûre : soit la tâche est créée, soit vous récupérez celle qui vient de l’être.
Les points de terminaison qui lisent cet en-tête
| Point de terminaison | Effet d’un nouvel envoi avec la même clé |
|---|---|
POST /api/v1/jobs/createTask | Renvoie la tâche originale dans son état actuel |
POST /api/v1/jobs/retry | Renvoie la tâche créée par la relance. La clé est limitée à la source : son utilisation avec différentes tâches sources ne crée pas de collision |
POST /v1/videos | Renvoie la tâche originale |
POST /api/v1/jobs/stream et les points de terminaison de texte sous /v1 et /v1beta | Renvoie 400 ; voir ci-dessous |
Les points de terminaison de lecture et ceux qui émettent les liens d’envoi ou de téléchargement ignorent cet en-tête. POST /api/v1/jobs/purge ne nécessite pas de clé : le répéter pour le même taskId est déjà sans conséquence.
Pour le texte et le streaming, la clé évite une double facturation, pas la perte d’une réponse
La réponse à une requête de texte vous est transmise pendant sa génération et n’est pas stockée. Réutiliser une clé dont la requête est terminée avec le même corps renvoie 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) sans facturation supplémentaire ; le texte ne peut être récupéré nulle part. Si la requête précédente a échoué, la nouvelle tentative renvoie la même erreur, ce qui vous permet de décider de réessayer ou non avec une nouvelle clé. Avec un corps différent, la clé renvoie 409.
Pour ces points de terminaison, utilisez donc une nouvelle clé pour chaque nouveau message et réutilisez une clé uniquement pour une requête strictement identique après une erreur réseau, en acceptant que sa réponse soit perdue. Si vous voulez obtenir une nouvelle réponse, envoyez la requête avec une nouvelle clé et payez cette exécution. Voir Texte et streaming.
La fenêtre est de 24 heures
Une clé reste valide pendant 24 heures à partir de sa première acceptation. Les répétitions ne prolongent pas cette durée. Ensuite, la même clé est traitée comme une nouvelle requête et crée une nouvelle tâche facturable : arrêtez les tentatives automatiques bien avant cette échéance.
Vingt-quatre heures constituent un compromis. Les nouvelles tentatives des clients se terminent en quelques secondes ou minutes : la fenêtre doit couvrir cette période. Mais plus elle est longue, plus longtemps une clé reste inutilisable pour autre chose ; avec une clé codée en dur, vous ne pourriez envoyer qu’une tâche par jour.
Choisir les clés
- Une clé par requête logique, pas par appel HTTP. Les nouvelles tentatives doivent réutiliser la même clé, sinon le mécanisme ne sert à rien.
- Dérivez-la d’un identifiant existant :
order-8814-render-1vaut mieux qu’un UUID aléatoire, car vous pouvez le recalculer après un redémarrage du processus. - Sans identifiant dont la dériver, générez un UUID avant la première tentative et enregistrez-le dans votre base, à côté de la commande concernée ; les nouvelles tentatives relisent cette valeur.
- Les clés sont réservées dans votre compte et n’ont pas besoin d’être uniques à l’échelle mondiale. Chaque nouvelle tentative d’une même requête logique doit toutefois utiliser aussi la même clé API. La longueur maximale est de 128 caractères.
Une autre clé API du compte ne peut pas récupérer la tâche
L’association d’idempotence est réservée au niveau du compte, tandis que la visibilité des tâches est isolée par clé API. Une autre clé API utilisant une clé d’idempotence déjà réservée reçoit 409 Conflict (Idempotency-Key is already in use by this account), et la réponse ne révèle pas l’ID de la tâche originale. Cela empêche une clé d’explorer les tâches d’une autre intégration par répétition. Réessayer ne résout rien : envoyez la nouvelle tentative avec la clé API utilisée au premier essai.
Ne générez jamais la clé dans la boucle de nouvelles tentatives
// 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 });
}À quoi ressemble une requête répétée
Sur createTask, la réponse contient les mêmes taskId et estimatedCost, mais state indique l’état actuel de la tâche originale. Il peut être queued, running ou terminal. Une répétition ne crée aucune seconde tâche, réservation ni facturation.
Aucun en-tête ni champ n’indique qu’il s’agit d’une répétition. Pour lire la trace complète, fournissez le taskId à recordInfo.
Envois simultanés avec la même clé
Lorsque deux requêtes de la même clé API avec la même clé d’idempotence arrivent simultanément, une seule crée une tâche et l’autre reçoit le même taskId. La décision repose sur une contrainte d’unicité de la base de données, plutôt que sur une vérification suivie d’une insertion : en concurrence, les deux vérifications pourraient ne rien trouver, et les deux tâches ainsi que leurs réservations seraient créées.
Dans de rares cas, cette concurrence se manifeste par :
{ "code": 409, "msg": "Idempotency key conflict, please retry shortly" }Attendez quelques centaines de millisecondes, puis réessayez avec la même clé.
Deux autres 409 de createTask ne justifient pas une nouvelle tentative : Idempotency-Key has already been used for a different request (vous avez modifié la requête) et Idempotency-Key is already in use by this account (une autre clé API de votre compte a déjà utilisé cette clé). Les trois partagent code: 409, et votre code ne doit pas utiliser msg pour décider : autorisez donc une ou deux nouvelles tentatives de 409 au maximum, puis présentez l’erreur à une personne.
Exemple complet
La fonction reçoit la clé en argument. Calculez-la depuis vos propres données, ici l’ID de commande, pour qu’un processus redémarré envoie à nouveau la même clé.
// 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);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"])Les erreurs de transport exigent aussi la même clé
Une exception de fetch, un timeout de connexion ou de lecture : dans tous ces cas, vous ignorez si la requête est arrivée. C’est précisément le rôle de la clé d’idempotence : réessayez avec la même clé, et le pire résultat est de récupérer la tâche précédente. Si vous épuisez les tentatives, gardez la clé : un appel ultérieur avec elle renvoie toujours la même tâche pendant la fenêtre de 24 heures.
Webhooks
Distinguer les charges utiles v1 et v2, choisir le bon identifiant de tâche et vérifier les signatures avec des exemples Node.js, Python et curl.
Devis et compatibilité des protocoles
Connaître le prix exact d’une requête avant de payer, la soumettre à ce prix, reprendre la main après une réponse perdue et savoir comment les API compatibles pour le texte et la vidéo sont facturées.

