Aller au contenu principal

Génération d’images et de vidéos en lot sans effondrement des files

Un design de production pour la concurrence, la contre-pression, l’idempotence, l’équité, les retries, le contrôle des coûts et l’observabilité.

Auteur
SpicyAPI Engineering
Publié
Lecture
25 min de lecture
Mis à jour
Un opérateur devant des écrans surveille des convoyeurs distincts pour les images et les vidéos dans une salle de serveurs illustrée

Illustration : des files séparées pour les images et les vidéos permettent à chaque charge d’avancer à son propre rythme.

Un générateur par lot échoue généralement au moment le moins intéressant : non pas en générant une image, mais lorsqu’un client importe 20 000 lignes et que l’application les transforme toutes en requêtes actives en même temps.

L’API peut être saine et chaque payload valide. Le système s’effondre pourtant : les connexions à la base de données se saturent, les callbacks arrivent plus vite que vous ne pouvez les valider, un gros tenant bloque tous les autres, un retry duplique une vidéo coûteuse et un pic de trafic devient une facture.

Gardez le travail en attente dans une file durable, plafonnez les tâches en vol et ne réessayez que lorsque vous savez si une tâche distante existe déjà. Un Promise.all plus grand ne fait rien de tout cela.

Un fait structure tout ce qui suit : SpicyAPI n’a pas d’endpoint de masse ni de lot. Chaque ligne est un appel createTask avec sa propre tâche, son propre blocage de fonds et son propre règlement, et une tâche acceptée ne peut pas être annulée. La file, le plafond de concurrence et le bouton d’arrêt se trouvent donc tous dans votre application.

Essayez d’abord dans le Playground

Ouvrez le Playground z-image. Écrivez un Prompt représentatif, choisissez l’Aspect ratio, réglez Prompt expansion comme vous comptez lancer le lot, cliquez sur Run model et vérifiez l’Output. Ne montez en charge que lorsque ce résultat unique est celui que vous voulez ; un ordonnanceur multiplie la requête, erreurs comprises.

La réponse courte

  • Gardez le backlog dans votre propre file durable, pas dans une requête HTTP ni dans une promesse en mémoire.
  • Plafonnez vous-même les tâches en vol. La limite de débit de la plateforme (1 000 requêtes par 10 secondes, séparément pour les lectures et les écritures) ne borne pas le nombre de tâches acceptées qui s’exécutent en même temps ; votre solde et vos plafonds de dépense, si. Choisissez un nombre, par classe de média, et tenez-le jusqu’à ce que la tâche soit terminale.
  • Admettez selon l’âge de la file, pas sa longueur, et décidez du coût de tout le lot avant le premier dispatch.
  • Une clé d’idempotence enregistrée par sortie logique, toujours envoyée avec la même clé d’API, et jamais considérée comme fiable au-delà de 24 heures.
  • Réessayez selon le code d’erreur : erreurs de capacité et de transport, oui ; entrée invalide, solde vide et plafonds de dépense, non.
  • Round-robin entre les tenants, et un kill switch du dispatch qui laisse le traitement des fins de tâche en marche.

Trois limites, et qui fixe chacune

Traitez la soumission, le dispatch et la fin de tâche comme des étapes séparées :

lot du client

│

▼

admission + contrôle des coûts ──► file durable ──► workers bornés ──► createTask

│

fin par polling / webhook

│

copie de la sortie ──► stockage des résultats + événement client

LimiteCe qu’elle borneQui la fixe sur SpicyAPI
AdmissionLe volume de backlog que vous gardez et promettez de terminerVous
Débit de soumissionAppels createTask par secondeLa plateforme : 1 000 écritures par 10 secondes et par compte, un token bucket d’environ 100 requêtes par seconde, avec X-RateLimit-Remaining dans chaque réponse et Retry-After sur un 429
Tâches en volTâches acceptées et non terminéesSurtout vous. La plateforme ne les borne qu’à travers les fonds que chaque tâche bloque, les plafonds de dépense de la clé, de l’équipe et de la plateforme (40202) et la capacité du modèle (50301)

