Lleva tu integración de Claude Sonnet 5 a producción: streaming, stop reasons, reintentos y errores — Cesar Ayala
← Todos los artículos

Lleva tu integración de Claude Sonnet 5 a producción: streaming, stop reasons, reintentos y errores

Tu llamada feliz a Sonnet 5 se rompe en producción: la salida de 128k expira sin streaming, los stop reasons no manejados pierden trabajo en silencio y los 429/5xx transitorios no se reintentan. Endurécela: usa streaming con get_final_message, ramifica en cada stop_reason, captura excepciones tipadas (reintenta 429+5xx, nunca 4xx), pasa una idempotency key y añade una cola con jitter a escala.

¿Por qué se rompe en producción la llamada del camino feliz?

Voy a empezar por donde duele. Esta es la llamada que copiaste del quickstart, la que funcionó a la primera en tu laptop:

import anthropic

client = anthropic.Anthropic()

message = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=4096,
    messages=[{"role": "user", "content": "Resume este reporte..."}],
)
print(message.content[0].text)

En tu máquina funciona porque la salida es corta, el tráfico es de una sola petición y no pasó nada transitorio. Ninguna de esas tres condiciones se sostiene en producción. Esta misma llamada se rompe de cuatro formas concretas:

  • Falla 1 — la salida larga expira. Sonnet 5 produce hasta 128k tokens de salida (300k en el Batch API con el beta header output-300k-2026-03-24). Los SDKs validan que una petición sin streaming no exceda un timeout de 10 minutos, y como guía práctica una salida arriba de unos ~16k max_tokens te acerca a ese límite. Además, las redes tiran conexiones inactivas y tu petición muere sin respuesta.
  • Falla 2 — un stop_reason no manejado pierde trabajo en silencio. Si lees content[0].text a ciegas, manejas mal tool_use, max_tokens, pause_turn y refusal. No truena: simplemente devuelves basura o nada, y nadie se entera hasta que un cliente reclama.
  • Falla 3 — un 429/5xx transitorio mata la petición. A escala, los rate limits y las sobrecargas son normales, no excepciones. Si no reintentas, pierdes peticiones perfectamente válidas.
  • Falla 4 — sin deduplicación, reintentas trabajo con efectos secundarios. Un agente que reenvía tras un timeout cobra dos veces y actúa dos veces (escribe, paga, manda correo) por duplicado.

El resto del post convierte esta llamada frágil en una que sobrevive. Si quieres el contexto más amplio de operar LLMs en serio, lee llevar un LLM a producción; aquí me enfoco en endurecer la integración de Sonnet 5. Nota honesta: Sonnet 5 acaba de salir (30 de junio de 2026), así que confirma cada forma contra la documentación vigente.

Streamingsalida larga con get_final_message()
stop_reasonramifica en los 5: end_turn / tool_use / pause_turn / refusal / max_tokens
Reintentosauto 429+5xx incl. 504 (excepciones tipadas + retry-after); NUNCA 4xx
Deduplicaciónclave de dedup que tú guardas antes de reintentar
Escalacola + jitter sobre lo que ya hace el SDK

¿Por qué tienes que usar streaming con la salida larga de Sonnet 5?

Primer paso para endurecer: streaming. La mecánica es simple. Los SDKs validan que una petición sin streaming no esté pensada para exceder un timeout de 10 minutos; como referencia práctica, arriba de unos ~16k max_tokens te acercas a ese límite, así que la salida de 128k de Sonnet 5 prácticamente exige streaming. No es opcional para cualquier ruta que pueda generar texto largo.

Lo bueno: no necesitas escribir manejadores de eventos. Usa client.messages.stream(...) como context manager y llama a stream.get_final_message() para obtener el Message completo.

import anthropic

client = anthropic.Anthropic()

with client.messages.stream(
    model="claude-sonnet-5",
    max_tokens=128000,
    thinking={"type": "adaptive"},  # adaptive thinking; NO budget_tokens
    output_config={"effort": "high"},  # default en Sonnet 5; re-ajusta por ruta
    messages=[{"role": "user", "content": "Escribe un análisis detallado..."}],
) as stream:
    message = stream.get_final_message()

