Todos los recursos

Un 429 o un 529 a mitad de un trabajo por lotes no debería tumbarte toda la corrida. Aquí ves cómo se combinan los reintentos, el backoff exponencial, el jitter y el header retry-after, y cuáles errores no debes reintentar jamás.

Maneja límites de tasa con reintentos y backoff en Python

En resumen

  • Reintenta solo los errores transitorios: 429 (límite de tasa), 408/timeouts, 500, 529 (overloaded). Nunca reintentes un 400, 401, 403 o 404, porque esperar no va a arreglar un request mal armado ni una clave inválida.
  • Aplica backoff exponencial (1s, 2s, 4s) con jitter para que una flota de workers no reintente todos a la vez y arme una estampida.
  • Si la respuesta trae un header retry-after, respétalo. Es el servidor diciéndote exactamente cuándo volver, y tu propio backoff es el plan B.
  • El SDK de Anthropic ya reintenta los 429/5xx dos veces con backoff. Ajusta max_retries primero, y arma tu loop a mano solo cuando necesites un control que el SDK no te da.
  • Pon un tope al total de intentos y vuelve a lanzar la excepción en el último. Los reintentos infinitos tapan caídas reales y convierten un bache de 30 segundos en un cuelgue silencioso de horas.

Cualquier API termina devolviendo un 429 o un 5xx tarde o temprano. Pasa bajo carga, durante un pico, o simplemente porque estás empujando un lote más rápido de lo que tu tier aguanta. Esos errores son transitorios: lo correcto es esperar un momento y volver a intentar, no tumbar el trabajo y perder las últimas 4,000 filas que ya procesaste. Esta guía cubre el patrón de confiabilidad que los maneja, o sea reintentos con backoff exponencial, jitter y el header retry-after, y algo igual de importante: los errores que nunca debes reintentar. Arrancamos con lo que el SDK de Anthropic ya hace por ti, luego armamos un loop a mano para cuando necesitas más, y al final lo ponemos a funcionar con una flota de workers concurrentes.

00 · Requisitos previos

Esto es un checklist corto, no un proyecto de setup. Vas a necesitar:

  • Un proyecto de Python funcionando con el SDK oficial instalado: pip install anthropic, y un ANTHROPIC_API_KEY en tu entorno. Los ejemplos usan claude-opus-4-8 pero el patrón es idéntico con cualquier modelo.
  • Código que hace la misma llamada a Claude muchas veces: un loop por lotes, un worker de cola, un job nocturno. La lógica de reintentos solo vale la pena cuando hay repetición y volumen. Una sola llamada interactiva rara vez necesita más que el default del SDK.
  • Una forma de ver tus logs. El objetivo de hacer esto bien es que una falla transitoria termine siendo un warning en el log y una pausa corta, no un stack trace y un proceso muerto.

Nota

Si vas a procesar miles de prompts que no dependen de la latencia, échale un ojo a la API de Message Batches antes de armar toda una infraestructura de reintentos. Corre de forma asíncrona a mitad de precio y absorbe el rate-limiting del lado del servidor, así que consultas por los resultados en vez de pelearte con 429s en un loop apretado. El retry/backoff es para el camino síncrono, donde el batching no encaja.

01 · Cuáles errores se reintentan (y cuáles nunca)

El error más común, por mucho, es reintentarlo todo. Un retry solo ayuda cuando el error es transitorio, cuando ese mismo request exacto podría funcionar si lo mandas de nuevo en unos segundos. Reintentar un error que no es transitorio solo quema tiempo y cuota mientras la falla sigue igual de rota.

Así queda dividido para la API de Claude:

StatusSignificado¿Reintentar?
400request inválidoNo, tu payload está mal; arréglalo
401API key inválidaNo, esperar no te va a dar una clave válida
403sin permisoNo, la clave no tiene acceso al modelo/feature
404no encontradoNo, casi siempre un typo en el model ID
408timeout del request
429límite de tasaSí, respeta retry-after
500error del servidor
529overloadedSí, haz backoff, considera un modelo más liviano

La asimetría importa. Reintentar un 429 es correcto y de buena educación. Reintentar un 400 en un loop es un bug disfrazado de resiliencia. Vas a ver el mismo request mal armado fallar cinco veces, comerte cinco esperas de backoff, y terminar exactamente donde empezaste, pero con un pedazo menos de tu presupuesto de rate limit.

Atención