La limite de débit régit la soumission, pas la concurrence : les tâches acceptées ne puisent plus dans le bucket de requêtes pendant la génération.

À 5 fins de tâche par seconde, vous créez environ 5 tâches par seconde, une petite fraction du bucket de 100 par seconde. Pour la plupart des lots, la limite de soumission est un fusible de sécurité, pas votre frein ; l’argent bloqué par les tâches en vol et vos propres plafonds vous arrêtent d’abord. Le polling utilise le bucket de lecture séparé, si bien qu’une boucle de polling emballée ne peut pas bloquer createTask, mais elle peut se mettre à renvoyer 429 sur recordInfo et vous laisser aveugle. Lisez l’en-tête au lieu de coder en dur les valeurs par défaut.

D’autres API peuvent imposer des limites distinctes de concurrence et de file en attente ; le guide de concurrence de Leonardo les distingue. SpicyAPI ne documente aucun plafond chiffré de tâches en vol : configurez donc le vôtre plutôt que de vous appuyer sur sa limite de débit de requêtes.

Dimensionner le plafond en vol selon le temps et l’argent

Pour une classe de charge stable, la loi de Little relie le nombre moyen de tâches dans le système au débit de fin de tâche et au temps moyen que chaque tâche y passe :

tâches en vol moyennes ≈ fins de tâche par seconde × secondes moyennes entre l’acceptation et l’état terminal

Si les tâches d’image durent 8 secondes en moyenne et que vous en voulez 5 par seconde :

5 jobs/s × 8 s = 40 tâches d’image simultanées

Considérez 40 comme une occupation moyenne, pas comme une garantie. Un plafond de départ de 50 ajoute 25 % de marge ; ne le relevez que tant que l’âge de la file, les erreurs et la dépense restent sains. Utilisez p95 et p99 pour les queues de distribution et les alarmes sur l’âge de la file. Mesurez la durée d’une tâche à partir de createdAt et completedAt dans recordInfo, par modèle et par réglage.

La vidéo a besoin de son propre pool. Si les clips Seedance 2.0 durent 70 secondes en moyenne dans votre charge et que vous en voulez 0,2 par seconde :

0,2 job/s × 70 s = 14 tâches vidéo simultanées

Les moyennes de 8 et 70 secondes sont des hypothèses de dimensionnement, pas des durées de génération promises.

Avec un seul pool partagé de 50, une rafale de longues vidéos peut occuper chaque slot pendant que les images rapides attendent. Avec 50 slots d’image et 14 slots de vidéo, les deux classes avancent. Z-Image et Seedream 5.0 Pro peuvent aussi avoir des pools distincts si leurs durées ou leurs priorités diffèrent.

Confrontez maintenant le plafond à l’argent. Chaque tâche acceptée bloque son estimatedCost jusqu’à son règlement, et ce blocage compte pour chaque plafond de clé et d’équipe. D’après les prix publics du catalogue :

En volBloqué en même temps
50 images Seedream 5.0 Pro en 1K ($0.036 chacune)$1.80
14 clips texte vers vidéo Seedance 2.0, 5 s en 720p ($0.152 par seconde, $0.76 chacun)$10.64

Un solde qui ne couvre pas ces blocages fait échouer les nouvelles tâches avec 40201, aussi bon que soit votre ordonnanceur. La dépense totale compte davantage : 20 000 images Seedream 5.0 Pro en 1K reviennent à $720 et 20 000 images Z-Image à $0.01 avec Prompt expansion désactivé, à $200. Les prix changent : lisez-les sur la page du modèle en ligne ou dans un devis. Le plafond qui arrête un mauvais lot est celui que vous fixez avant son départ : définissez un plafond de dépense quotidien sur la clé de production avant le dispatch, dimensionné sur le maximum que vous pouvez vous permettre de perdre en une mauvaise journée. Un plafond vide signifie aucune limite ; les limites d’équipe peuvent aussi arrêter de nouvelles tâches avec 40202.

L’estimation du temps d’écoulement est l’autre moitié :

