spicyapiDocumentación
Desarrollar con la API
Contenido principal

Idempotencia

Reintenta después de un tiempo de espera agotado sin generar ni pagar dos veces.

Una clave de idempotencia es una etiqueta que asignas a un trabajo concreto, como «generar el video del pedido 8814». Si recibimos dos veces la misma solicitud con esa etiqueta, realizamos el trabajo una sola vez.

createTask la acepta en el encabezado Idempotency-Key. Enviar dos veces la misma clave desde la misma cuenta y con la misma clave de API devuelve la tarea creada la primera vez: no se añade otra entrada a la cola, no se retienen más fondos ni se realiza un segundo cargo.

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

La clave está vinculada a lo que enviaste: el modelo, todo el input y callBackUrl. El orden de los campos y los espacios no importan, pero cualquier cambio real sí. Enviar la misma clave con una solicitud diferente devuelve 409 con Idempotency-Key has already been used for a different request. Ese 409 nunca se resuelve reintentando: si la solicitud cambió realmente, es un trabajo nuevo y necesita una clave nueva.

Por qué conviene usarla

Un tiempo de espera de red agotado te deja en una situación incierta: la solicitud podría no haber llegado, o podría haber llegado y haberse perdido solo la respuesta. Sin una clave de idempotencia, tienes dos opciones problemáticas: no reintentar, con lo que el usuario podría esperar algo que nunca llegará, o reintentar, con el riesgo de generar y pagar dos veces.

Con una clave, reintentar es seguro: se crea la tarea o recibes la que se creó unos instantes antes.

Qué endpoints leen el encabezado

EndpointQué hace una repetición con la misma clave
POST /api/v1/jobs/createTaskDevuelve la tarea original en su estado actual
POST /api/v1/jobs/retryDevuelve la tarea creada por el reintento. El alcance de la clave se limita a la tarea original, por lo que utilizar la misma clave con distintas tareas de origen no provoca una colisión
POST /v1/videosDevuelve la tarea original
POST /api/v1/jobs/stream y los endpoints de texto de /v1 y /v1betaDevuelve 400; consulta la explicación siguiente

Los endpoints de lectura y los que emiten enlaces de subida y descarga ignoran el encabezado. POST /api/v1/jobs/purge no necesita una clave: repetirlo con el mismo taskId ya es inocuo.

En texto y streaming, la clave evita un doble cargo, pero no recupera una respuesta perdida

La respuesta de una solicitud de texto se envía mientras se genera y no se almacena. Repetir una clave cuya solicitud ya terminó, con el mismo cuerpo, devuelve 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) y no añade ningún cargo; el texto no puede recuperarse en ningún lugar. Si la solicitud anterior falló, la repetición devuelve el mismo error, para que decidas si quieres intentarlo otra vez con una clave nueva. Con un cuerpo diferente, la clave devuelve 409.

Por tanto, en esos endpoints utiliza una clave nueva para cada mensaje nuevo y reutiliza una clave únicamente para reenviar exactamente la misma solicitud después de un error de red, aceptando que su respuesta puede haberse perdido. Si quieres recibir otra respuesta, envía una clave nueva y paga esa solicitud. Consulta Texto y streaming.

El plazo es de 24 horas

Una clave permanece válida durante 24 horas desde el momento en que se acepta por primera vez. Los reenvíos no amplían el plazo. Después, la misma clave se trata como una solicitud nueva y crea una tarea nueva y facturable, así que detén los reintentos automáticos con suficiente antelación.

El plazo de veinticuatro horas busca un equilibrio. Los reintentos del cliente suelen terminar en segundos o minutos, por lo que el período solo necesita cubrirlos. Sin embargo, cuanto más largo sea, más tiempo tendrás que esperar para reutilizar una clave: con una clave fija en el código, solo podrías enviar una tarea al día.

Cómo elegir las claves

  • Una clave por solicitud lógica, no por llamada HTTP. Los reintentos deben reutilizar la misma clave; de lo contrario, el mecanismo pierde su utilidad.
  • Derívala de un identificador que ya tengas: order-8814-render-1 resulta más útil que un UUID aleatorio porque puedes reconstruirlo después de reiniciar el proceso.
  • Si no tienes un identificador del que derivarla, genera un UUID antes del primer intento y guárdalo en tu base de datos junto al pedido correspondiente; los reintentos leen ese valor guardado.
  • Las claves se registran dentro de tu cuenta y no necesitan ser únicas a escala global, pero todos los reintentos de una misma solicitud lógica también deben utilizar la misma clave de API. La longitud máxima es de 128 caracteres.

Otra clave de API de la cuenta no puede recuperar la tarea mediante un reenvío

La asociación de idempotencia se registra para toda la cuenta, mientras que la visibilidad de las tareas se limita a cada clave de API. Otra clave que utilice una clave de idempotencia ya registrada recibe 409 Conflict (Idempotency-Key is already in use by this account), y la respuesta no revela el ID de la tarea original. Así se impide que una clave explore las tareas de otra integración mediante reenvíos. Reintentar no ayuda: envía el reintento con la clave de API utilizada en el primer intento.

Nunca generes la clave dentro del bucle de reintentos

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

Qué devuelve una solicitud repetida

En createTask, la respuesta conserva el mismo taskId y estimatedCost, pero state refleja el estado actual de la tarea original. Puede ser queued, running o un estado final. Un reenvío no crea otra tarea, no retiene más fondos ni añade otro cargo.

No hay un encabezado ni un campo que indique que recibiste una respuesta de reenvío. Para consultar el registro completo de la tarea, utiliza su taskId en recordInfo.

Envíos simultáneos con la misma clave

Cuando llegan simultáneamente dos solicitudes desde la misma clave de API con la misma clave de idempotencia, exactamente una crea la tarea y la otra recibe ese mismo taskId. La decisión se basa en una restricción de unicidad de la base de datos, en lugar de comprobar primero e insertar después: con solicitudes simultáneas, ambas comprobaciones podrían indicar que no existe una tarea, se crearían dos y ambas retenciones de fondos seguirían vigentes.

En casos poco frecuentes, esa competencia entre solicitudes aparece como:

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

Espera unos cientos de milisegundos y reintenta con la misma clave.

Hay otros dos 409 de createTask que no conviene reintentar: Idempotency-Key has already been used for a different request indica que cambiaste la solicitud, y Idempotency-Key is already in use by this account indica que otra clave de API de tu cuenta ya utilizó esta clave. Los tres comparten code: 409, y tu código no debería tomar decisiones basadas en msg. Por eso, limita los reintentos de 409 a uno o dos y después muestra el error para que una persona lo revise.

Ejemplo completo

La función recibe la clave como argumento. Calcúlala a partir de tus propios registros (en este caso, el ID del pedido), para que un proceso reiniciado vuelva a enviar la misma clave.

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

Los fallos de transporte también deben reutilizar la misma clave

Si fetch lanza una excepción, se agota el tiempo de conexión o se agota el tiempo de lectura, no sabes si la solicitud llegó. Precisamente para eso sirve la clave de idempotencia: reintenta con la misma clave y, en el peor caso, recibirás la tarea anterior. Si agotas los intentos, conserva la clave: una llamada posterior con ella seguirá devolviendo la misma tarea dentro del plazo de 24 horas.

En esta página