No atrapes un Exception pelado para reintentarlo. Eso se traga los 400 y 401 que deberías estar viendo de inmediato, y convierte un "mi API key está mal" en un job que se cuelga por minutos antes de morir. Atrapa solo los tipos de excepción transitorios específicos y deja que todo lo demás se propague.

Usa las excepciones tipadas del SDK, no comparación de strings

El SDK de Anthropic te da una excepción tipada por cada código de status. Compara por la clase, nunca por el texto del mensaje de error. Los strings del mensaje cambian sin avisar, los códigos de status no.

  • anthropic.RateLimitError es 429
  • anthropic.InternalServerError es 500+
  • anthropic.APITimeoutError es cuando el request hizo timeout
  • anthropic.APIConnectionError es cuando no se pudo llegar a la API
  • anthropic.APIStatusError es la base para cualquier respuesta que no sea 2xx; revisa .status_code si necesitas un control más fino

Para el caso del overload vas a ver APIStatusError con status_code == 529. El SDK además expone .type en los errores de status (por ejemplo "overloaded_error") cuando quieres ramificar según la clasificación de la propia API.

02 · Arranca con lo que el SDK ya hace

Antes de escribir una sola línea de lógica de reintentos, ten esto claro: el SDK ya reintenta los errores 429 y 5xx por ti, dos veces por defecto, con backoff exponencial, y lee el header retry-after de forma automática. Para muchas cargas de trabajo, el arreglo correcto es ajustar un solo parámetro.

import anthropic

# Sube los reintentos a nivel de cliente — aplica a cada llamada.
client = anthropic.Anthropic(max_retries=5)

# O sobreescribe por request cuando una llamada necesita otro comportamiento.
resp = client.with_options(max_retries=8).messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Resume este ticket."}],
)

Esto resuelve el caso común de forma limpia. El backoff del SDK ya incluye jitter y respeta retry-after, así que obtienes el comportamiento correcto sin tener que encargarte tú del loop. Arma un loop a mano solo cuando necesites algo que el SDK no te da: logging propio en cada intento, un circuit breaker, dead-lettering por fila en un lote, o cálculos de backoff afinados a tu tier.

Consejo

Pon un timeout de request junto con los reintentos. El default es generoso; para un worker por lotes normalmente lo quieres más ajustado, para que un solo request atascado no frene toda la cola. Pásale timeout=30.0 al cliente (o a with_options) y deja que un timeout se vuelva un error reintentable más.

03 · Armar el loop a mano cuando necesitas control

Cuando el default del SDK no alcanza, el patrón es chico y vale la pena entenderlo en vez de copiarlo a ciegas. Tres ingredientes: crecimiento exponencial, jitter y un tope duro.

import time
import random
import logging
import anthropic

log = logging.getLogger("claude")
client = anthropic.Anthropic(max_retries=0)  # ahora el loop es nuestro

RETRYABLE = (
    anthropic.RateLimitError,
    anthropic.InternalServerError,
    anthropic.APITimeoutError,
    anthropic.APIConnectionError,
)

def call_with_retry(messages, attempts=5, base=1.0, cap=30.0):
    for i in range(attempts):
        try:
            return client.messages.create(
                model="claude-opus-4-8",
                max_tokens=1024,
                messages=messages,
            )
        except RETRYABLE as e:
            if i == attempts - 1:
                log.error("nos rendimos despues de %d intentos", attempts)
                raise
            # Respeta retry-after si el servidor lo manda; si no, backoff exponencial.
            headers = getattr(getattr(e, "response", None), "headers", {})
            server_delay = headers.get("retry-after") if headers else None
            if server_delay is not None:
                wait = float(server_delay)
            else:
                wait = min(cap, base * (2 ** i)) + random.uniform(0, 1)
            log.warning("intento %d fallo (%s); reintentando en %.1fs", i + 1, type(e).__name__, wait)
            time.sleep(wait)

Repasando los tres ingredientes:

  1. Crecimiento exponencial. Cada espera casi se duplica: 1s, 2s, 4s, 8s. Es la diferencia entre echarte para atrás con educación y seguir golpeando un servicio que ya viene apretado. El cap evita que una cadena larga de reintentos te haga esperar minutos en el último intento.
  2. Jitter. El término random.uniform(0, 1) reparte los reintentos en el tiempo. Sin él, cien workers que pegan un 429 en el mismo instante van a despertar y reintentar todos en el mismo instante, una estampida que vuelve a disparar el rate limit y no te lleva a ningún lado. A escala, el jitter no es opcional.
  3. Un tope duro. attempts=5 más el volver a lanzar la excepción en la última iteración significa que una caída real sale como una excepción que tu sistema de alertas puede atrapar, en vez de un loop infinito que se traga en silencio un incidente de varias horas.