temps d’écoulement ≈ jobs en file ÷ fins de tâche soutenables par seconde

18 000 images en file à 5 par seconde représentent une heure de travail. Si vous promettez un démarrage en 15 minutes, l’admission doit ralentir bien avant 18 000. Un backlog qui grossit n’est pas un régime permanent : fiez-vous davantage à l’âge de file observé qu’aux équations.

La contre-pression commence avant le dispatch

La contre-pression signifie que le système en aval peut obliger le producteur à ralentir. Sans elle, la file absorbe du travail indéfiniment et le client n’apprend la surcharge que des heures plus tard.

Utilisez deux seuils d’admission sur l’âge de la file, l’âge de l’élément prêt le plus ancien. La longueur ment quand les éléments diffèrent par leur coût ; l’âge dit directement si l’attente promise est tenue.

  • Seuil souple : accepter le lot, renvoyer une estimation différée, ralentir le producteur.
  • Seuil dur : différer ou rejeter le nouveau travail avec une réponse produit réessayable avant qu’aucune tâche distante n’existe.

Comparez l’argent en unités décimales exactes plutôt qu’en virgule flottante binaire. Validez la réservation de façon atomique avec l’admission du lot dans votre adaptateur de file, afin que des soumissions concurrentes ne consomment pas le même budget restant.

Ne transformez pas chaque surcharge interne en 429 d’API pour vos propres clients. Si le travail est stocké durablement, renvoyez un ID de lot accepté dans un état différé. Ne rejetez que le travail que vous ne pouvez pas conserver de façon responsable et, dans ce cas, donnez une heure de nouvel essai et rendez idempotente la soumission du lot elle-même.

Deux détails de cette API déterminent combien de temps un élément peut attendre sans risque dans votre file :

  • Les fichiers de référence expirent en un jour. Un URI spicy:// issu d’un upload est valide jusqu’à l’expiresAt de sa réponse de commit (un jour par défaut), et un retry réutilise l’entrée d’origine ; un retry ultérieur renvoie donc 400. Conservez dans la file une URL HTTPS publique ou votre propre ID source et téléversez au moment du dispatch.
  • Les devis durent cinq minutes. Si vous voulez verrouiller le prix, demandez le devis au dispatch et envoyez quoteId avec expectedCost à createTask ; un prix modifié échoue alors avec 40901 au lieu de facturer un nouveau montant. Les devis sont gratuits mais comptent dans le bucket de lecture : demander un devis pour 20 000 lignes à l’admission est donc le mauvais endroit.

L’idempotence a deux couches

Deux doublons différents sont à empêcher : le client qui soumet deux fois le même lot, et votre dispatcher qui ignore si une création a réussi. Donnez au lot une identité stable, puis générez et enregistrez un UUID pour chaque nouvel élément logique. La reprise réutilise la clé enregistrée de cet élément.

Gardez une contrainte d’unicité sur cette clé dans votre base de données. Écrivez l’élément comme « intention » avant d’appeler l’API, envoyez la clé en Idempotency-Key et stockez le taskId renvoyé sur la même ligne. Après un crash, le worker suivant réutilise la clé enregistrée si l’acceptation est toujours inconnue. Écrire l’intention seulement après l’envoi laisse une fenêtre où un redémarrage peut acheter deux fois la même sortie.

Ce qu’ajoute la documentation sur l’idempotence, et que la file doit respecter :

  • Une clé est valable 24 heures à partir de la première requête acceptée ; les rejeux ne la prolongent pas. Passé ce délai, la même clé crée une nouvelle tâche facturée. Avant de rejouer un ancien élément en file ou en dead-letter, recherchez son taskId enregistré au lieu de faire confiance à la clé.
  • La clé appartient au même compte et à la même clé d’API, ainsi qu’au modèle, à l’input et au callBackUrl exacts. Changer l’un d’eux renvoie 409. Une clé sœur de votre compte reçoit aussi 409 et ne peut pas lire la tâche : seule la clé qui a créé une tâche peut la lire ou la réessayer ; utilisez donc une seule clé pour créer, interroger et réessayer dans une flotte de workers.
  • Un rejeu renvoie la tâche d’origine dans son état actuel, sans aucune marque indiquant qu’il s’agit d’un rejeu.
  • Une rare course sur la même clé renvoie 409 « Idempotency key conflict, please retry shortly ». Réessayez une fois après quelques centaines de millisecondes ; les deux autres messages 409 ne réussiront jamais.

