Documentación

Idempotencia

Una key, un turno: reintentar sin lanzar —ni pagar— la misma conversación dos veces.

Enviar un turno cuesta niucredits y produce efectos: puede leer bibliotecas, ejecutar herramientas, mandar un correo por un conector. Repetirlo por un corte de red no es aceptable, así que el header Idempotency-Key es obligatorio en los dos endpoints de envío.

Atención

La API nunca genera una key por vos. Hacerlo convertiría cada reintento en un turno nuevo —exactamente lo que la idempotencia existe para evitar— y no tendrías forma de enterarte.

Cómo elegir la key

  • Un UUID v4 por intento lógico es lo más simple y siempre funciona.
  • Un id de negocio tuyo también sirve —ticket-8842-resumen, job-91021— y es mejor si tu cola puede reencolar el mismo trabajo.
  • Lo importante es que se conserve entre reintentos: si la generás dentro del bloque que reintenta, no sirve de nada.

Nota

De la key solo se guarda su hash SHA-256. Podés usar un identificador interno sin que ese valor quede en nuestras tablas ni en la auditoría.

Qué identifica una key

La reserva vive en (credencial, key). Además se guarda una huella del request: método, ruta, chat_id y cuerpo, en JSON canónico. Repetir la key con la misma huella es un reintento; con otra huella es un error tuyo.

SituaciónRespuestaQué hacer
Key nuevaEl turno arranca.
Key repetida, mismo cuerpo, turno terminadoEl mismo resultado, con Idempotent-Replayed: true.Nada: ya lo tenés.
Key repetida, mismo cuerpo, turno corriendo409 api_v1_turn_in_progressEsperar y reintentar con la misma key.
Key repetida, otro cuerpo409 api_v1_idempotency_conflictUsar una key nueva. Con esta va a fallar siempre.

Nota

Una reserva vive 24 horas. Pasado ese plazo se purga y reusar la key queda fuera de la garantía — pero nunca se purga antes.

Qué NO quema tu key

Un rechazo que era culpa del request desde el principio no consume la reserva. Estos errores te devuelven la key intacta y podés corregir y reintentar con la misma:

  • 422 de forma: contenido demasiado largo, adjuntos de más, metadata grande.
  • 403 por un scope compañero que falta.
  • 404 de un chat_id que es de otra credencial.
  • 409 api_v1_chat_busy, o un rechazo de licencia: se liberan explícitamente, porque no crearon ni cobraron nada.

Atención

En cambio, un turno que arrancó y falló —502, 504— sí queda registrado bajo esa key con su terminal de error. Reintentar con la misma devuelve ese error guardado; para volver a intentarlo de verdad, generá una key nueva.

Cómo se ve un replay

En el envío sincrónico, el replay devuelve el mismo status y el mismo cuerpo, con el header Idempotent-Replayed: true. Lo único que cambia es meta.request_id, que es el de este request, para que puedas cruzarlo con tu log.

En el stream pasa lo mismo: la respuesta trae Idempotent-Replayed: true y el cuerpo emite message.start y directamente el evento terminal guardado. No se reejecuta nada ni se vuelven a emitir los deltas.

Server-Sent Events
event: message.start
data: {"chat_id":"3f6b1a90-…","message_id":90213,"request_id":"<el de este request>"}

event: message.completed
data: {"id":90213,"chat_id":"3f6b1a90-…","content":"…","usage":{"prompt_tokens":2410,"completion_tokens":188,"total_tokens":2598,"provider":"OPENAI","model":"gpt-4o"}}

Si el proceso se cae

Cada turno tiene un plazo (30 minutos) fijado con el reloj de la base, no con el de la aplicación. Si el proceso que lo corría muere sin escribir su resultado, el primer reintento posterior al plazo cierra el turno como resultado desconocido y te entrega ese terminal.

Cerrar es lo único honesto cuando no se sabe si el turno alcanzó a producir algo. Un turno que quedara in_progress para siempre dejaría esa key inutilizable — y a vos sin forma de reintentar.

Un turno por chat

Además de la idempotencia, un chat solo admite un turno a la vez, venga de la API o de alguien que abrió ese chat en la aplicación. El segundo recibe 409 api_v1_chat_busy con el message_id del que está corriendo.

El motivo es concreto: la cancelación se indexa por chat, así que con dos turnos de la API en el mismo chat, cancelar uno cancelaría el otro y el que quedara colgado no tendría quién lo cierre. Un turno abierto desde la aplicación no cuenta para este límite.

Sugerencia

Si necesitás paralelismo, usá un chat por conversación. Crear chats es barato y es el modelo que la API espera.

Un envío reintentable

import time
import uuid

import requests


def send_turn(chat_id: str, content: str, token: str, *, key: str | None = None):
    """La key se genera UNA vez, fuera del bucle de reintentos."""
    key = key or str(uuid.uuid4())

    for attempt in range(5):
        response = requests.post(
            f"https://api.niucore.com/api/v1/chats/{chat_id}/messages",
            headers={
                "Authorization": f"Bearer {token}",
                "Idempotency-Key": key,
            },
            json={"content": content, "permission_mode": "auto"},
            timeout=(10, 1_800),
        )

        if response.status_code == 200:
            return response.json()["data"]

        code = response.json().get("message_code")
        if code == "api_v1_turn_in_progress":
            time.sleep(2**attempt)   # misma key: el turno sigue vivo
            continue

        response.raise_for_status()

    raise TimeoutError(f"el turno {key} no terminó a tiempo")