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ón | Respuesta | Qué hacer |
|---|---|---|
| Key nueva | El turno arranca. | — |
| Key repetida, mismo cuerpo, turno terminado | El mismo resultado, con Idempotent-Replayed: true. | Nada: ya lo tenés. |
| Key repetida, mismo cuerpo, turno corriendo | 409 api_v1_turn_in_progress | Esperar y reintentar con la misma key. |
| Key repetida, otro cuerpo | 409 api_v1_idempotency_conflict | Usar 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:
422de forma: contenido demasiado largo, adjuntos de más,metadatagrande.403por un scope compañero que falta.404de unchat_idque 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.
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")