print(message.stop_reason)

Fíjate en las formas exactas. model="claude-sonnet-5", thinking={"type": "adaptive"} y nada de temperature, top_p, top_k ni budget_tokens — Sonnet 5 no los acepta. output_config.effort (low/medium/high/max) viene en high por defecto en la Claude API y Claude Code; súbelo o bájalo por ruta, porque más effort significa más thinking, más tokens, más latencia y más costo. En TypeScript el equivalente es .finalMessage(). Los detalles están en la documentación de Streaming. El streaming también te deja manejar pause_turn de forma limpia cuando usas server-side tools, que es lo que sigue.

Construye la peticiónmodel claude-sonnet-5, thinking adaptive, sin temperature
Abre el streamclient.messages.stream(...) + get_final_message()
429/5xx/504 -> el SDK reintentabackoff exponencial, honra retry-after
stop_reason pause_turn -> reenvíareanuda el server-side tool
refusal -> manéjalono leas content a ciegas
end_turn -> listoahora sí lees el contenido

¿Ramificaste en cada stop_reason?

Segundo paso: ramificar en los cinco valores de stop_reason para que ningún resultado se pierda en silencio. Leer content[0].text sin esta verificación es exactamente la falla silenciosa de la primera sección.

def manejar_respuesta(message, client, params):
    # params["messages"] debe traer ya el historial completo de la conversación.
    match message.stop_reason:
        case "end_turn":
            # El modelo terminó: seguro leer el contenido.
            return message.content
        case "tool_use":
            # Ejecuta la herramienta pedida y continúa el loop.
            return ejecutar_herramientas_y_continuar(message, client, params)
        case "max_tokens":
            # Salida cortada: sube max_tokens (o usa streaming) y continúa.
            params["max_tokens"] = min(params["max_tokens"] * 2, 128000)
            return reintentar_continuando(client, params)
        case "pause_turn":
            # Server-side tool en pausa: reenvía la conversación COMPLETA para reanudar.
            # params["messages"] ya tiene el turno del usuario; agregamos el turno pausado.
            params["messages"].append({"role": message.role, "content": message.content})
            return reanudar(client, params)
        case "refusal":
            # Rechazo de seguridad: manéjalo explícitamente, NO leas content a ciegas.
            return registrar_refusal(message)
        case otro:
            raise ValueError(f"stop_reason inesperado: {otro}")

Cada rama es trabajo real: end_turn es el único caso donde lees el contenido sin pensarlo. tool_use corre la herramienta y continúa. max_tokens significa que cortaste la salida — sube el límite o usa streaming. pause_turn lo dispara un server-side tool y se resuelve reenviando la conversación completa, no solo el último turno. refusal es un rechazo de seguridad que manejas aparte. Si estás construyendo el loop completo, esto va de la mano con crear un agente autónomo con Claude Sonnet 5 — el agente que estás endureciendo aquí.

¿Cuáles errores reintentas y cuáles arreglas?

Tercer paso: manejo de excepciones tipadas. La línea entre reintentar y arreglar es clara y vale oro porque reintentar el error equivocado nunca ayuda.

Reintenta (transitorios): 429 rate_limit_error (honra el header retry-after), 500 api_error, 504 timeout_error (la doc sugiere streaming para peticiones largas — atado directo a la tesis de este post) y 529 overloaded_error.

Arregla (tu bug): 400 invalid_request_error, 401 authentication_error, 402 billing_error, 403 permission_error, 404 not_found_error (por ejemplo, un id de modelo mal escrito) y 413 request_too_large. Reintentar estos es perder tiempo.

Los SDKs oficiales ya reintentan 429 + 5xx con backoff exponencial; el max_retries por defecto es 2 y lo puedes subir en el cliente. Lo crítico: captura las clases de excepción tipadas (anthropic.RateLimitError, anthropic.APIError), que exponen .status y .type. Nunca compares el texto del mensaje de error contra cadenas (nada de string-match): el texto puede cambiar, el tipo no.