Importante

Cuando la respuesta trae un header retry-after, respétalo en vez de hacer tus propios cálculos. Un 429 de la API incluye los segundos que faltan para que tu cuota se reponga. Ese es el servidor diciéndote la hora exacta para volver. Dormir menos que eso solo te gana otro 429; dormir más desperdicia throughput. Tu backoff exponencial es el plan B para cuando no viene ningún header (la mayoría de las respuestas 5xx).

04 · Ejemplo práctico: un trabajo por lotes que sobrevive un pico de rate limit

Aquí es donde todo se junta. Estás clasificando 5,000 tickets de soporte. A mitad de camino pegas el límite de tokens por minuto de tu tier y empiezas a recibir 429s. No quieres perder los 2,500 que ya hiciste, ni quedarte reintentando para siempre los cuatro tickets cuyos payloads vienen mal armados.

import json
import logging

log = logging.getLogger("claude")
results, failures = [], []

for ticket in tickets:
    try:
        resp = call_with_retry(
            [{"role": "user", "content": f"Clasifica este ticket:\n{ticket['body']}"}]
        )
        results.append({"id": ticket["id"], "label": resp.content[0].text})
    except anthropic.APIStatusError as e:
        if e.status_code in (400, 422):
            # No reintentable: input malo. Mandalo a dead-letter y sigue.
            log.warning("ticket %s rechazado (%s); saltando", ticket["id"], e.status_code)
            failures.append({"id": ticket["id"], "error": str(e)})
        else:
            # Reintentos agotados en un error transitorio — este si es un problema real.
            raise

with open("results.jsonl", "w") as f:
    for r in results:
        f.write(json.dumps(r) + "\n")

Dos cosas hacen que esto sobreviva un pico en vez de caerse. Primero, los errores transitorios se absorben dentro de call_with_retry, así que el pico solo pone más lento el job, no lo detiene. Segundo, los errores no reintentables 400/422 van a dead-letter: se registran, se guardan en failures y se saltan, así que cuatro tickets malos no tumban una corrida que procesó bien los otros 4,996. El trabajo que ya hiciste se va escribiendo de forma incremental, así que un crash duro en el intento 5,001 igual te deja todo lo de hasta ese punto.

05 · Pasar a concurrencia sin derretir tu rate limit

Retry más jitter es lo que hace segura la concurrencia, pero no es toda la historia. Si corres 50 workers a fondo, vas a pasar la mayor parte del tiempo en backoff porque estructuralmente estás por encima de tu límite. La lógica de reintentos absorbe picos, no te sube el techo.

Unas cuantas prácticas que de verdad hacen la diferencia:

  • Limita la concurrencia para que cuadre con tu tier. Un pool acotado (un Semaphore, o un ThreadPoolExecutor / task group de asyncio de tamaño fijo) dimensionado a tus tokens por minuto le gana a un fan-out sin límite que vive en el infierno del retry.
  • Usa el cliente async para fan-out con mucho I/O. anthropic.AsyncAnthropic con asyncio.gather sobre un semáforo acotado te da alto throughput sin tener 50 threads del sistema operativo. Aplica el mismo patrón de reintentos, solo que usas await asyncio.sleep(wait) en vez de time.sleep(wait).
  • Mira los headers de rate limit, no reacciones solo a los 429s. Las respuestas traen valores x-ratelimit-remaining-*. Un worker que baja el ritmo a medida que su presupuesto restante se acerca a cero esquiva el 429 por completo. Limitar el ritmo de forma proactiva le gana a reintentar de forma reactiva.
  • Considera un modelo de fallback ante 529s sostenidos. Si Opus está sobrecargado, un modelo menos cargado (Haiku, por ejemplo) puede responder de inmediato. Que eso sea aceptable depende de la tarea, pero para clasificación o extracción suele ser una degradación razonable.

Atención

El backoff es un amortiguador, no un planificador de capacidad. Si tu demanda en estado estable supera tu rate limit, ningún número de reintentos lo arregla. Solo vas a oscilar entre ráfagas y backoff, con peor latencia de cola que si hubieras limitado el ritmo a tu techo real. Dimensiona tu concurrencia a tu tier, o pide un tier más alto. El retry maneja los picos alrededor de una base sostenible; no puede fabricar throughput que no tienes.