Un dispatcher qui tient le plafond jusqu’à la fin de la tâche

Enregistrez un objet par ligne dans batch.jsonl, par exemple {"model": "alibaba/z-image-turbo/text-to-image", "input": {"prompt": "<one row's prompt>"}}, avec les entrées acceptées par le modèle choisi. Définissez SPICY_API_KEY, BATCH_BUDGET_USD et, facultativement, BATCH_WORKERS (2 par défaut). Exécutez cet exemple Python et approuvez le plafond du lot. Il garde chaque worker occupé jusqu’à ce que sa tâche soit terminale, réglée et qu’elle ne prépare plus de sortie.

Conservez le dossier batch-run pour la reprise et ne réordonnez pas ses lignes sources. Le même dossier conserve la dépense réglée et les réservations d’un redémarrage à l’autre. N’exécutez qu’un seul coordinateur par dossier.

python
import json
import os
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
from pathlib import Path
from threading import Event, Lock

BASE = "https://api.spicyapi.ai/api/v1"
API_KEY = os.environ["SPICY_API_KEY"]
CAP = Decimal(os.environ["BATCH_BUDGET_USD"])
WORKERS = int(os.environ.get("BATCH_WORKERS", "2"))
ROOT = Path(os.environ.get("BATCH_DIR", "batch-run"))
ROOT.mkdir(exist_ok=True, mode=0o700)
lock, stop = Lock(), Event()
reserved = Decimal("0")


class APIError(Exception):
    def __init__(self, status, body):
        self.status, self.code = status, body.get("code")
        super().__init__(f"{status}/{self.code}: {body.get('msg')}")


def call(path, payload=None, key=None):
    headers = {"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"}
    if key:
        headers["Idempotency-Key"] = key
    for attempt in range(5):
        request = urllib.request.Request(BASE + path,
            data=None if payload is None else json.dumps(payload).encode(), headers=headers)
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                body = json.load(response)
                if body.get("code") != 200:
                    raise APIError(response.status, body)
                return body["data"]
        except urllib.error.HTTPError as error:
            try:
                body = json.load(error)
            except ValueError:
                body = {}
            if error.code != 429 and error.code < 500:
                raise APIError(error.code, body)
            delay = float(error.headers.get("Retry-After") or min(15, 2 ** attempt))
        except (urllib.error.URLError, TimeoutError, ValueError):
            delay = min(15, 2 ** attempt)
        time.sleep(delay)
    raise RuntimeError("Unresolved API call; preserve this row for recovery")


def save(path, row):
    temporary = path.with_suffix(".tmp")
    temporary.write_text(json.dumps(row))
    temporary.chmod(0o600)
    temporary.replace(path)