import anthropic

client = anthropic.Anthropic(max_retries=5)  # sube el default de 2

try:
    with client.messages.stream(
        model="claude-sonnet-5",
        max_tokens=128000,
        thinking={"type": "adaptive"},
        messages=[{"role": "user", "content": "..."}],
    ) as stream:
        message = stream.get_final_message()
except anthropic.RateLimitError as e:
    # 429: el SDK ya hizo backoff; aquí decides la cola de aplicación.
    encolar_para_reintento(e)
except anthropic.APIError as e:
    # Transitorios (incluido 504 timeout) vs 4xx: ramifica por .status.
    if e.status in (500, 504, 529):
        encolar_para_reintento(e)
    else:
        raise  # 4xx: es tu bug, arréglalo, no reintentes.

Un 404 sobre un id de modelo casi siempre es un typo: el id es exactamente claude-sonnet-5, snapshot fijo sin sufijo de fecha. Y ojo con el streaming: un error puede ocurrir después de que empezó una respuesta SSE 200, así que envuelve también el consumo del stream.

REINTENTA (transitorios)

  • 429 rate_limit_error — honra retry-after
  • 500 api_error
  • 504 timeout_error — usa streaming/Batch en peticiones largas
  • 529 overloaded_error
  • el SDK ya hace backoff; default max_retries=2

ARREGLA (tu bug)

  • 400 invalid_request_error
  • 401 authentication_error
  • 402 billing_error
  • 403 permission_error
  • 404 not_found_error — id de modelo mal escrito
  • 413 request_too_large

¿Cómo sobrevives a los rate limits a escala?

Cuarto paso: diseñar para los 429 a volumen, más allá de lo que el SDK hace solo. Un 429 carga un header retry-after más headers x-ratelimit-*; el SDK hace backoff automático, pero a escala tú tienes que diseñar para esto.

Añade una cola a nivel de aplicación con jitter para que los reintentos no se sincronicen en una estampida (thundering herd). Si todos tus workers reintentan al mismo segundo, recreas el pico que te dio el 429. Y sube el tráfico gradualmente manteniendo patrones de uso consistentes: un salto brusco en consumo puede dispararte 429 por límites de aceleración.

Para trabajos offline que no son sensibles a latencia, mueve el trabajo al Message Batches API: corre al 50% del precio de tokens estándar, hasta 100k requests / 256 MB por batch, resultados disponibles ~29 días, y desbloquea 300k de salida con el beta header output-300k-2026-03-24. Es el cambio de costo más grande que puedes hacer si tu caso lo permite.

El prompt caching recorta el costo de entrada en prefijos estables repetidos: cache_control: {"type": "ephemeral"} al final del prefijo estable, máximo 4 breakpoints, una lectura cuesta ~0.1x y una escritura 1.25x (TTL 5 min) o 2x (1 hora). Verifica con usage.cache_read_input_tokens. El mínimo de prefijo cacheable depende del modelo — confírmalo para Sonnet 5 en la documentación en lugar de hardcodear un número.

¿Cómo evitas trabajo duplicado en los reintentos del agente?

Quinto paso: deduplicación. Cuando un agente reenvía tras un timeout, puede duplicar trabajo y cargos. La defensa que sí controlas es del lado del cliente: genera una clave de deduplicación estable para cada unidad de trabajo, guárdala antes de ejecutar la acción con efecto secundario y revísala antes de reintentar.

import hashlib

def clave_dedup(orden_id: str, intento: str) -> str:
    return hashlib.sha256(f"{orden_id}:{intento}".encode()).hexdigest()

def ejecutar_una_vez(clave, accion):
    # store es TU base de datos / cola: la fuente de verdad de qué ya corrió.
    if store.ya_completado(clave):
        return store.resultado(clave)        # ya se hizo: no repitas el efecto.
    resultado = accion()                     # p. ej. cobro en Stripe, escritura en DB.
    store.marcar_completado(clave, resultado)
    return resultado

