Python'da Üstel Geri Çekilmeli Yeniden Deneme
Selamlar, bu yazıda Python tarafında dış dünyaya konuşan kodun en sık takıldığı yere, yani 'bir saniye sonra tekrar deneyelim' meselesine bakacağız. Network hıçkırır, veritabanı bağlantı havuzu bir an boğulur, üçüncü parti API tam siz işlem yaparken 503 döner. Sorunun kendisinden çok, biz bu küçük titreşimleri nasıl karşılıyoruz, kullanıcıya yansıyıp yansımaması ona bağlı.
Hadi tenacity kütüphanesi etrafında üstel backoff, jitter ve devre kesici (circuit breaker) örüntülerini gerçekten production'da işe yarayacak şekilde örelim.
Neden üstel geri çekilme?
Klasik 'üç defa hemen dene' tuzağını çoğumuz biliriz. Hata gelir, biz hemen tekrar deneriz, hata yine gelir, yine tekrar deneriz. Sonuç:
0.00s: istek -> başarısız
0.01s: deneme 1 -> başarısız
0.02s: deneme 2 -> başarısız
0.03s: deneme 3 -> başarısız
Burada zaten zorlanan servisi dövmüş oluyoruz. Üstel geri çekilme her başarısızlıktan sonra bekleme süresini ikiye katlar:
0s: istek -> başarısız
1s: deneme 1 -> başarısız
2s: deneme 2 -> başarısız
4s: deneme 3 -> başarılı
Yanına biraz jitter (rastgele oynatma) eklerseniz, aynı anda yüzlerce istemci aynı saniyede tekrar denemekten vazgeçer. Buna literatürde thundering herd deniyor; bence Türkçesi 'sürü saldırısı' bayağı yerine oturuyor.
Tenacity ile minimum kurulum
Önce paketi kuralım:
pip install tenacity
Tipik bir HTTP çağrısını koruma altına almak şu kadar:
from tenacity import retry, stop_after_attempt, wait_exponential
import requests
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
)
def veri_getir(url: str):
# 5 saniye timeout - sonsuza kadar bekleme
yanit = requests.get(url, timeout=5)
yanit.raise_for_status()
return yanit.json()
Burada üç şey oluyor: stop_after_attempt(3) toplam üç denemeden sonra pes eder, wait_exponential 1s, 2s, 4s şeklinde artarak bekler ve raise_for_status 4xx/5xx geldiğinde exception fırlatarak tenacity'nin tekrar denemesini tetikler. Yani happy path'te zaten hiçbir maliyet yok; sadece kötü gün geldiğinde devreye giriyor.
Hangi hatada tekrar denemeli?
Burası kritik. 404 ya da 400 aldıysanız, hatayı bin kere de tekrarlasanız aynı cevabı alırsınız - hatta daha kötüsü, rate limit'e takılırsınız. O yüzden hatayı sınıflandırmak şart:
from tenacity import retry, retry_if_exception, wait_exponential_jitter
import requests
def tekrar_denemeye_deger_mi(hata: BaseException) -> bool:
if isinstance(hata, requests.HTTPError):
# Sadece sunucu kaynaklı 5xx hatalarını tekrar dene
return 500 <= hata.response.status_code < 600
# Bağlantı kopması ve timeout her zaman tekrar denenebilir
return isinstance(hata, (requests.ConnectionError, requests.Timeout))
@retry(
retry=retry_if_exception(tekrar_denemeye_deger_mi),
wait=wait_exponential_jitter(initial=1, max=30),
)
def akilli_getir(url: str):
yanit = requests.get(url, timeout=5)
yanit.raise_for_status()
return yanit.json()
wait_exponential_jitter da Türk usulü 'üstel + serpme' demek; ben düz wait_exponential yerine her zaman bunu tercih ediyorum, çünkü dağıtık sistemde tek başına düz üstel gecikmenin manası az.
Async tarafta nasıl?
tenacity asenkron fonksiyonlarla aynı dekoratörle çalışıyor, ayrı bir API öğrenmek yok:
import asyncio
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception_type
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential_jitter(initial=1, max=20),
retry=retry_if_exception_type((httpx.HTTPError, asyncio.TimeoutError)),
)
async def async_getir(url: str):
async with httpx.AsyncClient() as istemci:
yanit = await istemci.get(url, timeout=10)
yanit.raise_for_status()
return yanit.json()
asyncio.gather ile yan yana 100 istek başlattığınızda her görev kendi tekrar deneme döngüsüne sahip olur. Birinin patlaması diğerlerini etkilemez.
Devre kesici - retry'ın akıllı abisi
Servis tamamen düştüğünde tekrar deneme aslında yarayı büyütür. Devre kesici (circuit breaker) örüntüsü tam burada devreye giriyor. Üç durum var: CLOSED (normal akış), OPEN (servis ölü, isteği bile gönderme), HALF_OPEN (kontrollü yokla, dirildi mi diye).
Çok basit bir iskelet:
from datetime import datetime, timedelta
from threading import Lock
from enum import Enum
class DevreDurumu(Enum):
KAPALI = 'closed'
ACIK = 'open'
YARI_ACIK = 'half_open'
class DevreKesici:
def __init__(self, esik: int = 5, kurtarma_sn: int = 30):
self.esik = esik
self.kurtarma = timedelta(seconds=kurtarma_sn)
self.durum = DevreDurumu.KAPALI
self.hatalar = 0
self.son_hata_zamani = None
self._kilit = Lock()
def gecebilir_mi(self) -> bool:
with self._kilit:
if self.durum == DevreDurumu.ACIK:
if datetime.now() - self.son_hata_zamani > self.kurtarma:
self.durum = DevreDurumu.YARI_ACIK
return True
return False
return True
Devre kesiciyi tenacity ile birleştirdiğinizde retry sadece servis sağlıklıyken anlam kazanır. Servis ölmüşse hızlıca CircuitOpenError fırlatırsınız, müşteriye 30 saniye 502 yedirmek yerine 50 milisaniyede temiz bir hata dönersiniz.
Sık karşılaşılan hatalar
- Jitter koymamak: Düz üstel backoff'la 100 istemci aynı anda tekrar denerse, kurtulma anında servisi yeniden öldürürsünüz. Daima
wait_exponential_jitterya dawait_random_exponentialkullanın. - Idempotent olmayan isteği tekrar denemek:
POST /odemeisteği iki kez giderse müşteri iki kez ücretlendirilir. Idempotency key (Idempotency-Keyheader'ı) yoksaPOST'u körü körüne tekrar denemeyin. - 4xx hatalarını tekrar denemek:
400 Bad Requestsiz isteği değiştirmeden geçmez. Boşa retry yapmak hem kendi loglarınızı kirletir hem de upstream'i meşgul eder. stopkoymamak: Sonsuz döngüye girip thread'i tüketmek en sevdiğim production hatalarından. En azstop_after_attemptya dastop_after_delaykoyun.- Tekrar denemeleri loglamamak: Retry sessizce çalışıyorsa bağımlılıklarınızdaki yavaşlamayı asla göremezsiniz.
before_sleepcallback'iyle minimum bir uyarı seviyesi log şart.
Doğrulama
Yerelde test ederken responses veya httpretty ile yapay 503 dönen bir mock kurun, sonra dekoratörlü fonksiyonu çağırın. Beklenen davranış: süre damgalarına bakınca yaklaşık 1s, 2s, 4s aralıklarla denemelerin geldiğini, jitter varsa bunun ±%25 oynaklıkla geldiğini göreceksiniz. Üstel formül doğru çalışıyorsa süreler katlanarak artar; aksi halde bekleme stratejinizi yanlış seçmişsinizdir.
Kapanış
Bu yazıda üstel geri çekilme, jitter, doğru exception sınıflandırması, async retry ve devre kesici örüntülerine baktık. Bence retry mantığı yazılımın en yanlış 'kolay' işlerinden biri; üç satır gibi görünüyor ama hangi hatada, ne kadar bekleyerek, kaç kez tekrar deneyeceğinize verdiğiniz cevap sistemin dayanıklılığını belirliyor. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.