def worker(pair):
    global reserved
    index, line = pair
    if stop.is_set():
        return
    try:
        request = json.loads(line)
        if not isinstance(request.get("model"), str) or not isinstance(request.get("input"), dict):
            raise ValueError("model and input are required")
    except (ValueError, TypeError, AttributeError) as error:
        print(index, "invalid local row:", error)
        return
    path = ROOT / f"{index}.json"
    row = json.loads(path.read_text()) if path.exists() else {
        "request": request, "key": str(uuid.uuid4())}
    if row["request"] != request:
        stop.set()
        raise ValueError("Batch changed; preserve the old folder and use a new one")
    if row.get("settled") or row.get("rejected"):
        return
    accounted = "sentAt" in row
    try:
        if not row.get("taskId"):
            if time.time() - row.get("sentAt", time.time()) >= 23 * 3600:
                raise RuntimeError("Reconcile old acceptance before submitting again")
            if "submission" not in row:
                quote = call("/jobs/quote", request)
                row["hold"] = quote["maxCharge"]
                row["submission"] = {**request, "quoteId": quote["quoteId"],
                                     "expectedCost": quote["estimatedCost"]}
            with lock:
                if stop.is_set():
                    return
                hold = Decimal(row["hold"])
                if not accounted and reserved + hold > CAP:
                    stop.set()
                    print(index, "batch budget reached")
                    return
                if not accounted:
                    reserved += hold
                    accounted = True
            row.setdefault("sentAt", time.time())
            save(path, row)
            accepted = call("/jobs/createTask", row["submission"], row["key"])
            row["taskId"] = accepted["taskId"]
            save(path, row)
        task_id = row["taskId"]
        for _ in range(3000):
            task = call("/jobs/recordInfo?taskId=" + urllib.parse.quote(task_id))
            if task["state"] not in {"queued", "running", "succeeded", "failed", "expired", "canceled"}:
                raise RuntimeError("Unknown state: keep the reservation")
            if task["state"] in {"succeeded", "failed", "expired", "canceled"} and task.get("settled") is True:
                assets = task.get("output", {}).get("assets", [])
                if task["state"] == "succeeded" and any(a.get("pending") for a in assets):
                    time.sleep(5)
                    continue
                row["result"], row["settled"] = task, True
                save(path, row)
                with lock:
                    reserved += Decimal(task["cost"]) - Decimal(row["hold"])
                print(index, task_id, task["state"], task["cost"])
                return
            time.sleep(5)
        raise RuntimeError("Wait interrupted; resume the saved task, not a new generation")
    except APIError as error:
        if error.status == 400 and error.code in {400, 40003, 40004} and not row.get("taskId"):
            row["rejected"] = str(error)
            save(path, row)
            with lock:
                if accounted:
                    reserved -= Decimal(row.get("hold", "0"))
            print(index, "rejected input:", error)
            return
        stop.set()
        print(index, "paused:", error)
    except Exception as error:
        stop.set()
        print(index, "paused:", error)


if CAP <= 0 or not 1 <= WORKERS <= 20:
    raise ValueError("Use a positive budget and 1–20 workers")
# Count settled spend from this batch again on restart.
for path in ROOT.glob("*.json"):
    row = json.loads(path.read_text())
    if row.get("settled"):
        reserved += Decimal(row["result"]["cost"])
    elif row.get("sentAt") and not row.get("rejected"):
        reserved += Decimal(row["hold"])
if input(f"Authorize this batch up to ${CAP}? Type yes: ") != "yes":
    raise SystemExit("No tasks submitted")
rows = Path("batch.jsonl").read_text().splitlines()
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
    list(pool.map(worker, enumerate(rows)))

La documentation d’intégration en production couvre le déploiement. Cet exemple isole les lignes invalides, suspend la nouvelle admission en cas de solde insuffisant ou d’erreur incertaine, et borne les retries transitoires. Les tâches déjà acceptées continuent d’être collectées après la suspension de l’admission. Il ne fait volontairement aucun retry payant automatique ; une ligne en échec reste consignée pour une nouvelle tentative explicite après le règlement. Exécutez un tel processus par classe de média et placez le round-robin par tenant devant le fichier.

Ordonnancement équitable : protéger les petits tenants

Le premier arrivé, premier servi est équitable envers les jobs, pas envers les clients. Si le tenant A met en file 100 000 éléments par minute avant que le tenant B n’en mette dix, B attend derrière tous ceux de A. Gardez une file par tenant et prenez un élément par tenant à chaque tour :

tour 1 : A1, B1, C1

tour 2 : A2, B2, C2

Les poids peuvent refléter la capacité achetée, mais donnez à chaque tenant actif une part minimale. Plafonnez aussi le nombre de tâches en vol de chaque tenant : le plafond global protège votre compte, celui du tenant contient les voisins bruyants. Au sein d’un tenant, les voies de priorité (aperçus interactifs, lots normaux, backfills) ne valent la peine que lorsque la latence des aperçus devient une vraie plainte. Si vous les ajoutez, faites vieillir la priorité avec le temps en file, sinon un flux constant d’aperçus affame un backfill pour toujours. Tout ce qui se trouve sur un compte partage les deux mêmes buckets de requêtes et le même solde : l’équité entre tenants est donc votre travail, pas celui de l’API.

