Yeniden deneme (retry) mantığı: ne zaman yardım eder, ne zaman zarar verir
Yeniden denemeyi işe yarar kılan üç şart: hata sınıflandırması, idempotency anahtarı ve son tarihli backoff. Bir de retry'ın kesintiyi büyüttüğü metastable durum ve katmanlı retry tuzağı.
Yeniden deneme, kod tabanına en kolay giren dayanıklılık mekanizması. Bir try/catch ve bir döngü yeterli. Bu kolaylık aynı zamanda sorun: retry çoğu zaman düşünülerek değil, refleksle ekleniyor. Doğru yerde konursa geçici bir arızayı görünmez kılıyor; yanlış yerde konursa zaten sıkışmış bir sistemi tamamen kilitliyor.
Ayrım tek bir soruda toplanıyor: bu hata tekrar denenince geçme ihtimali olan bir hata mı?
Önce hatayı sınıflandırın
Retry yalnızca geçici (transient) hatalar için anlamlı. Kalıcı bir hatayı üç kez denemek, aynı yanlış cevabı üç kez almaktan başka bir işe yaramaz; üstelik istemcinin gecikmesini üçe katlar.
const GECICI_DURUMLAR = new Set([408, 425, 429, 500, 502, 503, 504]);
const GECICI_KODLAR = new Set(['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN']);
function geciciMi(hata, yanit) {
if (hata) return GECICI_KODLAR.has(hata.code);
return GECICI_DURUMLAR.has(yanit.status);
}
Bu listeye girmeyen her şey doğrudan yukarı fırlatılmalı. 400, 401, 403, 404, 409, 422 gibi yanıtlar isteğin kendisiyle ilgili; istek değişmediği sürece cevap da değişmez.
429 listeye dahil, ama özel bir durum: sunucu size ne zaman döneceğinizi söylüyorsa kendi backoff hesabınızı bir kenara bırakın ve Retry-After başlığına uyun.
İkinci soru: işlem idempotent mi
GET, PUT ve DELETE genelde güvenli. Sorun POST tarafında çıkıyor. İstek sunucuya ulaştı, işlendi, ama yanıt dönerken bağlantı koptuysa istemci hatayı görür — oysa işlem gerçekleşmiştir. Aynı isteği tekrarlamak, ikinci bir sipariş ya da ikinci bir tahsilat üretir.
Bu yüzden yeniden denenecek her yazma işleminin sunucu tarafında bir tekilleştirme anahtarı olmalı:
const anahtar = crypto.randomUUID(); // deneme başına DEĞİL, işlem başına üretilir
await denemeliCagri(({ timeoutMs }) =>
fetch('/api/odemeler', {
method: 'POST',
headers: { 'Idempotency-Key': anahtar, 'Content-Type': 'application/json' },
body: JSON.stringify(odeme),
signal: AbortSignal.timeout(timeoutMs),
})
);
Kritik nokta anahtarın döngünün dışında üretilmesi. Her denemede yeni anahtar üretirseniz idempotency mekanizması hiçbir şey yapmaz, yalnızca kod okuyanları yanıltır.
Sunucu tarafında anahtar, sonucu saklayan bir kayıtla eşleşmeli: aynı anahtar tekrar geldiğinde işlem yeniden yürütülmez, ilk yanıt döndürülür.
Backoff: sabit bekleme yerine jitter
Sabit aralıkla yeniden deneme, arızadan etkilenen tüm istemcileri aynı anda uyandırır. Servis toparlanmaya çalışırken senkronize bir dalga daha yer. Üstel artış bunu yumuşatır, rastgelelik ise dalgayı dağıtır.
async function denemeliCagri(islem, {
maxDeneme = 3,
tabanMs = 200,
tavanMs = 5000,
sonTarih = Date.now() + 10_000,
} = {}) {
let sonHata;
for (let deneme = 0; deneme < maxDeneme; deneme++) {
const kalan = sonTarih - Date.now();
if (kalan <= 0) break;
try {
return await islem({ timeoutMs: Math.min(kalan, 2000) });
} catch (hata) {
if (!hata.gecici) throw hata;
sonHata = hata;
const tavan = Math.min(tavanMs, tabanMs * 2 ** deneme);
const bekle = Math.random() * tavan; // full jitter
if (Date.now() + bekle >= sonTarih) break;
await new Promise((r) => setTimeout(r, bekle));
}
}
throw sonHata ?? new Error('süre doldu');
}
Buradaki sonTarih çoğu uygulamada eksik olan parça. Deneme sayısını sınırlamak yetmez; toplam süreyi de sınırlamak gerekir. Aksi halde üç denemelik bir politika, her denemesi otuz saniye süren bir çağrıda doksan saniyelik bir istemci bekleyişine dönüşür.
Her denemenin kendi zaman aşımı da şart. Zaman aşımı olmayan bir çağrıda retry hiç devreye girmez; istemci ilk denemede asılı kalır.
Retry'ın zarar verdiği yer: doygun bir bağımlılık
Asıl tehlike burada. Bir servis yükün altında yavaşladığında istemciler zaman aşımı almaya başlar. Her zaman aşımı bir yeniden denemeyi tetikler. Yeniden denemeler servise gelen yükü artırır. Artan yük daha fazla zaman aşımı üretir.
Bu döngü kendi kendini besler. Yük kaynağı ortadan kalksa bile sistem eski haline dönmez, çünkü ayakta kalan tek şey retry trafiğidir. Literatürde metastable failure diye geçen bu durum, üretimde en sık görülen tam kesinti biçimlerinden biri.
İki savunma var:
Yeniden deneme bütçesi. Retry'ları toplam trafiğin sabit bir oranıyla sınırlayın. Basit bir jeton kovası yeterli: her başarılı çağrı bir miktar jeton üretir, her yeniden deneme bir jeton harcar. Kova boşsa retry yapılmaz, hata doğrudan yukarı çıkar. Böylece retry oranı yük altında otomatik olarak sıfıra yaklaşır.
Devre kesici. Belirli bir pencerede hata oranı eşiği aşarsa çağrıları bir süre hiç denemeden reddedin. Yarı açık durumda tek bir yoklama isteğiyle servisin toparlanıp toparlanmadığına bakın. Devre kesici, retry'ın açamayacağı kapıyı zorlamayı bırakmanın yolu.
Katmanlı retry çarpar, toplamaz
Üretimde en sık gözden kaçan hata bu. İstemcide 3 deneme, API geçidinde 3 deneme, servisin veritabanı istemcisinde 3 deneme varsa tek bir kullanıcı isteği veritabanına 27 sorgu olarak inebilir.
Kural basit: yeniden deneme yığının tek bir katmanında yapılır. Genelde en dışta, isteğin bağlamını bilen katmanda. Alt katmanlar hatayı sınıflandırıp yukarı geçirir, kendileri denemez. Bir kütüphane sizin adınıza sessizce retry yapıyorsa (birçok HTTP ve veritabanı istemcisi varsayılan olarak yapar) bunu kapatın ya da kendi politikanızı ona göre kısın.
Kontrol listesi
Koda retry eklemeden önce şu beş maddeyi doğrulayın:
- Hata sınıflandırması var mı, yoksa her hata mı deneniyor?
- İşlem idempotent mi, değilse tekilleştirme anahtarı işlem başına mı üretiliyor?
- Her denemenin zaman aşımı ve tüm döngünün son tarihi tanımlı mı?
- Backoff üstel ve rastgeleleştirilmiş mi,
Retry-Afterdikkate alınıyor mu? - Yığında başka hangi katman retry yapıyor?
Son maddeyi cevaplayamıyorsanız, eklediğiniz retry bir dayanıklılık önlemi değil, henüz fark edilmemiş bir yük çarpanı.
- retry
- dayanıklılık
- backoff
- idempotency