Tenacity
Retry dengan exponential backoff dan jitter — mekanisme wajib untuk memanggil API LLM yang punya rate limit.
Intisari
- Satu dekorator
@retrymenggantikan puluhan baris loop retry manual. - Tiga bagian yang selalu kamu atur:
stop(kapan menyerah),wait(jeda),retry(error mana yang layak diulang). - Wajib pakai exponential backoff dengan jitter:
wait_random_exponential. - Jangan mengulang error 4xx selain 429 — request-nya memang salah, mengulang hanya membuang kuota.
- Bekerja untuk fungsi sinkron maupun
async deftanpa perubahan.
Masalahnya
Panggilan jaringan gagal secara sporadis: rate limit, server kelebihan beban, koneksi putus. Retry manual cepat berubah jadi kode yang berantakan dan rentan salah:
for percobaan in range(5):
try:
return panggil_api()
except Exception:
if percobaan == 4:
raise
time.sleep(2 ** percobaan) # tanpa jitter — semua klien mengulang serentak
uv add tenacity
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(5), wait=wait_exponential(min=1, max=30))
def panggil_api():
...
Tiga bagian yang perlu diatur
1. stop — kapan menyerah
from tenacity import stop_after_attempt, stop_after_delay
stop=stop_after_attempt(5) # maksimal 5 percobaan
stop=stop_after_delay(60) # maksimal 60 detik total
stop=stop_after_attempt(5) | stop_after_delay(60) # mana yang lebih dulu tercapai
2. wait — berapa lama jeda
from tenacity import wait_fixed, wait_exponential, wait_random_exponential
wait=wait_fixed(2) # selalu 2 detik
wait=wait_exponential(multiplier=1, min=1, max=60) # 1, 2, 4, 8, 16, 32, 60...
wait=wait_random_exponential(multiplier=1, max=60) # eksponensial + acak ← PAKAI INI
Kenapa jitter (unsur acak) wajib: kalau seratus worker-mu terkena rate limit pada detik yang sama, backoff tanpa jitter membuat semuanya mengulang pada detik yang sama pula — dan langsung kena limit lagi. Ini disebut thundering herd. Menyebar waktu ulang secara acak memutus siklus itu.
3. retry — error mana yang layak diulang
from tenacity import retry_if_exception_type, retry_if_result
retry=retry_if_exception_type(httpx.TimeoutException)
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError))
retry=retry_if_result(lambda r: r.status_code >= 500)
Yang boleh dan tidak boleh diulang
| Kondisi | Ulangi? | Alasan |
|---|---|---|
| Timeout / koneksi putus | ✅ | Gangguan sementara |
429 rate limit | ✅ | Tunggu, lalu coba lagi |
500, 502, 503, 529 | ✅ | Masalah di sisi server |
400 bad request | ❌ | Request-mu memang salah |
401 / 403 | ❌ | Kredensial salah |
404 | ❌ | Resource tidak ada |
422 validasi | ❌ | Data tidak valid |
Mengulang error 4xx adalah bug yang mahal. Untuk API LLM, kamu tetap dibebani token input pada tiap percobaan — jadi mengulang request yang pasti gagal berarti membayar berkali-kali untuk kegagalan yang sama.
Pola lengkap untuk panggilan LLM
import httpx
from tenacity import (
retry, stop_after_attempt, wait_random_exponential,
retry_if_exception_type, before_sleep_log,
)
import logging
logger = logging.getLogger(__name__)
def layak_diulang(e: BaseException) -> bool:
if isinstance(e, (httpx.TimeoutException, httpx.ConnectError)):
return True
if isinstance(e, httpx.HTTPStatusError):
return e.response.status_code == 429 or e.response.status_code >= 500
return False
@retry(
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=1, max=60),
retry=retry_if_exception_type(Exception) & retry_if_exception(layak_diulang),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
async def panggil_llm(prompt: str) -> str:
r = await client.post("/v1/messages", json={"prompt": prompt})
r.raise_for_status()
return r.json()["content"]
Bentuk yang lebih sederhana dan sama efektifnya:
from tenacity import retry_if_exception
@retry(
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=1, max=60),
retry=retry_if_exception(layak_diulang),
reraise=True,
)
async def panggil_llm(prompt: str) -> str: ...
Opsi lain yang berguna
@retry(
reraise=True, # lempar exception ASLI, bukan RetryError pembungkus
before_sleep=before_sleep_log(logger, logging.WARNING), # catat tiap percobaan
after=after_log(logger, logging.INFO),
)
def f(): ...
reraise=True hampir selalu yang kamu mau. Tanpa itu, tenacity membungkus
kegagalan terakhir dalam RetryError — sehingga blok except
httpx.HTTPStatusError di pemanggil tidak akan menangkapnya lagi.
Mendengarkan header Retry-After
from tenacity import wait_base
class TungguSesuaiHeader(wait_base):
def __call__(self, retry_state) -> float:
e = retry_state.outcome.exception()
if isinstance(e, httpx.HTTPStatusError):
ra = e.response.headers.get("retry-after")
if ra and ra.isdigit():
return float(ra)
return wait_random_exponential(multiplier=1, max=60)(retry_state)
Server yang baik memberi tahu berapa lama kamu harus menunggu. Menghormatinya lebih sopan dan lebih cepat pulih.
Tanpa dekorator
from tenacity import AsyncRetrying, stop_after_attempt, wait_random_exponential
async for percobaan in AsyncRetrying(
stop=stop_after_attempt(3),
wait=wait_random_exponential(max=30),
reraise=True,
):
with percobaan:
hasil = await panggil_api()
Bentuk ini berguna kalau parameter retry-nya perlu ditentukan saat runtime.
Catatan: SDK sering sudah punya retry
SDK Anthropic sudah mengulang otomatis untuk 408, 409, 429, dan 5xx —
default max_retries=2. Jadi:
client = anthropic.Anthropic(max_retries=5) # cukup ubah angkanya
Pakai tenacity untuk lapisan di luar SDK: HTTP client-mu sendiri, panggilan ke vector store, atau logika retry tingkat aplikasi yang lebih kaya. Jangan menumpuk retry di atas retry tanpa sadar — 5 percobaan SDK × 5 percobaan tenacity = 25 panggilan berbayar.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.