Classer les échecs avant de réessayer

« Réessayer trois fois » n’est pas une politique. La bonne action dépend de ce que vous savez et de la possible existence d’une tâche. Ce tableau utilise les codes documentés ; les valeurs de code se trouvent dans le corps de la réponse, et les échecs de tâche arrivent sous la forme state: "failed" avec un errorCode lors d’un appel recordInfo réussi (code: 200).

OùSignalRetry ?Que faire
CréationTimeout, connexion coupée, corps illisibleOuiRésultat inconnu. Même clé, même clé d’API, backoff avec jitter
Création429 avec Retry-AfterOuiAttendre les secondes indiquées, ajouter du jitter, réduire le débit de dispatch. Ne pas y voir la preuve que vous alliez trop vite
Upload429 sans Retry-After (uploads en attente)Pas de retry immédiatTerminer les uploads en attente ou attendre l’expiration de leurs tickets avant d’en demander d’autres
Création500, 503, 50301 (modèle indisponible)Oui, bornéBackoff avec échéance ; 50301 peut aussi signifier l’absence de prix actif : actualisez le catalogue
Création409 course sur la même cléUne foisQuelques centaines de ms, même clé. Les autres 409 signifient payload modifié ou autre clé : arrêtez
Création400, 40003, 40004NonChamp invalide, combinaison non prise en charge ou type/taille de fichier incompatible ; corrigez l’élément, affichez le message
Création40201 solde, 40202 plafond de dépenseNonSuspendre le dispatch et alerter. Réessayer ne changera ni un solde ni un plafond
Création401, 403, 40301NonPermission de clé ou de modèle : suspendre l’intégration, alerter un opérateur
Tâcherate_limited, upstream_unavailable, upstream_failedOuiSeulement après settled: true : POST /api/v1/jobs/retry avec une clé liée à la tâche source ; crée une nouvelle tâche facturée. Un retry plus tôt renvoie 409
Tâchetimeout, generation_failedPeut-êtreÉchoue souvent de la même façon. Essayer une durée plus courte ou une résolution plus basse, ou abandonner
Tâcheinvalid_request, unsupported_combination, content_rejectedNonLa même entrée échoue de nouveau. Changer l’entrée ou choisir un autre modèle
Tâcheinvalid_assetNouvelle révisionLe fichier de référence a disparu : le téléverser à nouveau, mettre à jour l’entrée et chiffrer une nouvelle tâche
LocalCallback reçu, votre transaction a échouéOuiRéessayer votre handler, pas la création ; dédupliquer par ID de livraison ou taskId

Utilisez un backoff aléatoire pour que les workers ne réessaient pas en cadence.

Déplacez les échecs confirmés et les entrées rejetées vers une dead-letter queue avec leur classification et l’action de rejeu sûre. Gardez une création ambiguë ou une tâche acceptée non résolue dans la file de réconciliation avec sa clé enregistrée et son ID de tâche ; une échéance de retry locale ne prouve pas qu’elle a échoué. Une tâche en échec libère normalement son blocage en totalité, mais certains résultats content_rejected facturables sont réglés au montant du devis : lisez donc settled et cost au lieu de présumer un remboursement.

La protection des coûts relève de l’ordonnanceur

Les limites de concurrence protègent les machines, pas l’argent. Un worker parfaitement stable peut quand même dispatcher un lot inabordable. Vérifiez le coût estimé par rapport à chaque enveloppe avant le dispatch :

autorisé = reste quotidien du tenant

∩ reste mensuel du tenant

∩ reste du lot du projet

∩ reste horaire global

