API entegrasyon desenleri: yeniden deneme, geri çekilme, hız limiti ve idempotency
API entegrasyonlarında yeniden deneme, üstel geri çekilme ve jitter, token bucket ile hız sınırlama ve Idempotency-Key ile çift kayıt önleme; Python örnekli.
Üçüncü taraf bir API'ye bağlanan her otomasyon, ilk haftasında değilse bile ilk ayında aynı dört duvara çarpar: bir gece boyunca 502 döndüren uç nokta, toplu sorgu sırasında gelen 429 hız limiti, cevabı hiç gelmeyen istekler ve en kötüsü, yeniden denendiği için aynı siparişin iki kez oluşması. API entegrasyon desenleri dediğim şey, bu dört durumun her birine önceden verilmiş bir cevaptır. Danışmanlık işlerimde e-ticaret paneli, muhasebe yazılımı ya da harita servisi fark etmiyor; bir entegrasyonun ne kadar sağlam olduğu mutlu yolda değil, bu duvarlara çarptığı anda belli oluyor.
Bu yazıda dört deseni sırayla ele alıyorum: hangi hatanın tekrar denenip hangisinin denenmeyeceği, üstel geri çekilme ve jitter, token bucket ile istemci tarafında hız sınırlama ve Idempotency-Key ile çift kayıt önleme. Örnekler Python'da ve requests kütüphanesi üzerine oturuyor; httpx kullanıyorsanız istisna sınıflarının adını değiştirmek dışında aynı yapı geçerli. Kod parçaları kısa tutuldu ama gerçek projelerde kullandığım iskeletin sadeleştirilmiş hâli; olduğu gibi kopyalayıp çalıştırabilirsiniz.
Başlamadan bir uyarı: bu desenlerin hiçbiri tek başına yeterli değil. Yeniden deneme olmadan hız limiti anlamsız, idempotency olmadan yeniden deneme tehlikeli. Dördü birlikte bir bütün oluşturuyor; yazının sonunda bunları hangi sırayla üst üste koyduğumu da göstereceğim.
Hangi hata tekrar denenir, hangisi denenmez
Yeniden deneme mantığındaki en yaygın hata, her başarısız isteği tekrar denemektir. Oysa hataların büyük kısmı ikinci denemede de aynı sonucu verir ve tekrar denemek yalnızca karşı tarafın yükünü artırır. Ayrımı şöyle yapıyorum:
- Tekrar denenir: bağlantı hataları, zaman aşımı,
408,429,500,502,503ve504. Bunlar geçici olma ihtimali yüksek durumlardır; sunucu meşguldür, ağ bir an kopmuştur ya da yük dengeleyici arkada sağlıklı bir örnek bulamamıştır. - Tekrar denenmez:
400,401,403,404,422ve genel olarak4xxailesinin geri kalanı. İstek bozuktur, yetki yoktur ya da kaynak yoktur; aynı isteği tekrar göndermek aynı cevabı üretir. Bunlar loglanır ve yukarı fırlatılır. - Dikkatle denenir: zaman aşımına uğrayan
POSTistekleri. İstek karşıya ulaşmış ve işlenmiş olabilir; cevabı siz görmediniz diye işlem olmamış sayılmaz. Bu durumun çözümü idempotency bölümünde.
Bir de Retry-After başlığı var. 429 ve 503 cevaplarında sunucu çoğu zaman ne kadar bekleneceğini kendisi söyler; bu değeri okuyup kendi hesabınızın önüne koymak hem karşı tarafa saygı hem de gereksiz denemelerden kurtuluş demek. Başlık saniye cinsinden bir sayı ya da HTTP tarihi olabilir; aşağıdaki örnekte yalnızca sayı biçimini ele alıyorum, tarih biçimini üretim kodunda email.utils.parsedate_to_datetime ile çözebilirsiniz.
Üstel geri çekilme ve jitter: dekoratör örneği
Bekleme süresini sabit tutmak, mesela her denemede iki saniye, iki sorun çıkarır. Birincisi, sunucu gerçekten zordaysa iki saniye sonra da zordadır. İkincisi, aynı anda hata alan yüz istemci aynı anda tekrar dener ve sürü etkisi denilen dalgayı üretir. Üstel geri çekilme birincisini, jitter ikincisini çözer. Aşağıdaki dekoratör bekleme süresini her denemede ikiye katlar, bir tavanla sınırlar ve sıfırla o tavan arasında rastgele bir değer seçer. Bu yönteme "full jitter" deniyor; AWS Architecture Blog'daki "Exponential Backoff And Jitter" yazısı farklı jitter stratejilerini simülasyonla karşılaştırıyor ve bu yazıda gördüğüm sonuçlar beni bu seçime götürdü.
import functools
import random
import time
import requests
RETRY_STATUS = {408, 429, 500, 502, 503, 504}
RETRY_EXC = (requests.ConnectionError, requests.Timeout)
def retry_after_seconds(resp):
"""Retry-After başlığı saniye ise float döner, yoksa None."""
value = resp.headers.get("Retry-After")
if value is None:
return None
try:
return float(value)
except ValueError:
return None # HTTP-date biçimi bu örneğin dışında
def with_retry(max_attempts=5, base=0.5, cap=30.0):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, max_attempts + 1):
wait = None
try:
resp = fn(*args, **kwargs)
except RETRY_EXC as exc:
last_exc = exc
else:
if resp.status_code not in RETRY_STATUS:
return resp # başarı ya da tekrar denenmeyecek hata
last_exc = requests.HTTPError(
f"{resp.status_code} {resp.reason}", response=resp
)
wait = retry_after_seconds(resp)
if attempt == max_attempts:
raise last_exc
if wait is None:
# üstel geri çekilme + full jitter
wait = random.uniform(0, min(cap, base * 2 ** (attempt - 1)))
time.sleep(wait)
return wrapper
return decorator
session = requests.Session()
@with_retry(max_attempts=5)
def get_orders(session, page):
return session.get(
"https://api.example.com/orders",
params={"page": page},
timeout=(3.05, 10), # bağlantı, okuma
)
Birkaç noktanın altını çizeyim:
timeout=(3.05, 10)bağlantı ve okuma zaman aşımını ayrı ayrı verir.requestsvarsayılan olarak süresiz bekler; zaman aşımı vermeyen bir entegrasyon, karşı taraf yanıt vermeyi kestiğinde sessizce asılı kalır ve bunu ancak sabah fark edersiniz.- Son denemede beklemeden istisna fırlatılıyor; çağıran taraf ne olduğunu görüyor. "Beş kez denedim, olmadı" bilgisi loglara düşmeli, yutulmamalı.
- Dekoratör yalnızca cevabı döndüren fonksiyonu sarıyor; JSON ayrıştırma ve iş mantığı dışarıda kalıyor. Böylece bozuk bir gövdeden kaynaklanan ayrıştırma hatası yanlışlıkla tekrar denenmiyor.
Token bucket ile istemci tarafı hız sınırlama
Yeniden deneme, hız limitine çarptıktan sonra devreye giren bir tampondur. Asıl hedef, hiç çarpmamak. Sağlayıcının belgelediği limit "saniyede 10 istek" ise bunu istemci tarafında kendiniz uygulamalısınız; aksi hâlde toplu bir sorguda ilk saniyede yüz istek gönderir, ardından 429 cevaplarını bekleyerek vakit kaybedersiniz. Google Maps ile potansiyel müşteri keşfi gibi binlerce sorgunun peş peşe gittiği işlerde bu fark, yarım saatte biten iş ile gece boyu sürüp yarısı hata alan iş arasındaki farktır.
Token bucket, yani jeton kovası, bu iş için en basit ve en esnek model: kova saniyede belirli sayıda jetonla dolar, her istek bir jeton harcar, kova boşsa istek dolana kadar bekler. Kapasite kısa süreli patlamalara izin verir; dolum oranı uzun vadeli ortalamayı sabitler.
import threading
import time
class TokenBucket:
"""Saniyede `rate` jeton dolduran, en fazla `capacity` biriktiren kova."""
def __init__(self, rate, capacity):
self.rate = float(rate)
self.capacity = float(capacity)
self.tokens = float(capacity)
self.updated = time.monotonic()
self.lock = threading.Lock()
def _refill(self):
now = time.monotonic()
gecen = now - self.updated
self.tokens = min(self.capacity, self.tokens + gecen * self.rate)
self.updated = now
def acquire(self, tokens=1):
"""Yeterli jeton yoksa bekler; isteği asla düşürmez."""
while True:
with self.lock:
self._refill()
if self.tokens >= tokens:
self.tokens -= tokens
return
eksik = tokens - self.tokens
time.sleep(eksik / self.rate)
# saniyede 10 istek, 20 isteklik patlama payı
bucket = TokenBucket(rate=10, capacity=20)
for page in range(1, 200):
bucket.acquire()
resp = get_orders(session, page)
Uygulamada dikkat ettiğim ayrıntılar:
time.monotonic()kullanıyorum; sistem saati NTP ile geri alındığındatime.time()negatif fark üretir ve kova bozulur.- Kilit, aynı kovayı birden fazla iş parçacığının paylaşabilmesi için var. Süreçler arası paylaşım gerekiyorsa kova merkezi bir yerde, örneğin Redis'te tutulmalı; yerel bir nesne yalnızca kendi sürecini görür.
- Dolum oranını belgelenen limitin biraz altına koyuyorum; sağlayıcının sayacıyla benimki hiçbir zaman tam aynı saniyeyi görmüyor.
Idempotency-Key ile çift kayıt önleme
Buraya kadar anlattıklarım okuma isteklerinde tehlikesiz. Sorun yazma isteklerinde başlıyor: ödeme oluşturan bir POST zaman aşımına uğradı, dekoratör tekrar denedi ve müşteriden iki kez çekim yapıldı. İlk istek aslında işlenmişti, yalnızca cevabı size ulaşmamıştı. Stripe ve Adyen gibi ödeme servislerinin desteklediği Idempotency-Key başlığı tam bunun için var: aynı anahtarla gelen ikinci istek yeni bir işlem yaratmaz, ilkinin cevabını geri döndürür. Başlığın standartlaşması için IETF'te bir taslak da var; ama sağlayıcı sağlayıcı davranış farkı olduğundan belgeyi okumadan güvenmeyin.
import uuid
@with_retry(max_attempts=4)
def create_payment(session, payload, idempotency_key):
return session.post(
"https://api.example.com/payments",
json=payload,
headers={"Idempotency-Key": idempotency_key},
timeout=(3.05, 15),
)
# Anahtar BİR KEZ, yeniden deneme döngüsünün dışında üretilir
# ve isteği temsil eden kayıtla birlikte kalıcı depoya yazılır.
key = str(uuid.uuid4())
resp = create_payment(session, {"amount": 1250, "currency": "TRY"}, key)
Bu deseni uygularken üç kural:
- Anahtar yeniden deneme döngüsünün dışında üretilir. Fonksiyonun içinde
uuid4()çağırırsanız her deneme yeni bir anahtar alır ve koruma sıfırlanır; kod incelemelerinde en sık gördüğüm hata bu. - Anahtar, isteği gönderen kayıtla birlikte kalıcı depoya yazılır. Süreç çöküp yeniden başladığında aynı anahtarla devam edebilmelisiniz; SQLite ile küçük ama sağlam veri boru hatları yazısında anlattığım iş tablosu bu anahtarı tutmak için fazlasıyla yeterli.
- Sağlayıcı bu başlığı desteklemiyorsa kendi tarafınızda doğal bir anahtar kullanın: sipariş numarası gibi tekil bir alanı önce
GETile sorgulayın, yoksa yaratın. Yarış durumuna tamamen kapalı değil ama çift kaydı büyük ölçüde keser.
Dört deseni bir araya getirmek
Gerçek bir entegrasyonda katmanların sırası şöyle: önce kova (bucket.acquire()), sonra dekoratörlü çağrı, çağrının içinde zaman aşımı ve idempotency anahtarı. Kova isteklerin dışarı çıkış hızını sabitler; dekoratör geçici hataları emer; anahtar, dekoratörün tekrar denemesini güvenli kılar. Bunlardan birini eksik bıraktığınızda diğer ikisi ya işe yaramaz ya da zarar verir.
Bir de gözlemlenebilirlik boyutu var. Her yeniden denemeyi, her 429'u ve her bekleme süresini yapılandırılmış log olarak yazıyorum; bir hafta sonra "sağlayıcı mı yavaşladı, biz mi limiti aştık" sorusunun cevabı bu loglarda. Yeniden deneme sayısını metrik olarak izlemek, sağlayıcı tarafındaki bir bozulmayı müşterinin şikâyetinden önce görmenin en ucuz yolu.
Mevcut bir entegrasyonunuz varsa üç soruyla başlayın: zaman aşımı tanımlı mı, hangi hata kodları tekrar deneniyor, yazma istekleri tekrar denendiğinde ne oluyor. Üçünün cevabı belirsizse sistem hâlâ mutlu yolda yaşıyor demektir ve ilk duvar yakındır. Bu tür entegrasyonları kurmak ya da eldekini sağlamlaştırmak için danışmanlık yaklaşımımı anlattığım ana sayfadan /#contact bölümüne uğrayıp bana yazabilirsiniz; ilk görüşmede genellikle hangi duvara ne zaman çarpacağınızı birlikte tahmin edebiliyoruz.