Aplícalo a cualquier acción que pudieras reintentar, sobre todo a las herramientas con efectos secundarios: escrituras en base de datos, pagos, correos. Una llamada duplicada a un cobro de Stripe no es un detalle teórico, es dinero real. Empareja esta deduplicación con el manejo de excepciones tipadas para que los reintentos sean a la vez seguros y limitados solo a errores transitorios. Si Anthropic publica un mecanismo de idempotencia a nivel de API, úsalo según su documentación; mientras tanto, la dedup del lado del cliente es lo correcto.

  1. Streaming con get_final_message()salida larga sin timeouts
  2. Ramifica en cada stop_reasonend_turn / tool_use / pause_turn / refusal / max_tokens
  3. Excepciones tipadas + max_retriesreintenta 429/500/504/529, nunca 4xx
  4. Clave de dedup antes de reintentarevita cobros y acciones duplicadas
  5. Cola + jitter a escalasin estampida; sube tráfico gradual
  6. Monitorea usagecache_read_input_tokens y effort por ruta

¿Cuánto cuesta la lista de verificación completa de producción?

Apilemos la lista: streaming para salida larga, ramificar en cada stop_reason, reintentos con excepciones tipadas, deduplicación del lado del cliente, cola + jitter, y monitorear uso. Eso es lo que separa “funciona en mi laptop” de “sobrevive en producción”.

Precio honesto: Sonnet 5 cuesta $3 / $15 por MTok estándar, con un precio introductorio de $2 / $10 por MTok hasta el 31 de agosto de 2026. Confirma el precio vigente en la documentación, ya que el modelo es nuevo. Modela tu factura al precio estándar de $3/$15 porque el introductorio es temporal — si presupuestas al precio intro, en septiembre te llevas la sorpresa (y la factura al doble).

Sonnet 5 es el tier de valor (cerca de Opus 4.8 a menor costo), no la frontera absoluta. Elígelo para volumen confiable de producción, no para el razonamiento más al límite. Monitorea usage.cache_read_input_tokens y el effort por ruta para mantener la factura honesta, y reserva effort max para las rutas donde importa más que el resultado sea correcto que lo que cuesta. Confirma cada detalle contra la documentación vigente porque el modelo es nuevo. Antes de desplegar, vale la pena medir el agente — para eso está la evaluación mínima de agentes de IA.

Preguntas rápidas

¿Cuál es el id exacto del modelo? claude-sonnet-5 — snapshot fijo sin sufijo de fecha. Un 404 not_found_error casi siempre es un typo aquí.

¿Necesito streaming para salidas cortas? No estrictamente, pero cualquier cosa que pueda salir larga sí; los SDKs validan contra un timeout de 10 minutos en peticiones sin streaming, y arriba de unos ~16k max_tokens ya te acercas a ese riesgo.

¿Puedo poner temperature o budget_tokens? No — Sonnet 5 usa adaptive thinking solamente (thinking tipo adaptive); no hay budget_tokens, temperature, top_p ni top_k.

¿El SDK ya reintenta por mí? Sí, 429+5xx (incluido 504) con backoff, max_retries por defecto 2 — pero a escala diseña tu propia cola + jitter y tu deduplicación.

¿Presupuesto al precio intro? No — modela al estándar $3/$15; el intro de $2/$10 termina el 31 de agosto de 2026.

A producción

La distancia entre “funciona en mi laptop” y “sobrevive en producción” son seis hábitos concretos, no una reescritura. Usa streaming, ramifica en cada stop_reason, reintenta solo los errores transitorios con excepciones tipadas, deduplica del lado del cliente, y encola con jitter. Si necesitas qué cambió y cómo migrar, está en qué cambia en Sonnet 5 y cómo migrar, y hay más guías en agentes de IA.

Verifica todo contra la documentación vigente de Anthropic — Sonnet 5 es nuevo y las formas exactas son justo lo que hace que esto sea correcto. Fuentes oficiales: Introducing Claude Sonnet 5, Models overview, Errors y Streaming.