Réconciliez avec le règlement réel : createTask renvoie l’estimatedCost bloqué, une tâche réussie est réglée à ce montant ou en dessous, et settled: true avec cost donne la charge finale. Libérez votre propre réservation pour les éléments que l’API a rejetés et conservez-la pour les éléments acceptés jusqu’à ce que settled bascule. Ne devinez pas à partir d’un timeout HTTP.

Garde-fous utiles :

  • un coût estimé maximal par lot, montré à une personne avant qu’un gros lot ne démarre ;
  • un budget de dispatch horaire par tenant et une alarme de vitesse de dépense globale ;
  • un circuit breaker par modèle ou par tenant ;
  • des plafonds de clé d’API inférieurs au solde du compte, et une alerte de solde bas ;
  • un kill switch qui arrête le nouveau dispatch tout en laissant le traitement des fins de tâche en marche.

Comme les tâches acceptées ne peuvent pas être annulées, arrêter la création est le seul frein : le plafond en vol, et non un bouton d’annulation, limite les dégâts lors d’un incident de coût. Continuez à recevoir les webhooks, à interroger les anciennes tâches, à régler les réservations et à copier les sorties.

La fin de tâche à l’échelle d’un lot

La livraison par callBackUrl de chaque tâche expire au bout de 15 secondes ; un endpoint de compte enregistré peut aussi recevoir sa propre livraison. Le récepteur doit donc vérifier la signature sur le corps brut, écrire l’événement dans une table inbox, renvoyer 2xx et laisser un worker faire le reste. Dédupliquez sur le request_id de livraison v2 et décidez d’après data.state, pas d’après l’ordre d’arrivée. Gardez un polling lent comme solution de repli pour les événements manqués (voir la documentation des webhooks).

Copiez les sorties dans votre propre stockage à mesure que les tâches se terminent. Les liens de résultat durent environ 20 minutes et les médias générés sont conservés environ 14 jours au maximum (rétention) ; à 5 fins de tâche par seconde, l’étape de copie fait partie de votre débit, et un uploader lent devient le goulot qui se cache dans les tâches « terminées ». Raccourcir la rétention peut aussi supprimer les résultats de tâches que vous avez déjà exécutées.

Observer le flux, pas seulement les erreurs

Un tableau de bord utile répond à quatre questions : acceptons-nous du travail, démarre-t-il, se termine-t-il, le coût se règle-t-il comme prévu ? Suivez par modèle, classe de média, niveau de tenant et issue :

SignalPourquoi c’est important
Âge de l’élément prêt le plus ancienMesure directe de l’attente du client et de l’échec de la contre-pression
Nombres d’éléments en file, en vol et terminauxMontre où le travail s’accumule
Tâches acceptées par seconde vs créations tentéesSépare le vrai débit des retries
Durée de tâche p50/p95/p99 (completedAt - createdAt)Guide le dimensionnement de la concurrence et la détection des tâches bloquées
Issues terminales et répartition des errorCodeDégradation propre à un modèle ou à une entrée
X-RateLimit-RemainingAvertit avant un 429
Solde bloqué vs disponible (GET /api/v1/chat/credit)Prédit 40201 avant qu’il n’arrête le lot
Coût réservé vs réglé, dépense par heure et par tenantRéservations orphelines, lots emballés
Retard des webhooks et récupérations par pollingSanté du chemin de fin de tâche

Portez une seule chaîne de corrélation (batchId, ID d’élément, clé d’idempotence, taskId, request_id) à travers les logs et les événements, et gardez prompts, clés d’API et URL d’assets privés hors des labels de métriques et des logs courants. Alertez sur les symptômes : « 50 000 éléments en file » peut être normal ; « l’élément interactif le plus ancien a 12 minutes » appelle une action.

Commencer par un lot borné, puis augmenter la capacité

N’augmentez la capacité que tant que l’âge du plus ancien élément, les échecs de tâche et la dépense restent dans vos seuils ; plus de workers ne peuvent pas réparer un chemin de fin de tâche surchargé.

