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_jitter ya da wait_random_exponential kullanın.
  • Idempotent olmayan isteği tekrar denemek: POST /odeme isteği iki kez giderse müşteri iki kez ücretlendirilir. Idempotency key (Idempotency-Key header'ı) yoksa POST'u körü körüne tekrar denemeyin.
  • 4xx hatalarını tekrar denemek: 400 Bad Request siz isteği değiştirmeden geçmez. Boşa retry yapmak hem kendi loglarınızı kirletir hem de upstream'i meşgul eder.
  • stop koymamak: Sonsuz döngüye girip thread'i tüketmek en sevdiğim production hatalarından. En az stop_after_attempt ya da stop_after_delay koyun.
  • Tekrar denemeleri loglamamak: Retry sessizce çalışıyorsa bağımlılıklarınızdaki yavaşlamayı asla göremezsiniz. before_sleep callback'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.