La confiabilidad aquí se trata sobre todo de ser honesto sobre cuáles fallas son temporales y cuáles no. Reintenta las transitorias con backoff y jitter, respeta el retry-after del servidor cuando te lo da, manda a dead-letter las permanentes para que no envenenen la corrida, y pon un tope a tus intentos para que una caída real salga como una alerta y no como un cuelgue. Acierta esas cuatro y un 429 a mitad de un job de 5,000 filas se vuelve una nota al pie en los logs en lugar de una llamada a las 2 de la mañana.

Puntos clave

  • Reintenta solo los errores transitorios (429, 408, 500, 529); deja salir los 400/401/403/404 de inmediato, porque esperar nunca los arregla.
  • El backoff exponencial te cubre un cliente; el jitter es lo que hace segura una flota entera. Usa los dos.
  • Respeta el header retry-after cuando venga; cae a tu propio backoff cuando no.
  • Prueba el max_retries del SDK antes de armar el loop a mano; el loop tipado es para el control que el SDK no te da.
  • Pon un tope a los intentos y manda a dead-letter las fallas permanentes, para que un pico ponga lento el job en vez de matarlo.

Preguntas frecuentes

¿El SDK ya no reintenta? ¿Para qué escribo mi propio loop?

Sí lo hace: 429 y 5xx, dos veces por defecto, con backoff con jitter y soporte de retry-after. Para la mayoría de las cargas, subir max_retries es todo el arreglo que necesitas. Arma un loop a mano solo cuando necesites un comportamiento que el SDK no expone: logging por intento, dead-lettering por fila en un lote, un circuit breaker, o cálculos de backoff afinados a tu tier exacto.

¿Cuál es la diferencia entre un 429 y un 529?

Un 429 es rate limiting: superaste tu propia cuota (requests o tokens por minuto), y trae un retry-after que te dice cuándo se repone tu presupuesto. Un 529 es overload: el servicio de Anthropic está saturado de forma temporal, sin que tenga nada que ver con tu cuota. Ambos son reintentables, pero piden respuestas distintas. Un 429 significa bajar el ritmo o subir de tier; un 529 sostenido podría significar caer a un modelo menos cargado.

¿Por qué necesito jitter? El backoff exponencial ya separa los reintentos.

El backoff exponencial separa los reintentos de un solo cliente, pero no desincroniza a muchos clientes entre sí. Si cien workers pegan un 429 en el mismo momento, todos calculan el mismo horario de 1s, 2s, 4s y reintentan todos a la vez, una estampida que vuelve a disparar el rate limit en cada oleada. El jitter agrega un pequeño offset aleatorio para que los reintentos se repartan y el límite tenga chance de recuperarse.

¿En cuántos intentos debo poner el tope?

Cinco en total es un default sensato para la mayoría del trabajo interactivo y por lotes. Con backoff exponencial eso da como 30 segundos de espera total, suficiente para aguantar un pico normal. Súbelo más (8-10) para jobs nocturnos desatendidos, donde terminar tarde le gana a fallar. La clave es que haya un tope, punto: un loop sin tope convierte una caída real en un cuelgue silencioso que ninguna alerta llega a atrapar.

Mi job se la pasa en backoff. ¿Está mal mi lógica de reintentos?

Probablemente no: la lógica de reintentos está haciendo su trabajo; el problema es que tu demanda en estado estable supera tu rate limit. El backoff es un amortiguador para los picos, no un planificador de capacidad. Si vives con el ritmo limitado, ninguna estrategia de retry lo arregla. Limita tu concurrencia para que cuadre con tu tier, limita el ritmo de forma proactiva usando los headers x-ratelimit-remaining, mueve el trabajo no urgente a la API de Batches, o pide un tier más alto.

¿Prefieres que lo hagamos por ti?

Esto mismo lo construimos para negocios como el tuyo. La primera conversación es gratis y sin compromiso.

Escríbenos por WhatsApp

Escríbenos por WhatsApp

Escanéalo con tu teléfono para escribirnos por WhatsApp.

Escanéalo con tu teléfono para escribirnos por WhatsApp.

¿Estás desde el teléfono y no puedes escanear? Escríbenos a info@ilustrari.com

Primera conversación gratis. Te responde el fundador.

Recursos relacionados