Avant le premier lot payant, utilisez un mock pour interrompre un worker juste avant et juste après la soumission et pour renvoyer des 429, des tâches en échec et des erreurs de plafond de dépense. La reprise doit réutiliser l’intention enregistrée, laisser non résolues les tâches inconnues et arrêter le nouveau dispatch sans arrêter la fin de tâche. Lancez ensuite un petit lot approuvé avant d’ouvrir l’admission aux gros imports.

Pour le traitement des fins de tâche, voir « Polling vs webhooks for async generation jobs » ; pour le budget d’un lot, voir Per-unit pricing vs subscriptions. La checklist de production couvre la configuration des clés et les tests de lancement.

FAQ

SpicyAPI a-t-elle un endpoint de lot ou une limite de concurrence ?

Pas d’endpoint de lot : chaque ligne est un createTask, et il n’existe aucun plafond documenté de tâches en vol (les limites figurent dans le tableau ci-dessus ; le débit actuel se lit dans X-RateLimit-Limit). Si la limite de débit est trop basse pour votre pic, demandez-en une plus élevée au support de la console, en décrivant le pic de requêtes par seconde et la taille du lot.

Puis-je annuler un lot lancé par erreur ?

Non. Utilisez le kill switch du dispatch pour arrêter les nouvelles tâches pendant que le traitement des fins de tâche continue.

Un 429 doit-il consommer une tentative de retry ?

Attendez Retry-After plus du jitter et gardez la même clé d’idempotence. Un étranglement répété appelle un débit de dispatch plus bas. Bornez les retries à la fois par le nombre de tentatives et par le temps ; si l’acceptation reste incertaine quand ce budget est épuisé, réconciliez l’intention enregistrée avant de créer un remplaçant.

Comment éviter les doubles facturations après un timeout ?

Renvoyez la requête avec la même Idempotency-Key et la même clé d’API dans les 24 heures, et stockez le taskId dès que vous l’avez. Après 24 heures, recherchez plutôt le taskId enregistré.

Une file suffit-elle à empêcher la surcharge ?

Non. Une file stocke la surcharge ; la contre-pression la contrôle. Il vous faut toujours des seuils d’admission sur l’âge de la file, un plafond de tâches en vol et une réponse produit pour le cas où l’attente promise ne peut pas être tenue.

Quelle est une bonne concurrence de départ ?

Fins de tâche par seconde multipliées par la durée moyenne d’une tâche, par classe de média, plus environ 25 % de marge : 40 pour 5 images par seconde à 8 secondes chacune. Ne la relevez que tant que l’âge de la file, les erreurs et la dépense restent sains.

Les jobs très prioritaires peuvent-ils contourner les limites ?

Non. La priorité choisit parmi le travail admissible et ne contourne jamais le plafond global ni les budgets. Donnez au travail interactif une capacité réservée et faites vieillir le travail moins prioritaire pour qu’il ne soit pas affamé.

Laissez un Agent gérer l’appel

Utilisez un Agent lorsque vous voulez qu’il prépare les entrées et renvoie le résultat pendant que vous approuvez le prix et les choix créatifs.

  • CLI : avec l’objet input d’une ligne enregistré sous input.json (pour Z-Image : prompt, aspect_ratio, enable_prompt_expansion, output_format, seed), utilisez npx @spicyapi/cli tasks create --model alibaba/z-image-turbo/text-to-image --input-file input.json --wait. La CLI confirme le prix ; conservez l’ID de tâche renvoyé pour la reprise.
  • MCP : demandez à votre assistant d’utiliser spicyapi_task_create pour alibaba/z-image-turbo/text-to-image, puis spicyapi_task_wait. Le serveur MCP demande une confirmation du prix avant la création.
  • Skill : installez-la avec npx skills add spicyapi-ai/spicy-skill, puis demandez à $spicyapi d’exécuter le même workflow et de s’arrêter pour votre approbation. La Skill utilise une connexion CLI ou MCP configurée.

Aussi en English · 日本語 · 한국어 · Deutsch · Español · Português (Brasil) · Русский · Italiano · Polski

Solutions

API vidéo générative : comparer, tester, intégrerComparez les points d’accès vidéo par durée, résolution, audio et prix, puis envoyez la même requête depuis votre serveur.