Bir reklam platformundan gelen lead'i CRM'e yazan bir entegrasyon, ilk hafta genellikle kusursuz çalışır. Sorunlar sonra başlar: CRM'in API'si bir gece bakım moduna girer, hız limiti dolar, bir alanın formatı değişir ya da ağda birkaç saniyelik bir kesinti olur. Basit bir "hata olursa üç kez dene" döngüsü bu durumların bazılarını kurtarır, bazılarını ise daha da kötüleştirir. Ben Koray Yalçın; lead yönetimi, CRM entegrasyonu ve satış otomasyonu üzerine çalışıyorum. Gayrimenkul ve B2B satış ekipleri için kurduğum veri akışlarında en çok zaman harcadığım konulardan biri, başarısız gönderimlerin nasıl yeniden deneneceği oldu.
Bu yazıda yeniden deneme (retry) mekanizmasını bir döngü olarak değil, bir tasarım kararı olarak ele alıyorum: hangi hatalar tekrar denenir, bekleme süresi nasıl hesaplanır, aynı anda yüzlerce işçinin aynı API'ye saldırması nasıl önlenir, vazgeçilen mesajlar nereye gider ve sonradan nasıl geri oynatılır. Sonda sık sorulan soruları cevapladım.
Neden "üç kez dene" yeterli değil
Saf bir yeniden deneme döngüsünün üç temel sorunu var.
Birincisi, her hatayı aynı kabul eder. Geçersiz bir e-posta adresi yüzünden reddedilen bir kayıt, kaç kez denenirse denensin reddedilecektir. Bu denemeler hem hız limitinizi tüketir hem de loglarda gerçek sorunları gizler.
İkincisi, sabit aralıklarla dener. Karşı taraf zaten zorlanıyorsa, her saniye gelen yeni denemeler toparlanmasını geciktirir. Bir CRM'in yaşadığı beş dakikalık yavaşlık, istemcilerin agresif yeniden denemeleri yüzünden yarım saatlik bir kesintiye dönüşebilir.
Üçüncüsü, sonunda sessizce vazgeçer. Üçüncü deneme de başarısız olduğunda kayıt genellikle bir log satırına dönüşür ve kaybolur. Satış ekibi için bu, hiç aranmayan bir lead demektir. Bir gayrimenkul projesinde sıcak bir alıcı adayının kaybolması, entegrasyonun bütün maliyetinden daha pahalıya gelebilir.
Bu üç sorunun karşılığı, yazının ana bölümlerini oluşturuyor: hata sınıflandırma, akıllı bekleme ve görünür bir vazgeçme noktası.
1. Önce hatayı sınıflandırın
Yeniden deneme kararı, hatanın türüne bağlıdır. Ben her entegrasyonda hataları üç sınıfa ayırıyorum.
Geçici hatalar tekrar denendiğinde başarılı olma ihtimali olan hatalardır: zaman aşımı, bağlantı kopması, 502, 503, 504 yanıtları. Bunlar yeniden denenir.
Hız limiti hataları ayrı bir sınıftır. 429 yanıtı "şu an değil" demektir, "asla" değil. Bunlar da yeniden denenir, ama bekleme süresini karşı taraf belirler.
Kalıcı hatalar ise isteğin kendisinden kaynaklanır: 400, 401, 403, 404, 422 gibi yanıtlar, şema doğrulama hataları, zorunlu alanın eksik olması. Bunlar yeniden denenmez; doğrudan inceleme kuyruğuna gider.
from dataclasses import dataclass from enum import Enum class ErrorClass(Enum): TRANSIENT = "transient" RATE_LIMITED = "rate_limited" PERMANENT = "permanent" @dataclass class Outcome: error_class: ErrorClass | None retry_after: float | None = None detail: str = "" def classify(status: int | None, exc: Exception | None, headers: dict) -> Outcome: if exc is not None: # Ağ hatası, zaman aşımı, DNS sorunu return Outcome(ErrorClass.TRANSIENT, detail=type(exc).__name__) if 200 <= status < 300: return Outcome(None) if status == 429: ra = headers.get("Retry-After") return Outcome(ErrorClass.RATE_LIMITED, retry_after=float(ra) if ra and ra.isdigit() else None) if status in (500, 502, 503, 504): return Outcome(ErrorClass.TRANSIENT, detail=f"http_{status}") return Outcome(ErrorClass.PERMANENT, detail=f"http_{status}") Burada dikkat ettiğim iki ayrıntı var. 500 yanıtını geçici sayıyorum, ama aynı kayıt için art arda gelen 500'leri izliyorum; bazı API'ler bozuk bir veriyi 500 ile reddeder ve bu durumda yeniden deneme hiçbir işe yaramaz. İkincisi, 401 yanıtını kalıcı sayıyorum ama tek bir istisnayla: erişim jetonunun süresi dolmuşsa, jetonu yenileyip bir kez daha deniyorum. Bu yenileme yeniden deneme sayacına dahil değil, ayrı bir adım.
2. Exponential backoff ve jitter
Geçici hatalarda bekleme süresini her denemede katlayarak artırıyorum: bir saniye, iki saniye, dört saniye, sekiz saniye ve bir üst sınıra kadar. Buna exponential backoff deniyor. Mantığı basit: karşı taraf kısa bir aksaklık yaşıyorsa hızlı toparlanırsınız, uzun bir kesinti yaşıyorsa onu boğmazsınız.
Ama tek başına backoff yetmez. Bir kampanya sırasında yüzlerce lead aynı saniyede gelmiş ve aynı kesintiye takılmışsa, hepsi aynı anda bir saniye, sonra aynı anda iki saniye bekler. Sonuç, karşı tarafa düzenli aralıklarla gelen dalgalardır. Bunu kırmak için bekleme süresine rastgelelik ekliyorum; buna jitter deniyor.
import random BASE = 1.0 # saniye CAP = 300.0 # en fazla 5 dakika MAX_ATTEMPTS = 8 def next_delay(attempt: int, outcome: Outcome) -> float | None: if outcome.error_class is ErrorClass.PERMANENT: return None if attempt >= MAX_ATTEMPTS: return None if outcome.error_class is ErrorClass.RATE_LIMITED and outcome.retry_after: # Karşı tarafın istediği süreye küçük bir pay ekle return outcome.retry_after + random.uniform(0, 1) # "Full jitter": 0 ile üstel sınır arasında rastgele bir süre ceiling = min(CAP, BASE * (2 ** attempt)) return random.uniform(0, ceiling) "Full jitter" olarak bilinen bu yaklaşımda bekleme süresi sıfır ile üstel sınır arasında rastgele seçilir. Ortalama bekleme biraz kısalır ama denemeler zamana yayılır ve karşı taraf dalga yerine düzgün bir akış görür. Deneme sayısı ve üst sınır ise işin doğasına göre değişir. Bir lead'in CRM'e yazılması dakikalar içinde olmalı; bu yüzden toplam bekleme penceresini kısa tutuyorum. Gece çalışan bir raporlama senkronizasyonunda ise saatlerce bekleyebilirim.
3. Yeniden denemeyi işçinin içinde değil, kuyrukta yapın
En sık gördüğüm hatalardan biri, yeniden denemeyi HTTP isteğini yapan fonksiyonun içinde sleep ile yapmak. Bu, işçiyi dakikalarca meşgul eder, sunucu yeniden başlatıldığında bekleyen denemeleri kaybeder ve kaç kaydın beklemede olduğunu görmenizi imkansız hale getirir.
Bunun yerine her gönderimi bir iş kaydı olarak tutuyorum. Başarısız olan iş, bir sonraki deneme zamanıyla birlikte kuyruğa geri yazılıyor.
CREATE TABLE delivery_job ( id BIGSERIAL PRIMARY KEY, target TEXT NOT NULL, -- örn. 'crm.lead.create' payload JSONB NOT NULL, dedupe_key TEXT NOT NULL UNIQUE, -- aynı lead iki kez kuyruğa girmesin status TEXT NOT NULL DEFAULT 'pending', -- pending | done | dead attempt INT NOT NULL DEFAULT 0, next_run_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX ON delivery_job (next_run_at) WHERE status = 'pending'; İşçi, zamanı gelmiş işleri kilitleyerek alıyor. PostgreSQL'de FOR UPDATE SKIP LOCKED birden çok işçinin aynı işi almasını engelliyor.
SELECT id, target, payload, attempt FROM delivery_job WHERE status = 'pending' AND next_run_at <= now() ORDER BY next_run_at LIMIT 50 FOR UPDATE SKIP LOCKED; İş başarısız olduğunda attempt artıyor, next_run_at hesaplanan bekleme süresi kadar ileri alınıyor ve last_error güncelleniyor. Böylece hangi kaydın neden beklediği her an sorgulanabiliyor.
4. Yeniden deneme, idempotency olmadan tehlikelidir
Zaman aşımı en sinsi hatadır: isteğiniz karşı tarafa ulaşmış, kayıt oluşmuş ama yanıt size gelmemiş olabilir. Bu durumda yeniden denemek, CRM'de aynı lead'in iki kez oluşması demektir. Satış ekibi aynı kişiyi iki kez arar ve müşteri deneyimi zedelenir.
Bu yüzden yeniden denenen her isteğin tekrar edildiğinde aynı sonucu üretmesi gerekir. Karşı taraf bir idempotency anahtarı başlığı destekliyorsa onu kullanıyorum ve anahtarı deneme sayısından bağımsız, işin kendisinden türetiyorum. Desteklemiyorsa, oluşturma işlemi yerine dış kimlik (external ID) üzerinden upsert yapıyorum ya da göndermeden önce kaydın var olup olmadığını kontrol ediyorum. Önemli olan, "bu isteği ikinci kez göndermek güvenli mi?" sorusunun cevabını her hedef için önceden bilmek.
5. Devre kesici: Karşı taraf düşmüşse durun
Backoff tek bir işin davranışını düzenler; ama CRM tamamen erişilemez durumdaysa, kuyruktaki her iş yine de kendi denemesini yapar. Bunun önüne geçmek için hedef bazında basit bir devre kesici (circuit breaker) kullanıyorum.
import time class CircuitBreaker: def __init__(self, threshold=20, cooldown=60): self.threshold = threshold self.cooldown = cooldown self.failures = 0 self.opened_at = None def allow(self) -> bool: if self.opened_at is None: return True # Bekleme süresi dolduysa tek bir deneme isteğine izin ver return time.monotonic() - self.opened_at >= self.cooldown def record(self, ok: bool): if ok: self.failures, self.opened_at = 0, None else: self.failures += 1 if self.failures >= self.threshold: self.opened_at = time.monotonic() Devre açıkken işçi o hedefe istek göndermiyor; işleri deneme sayısını artırmadan ileri bir zamana erteliyor. Bu ayrıntı önemli: kesinti sırasında harcanan denemeler, kesinti bittikten sonra kayıtların gereksiz yere vazgeçme kuyruğuna düşmesine yol açmamalı.
6. Dead-letter kuyruğu: Vazgeçmek görünür olmalı
Kalıcı hata alan ya da deneme hakkını dolduran iş silinmiyor; durumu dead olarak işaretleniyor. Bu, dead-letter kuyruğunun (DLQ) en sade hali. Benim için DLQ'nun üç şartı var.
Birincisi, her kaydın neden orada olduğu okunabilir olmalı. "HTTP 422" yeterli değil; karşı tarafın döndürdüğü hata mesajı, hangi alanın sorun çıkardığı ve son deneme zamanı kaydediliyor.
İkincisi, birisi DLQ'ya bakmalı. Yeni bir kayıt düştüğünde operasyon ekibine bir bildirim gidiyor. Lead akışlarında bu bildirimi saatlik özet yerine anlık yapıyorum, çünkü bekleyen her lead soğuyor.
Üçüncüsü, düzeltilen kayıt geri oynatılabilmeli. Sorun veri kaynaklıysa kayıt düzeltiliyor, sorun hedef tarafta bir yapılandırma hatasıysa (silinmiş bir özel alan, değişmiş bir seçim listesi) önce o düzeltiliyor; ardından iş pending durumuna, deneme sayısı sıfırlanarak geri alınıyor. İdempotency sayesinde bu geri oynatma güvenli.
7. Gözlemlenebilirlik: Hangi sayılara bakıyorum
Yeniden deneme mekanizması iyi çalıştığında görünmez olur; bu yüzden ölçmek şart. Her hedef için şu göstergeleri izliyorum: ilk denemede başarı oranı, kuyrukta bekleyen iş sayısı, en eski bekleyen işin yaşı, DLQ'ya günlük düşen kayıt sayısı ve devre kesicinin açık kaldığı süre. En değerlisi "en eski bekleyen işin yaşı"; bir lead akışında bu değer birkaç dakikayı geçtiğinde, satış ekibi henüz fark etmeden müdahale edebiliyorum.
Sık sorulan sorular
Kaç deneme yapmalıyım?
Sabit bir doğru sayı yok. Ben deneme sayısından önce toplam bekleme penceresini belirliyorum: bir lead için birkaç dakika ile bir saat arası, raporlama senkronizasyonu için birkaç saat. Deneme sayısı ve üst sınır bu pencereye göre hesaplanıyor.
Make, Zapier veya n8n gibi araçlarda bu yapı kurulabilir mi?
Bu araçların çoğunda yerleşik yeniden deneme ve hata yolu seçenekleri var. Ancak hata sınıflandırma, jitter ve geri oynatılabilir bir DLQ genellikle elle kurulmalı. Kritik akışlarda başarısız işleri ayrı bir tabloya ya da kuyruğa yazan bir hata dalı eklemeyi öneriyorum.
Webhook gönderen tarafta mıyım, alan tarafta mı; fark eder mi?
Fark eder. Gönderen taraftaysanız bu yazıdaki mekanizma doğrudan sizin sorumluluğunuzda. Alan taraftaysanız, karşı tarafın yeniden denemelerine hazırlıklı olmanız gerekir: aynı olayı birden fazla kez alabileceğinizi varsayın, olay kimliğiyle tekilleştirin ve isteği hızlıca kabul edip işlemi arka planda yapın.
Retry-After başlığı tarih formatında gelirse ne olur?
Bu başlık saniye ya da HTTP tarih formatında gelebilir. Yukarıdaki örnek yalnızca saniyeyi okuyor; gerçek projelerde tarih formatını da çözümleyip şimdiki zamanla farkını alıyorum. Başlık hiç yoksa normal backoff'a dönüyorum.
DLQ'daki kayıtları otomatik olarak yeniden denemek mantıklı mı?
Genellikle hayır. Kalıcı hatalar insan müdahalesi gerektirir; otomatik geri oynatma aynı hatayı tekrar üretir. İstisna, devre kesici açıkken vazgeçmiş işlerdir; bunları kesinti bittiğinde toplu olarak geri almak mantıklı olabilir.
Kapanış
İyi bir yeniden deneme mekanizması yedi parçadan oluşuyor: hatanın sınıflandırılması, üstel bekleme, jitter, kuyruk tabanlı zamanlama, idempotent istekler, hedef bazlı devre kesici ve görünür bir dead-letter kuyruğu. Bunların her biri tek başına basit; birlikte ise "lead kayboldu" cümlesini neredeyse tamamen ortadan kaldırıyor.
Bu yazıdaki kodlar kendi projelerimde kullandığım yapıların sadeleştirilmiş halleri. Farklı bir dil, kuyruk sistemi ya da CRM kullanıyorsanız mantık aynı kalır.
Koray Yalçın — lead yönetimi, CRM entegrasyonu ve satış otomasyonu üzerine çalışıyor.