Idempotency: API'lerde Güvenli Retry için Başlangıç Rehberi
API, ödeme ve mesaj tüketicisi geliştirenler için idempotency'ye pratik giriş: HTTP metot semantiği, idempotency key'leri, upsert ve yaygın tuzaklar.
Idempotency, aynı işlemin defalarca çalıştırılmasının bir kez çalıştırılmasıyla aynı sonucu üretmesi özelliğidir. Para, yan etki ya da dağıtık iş tutan her API için kritiktir. Altta yatan taşıma katmanları (HTTP, mobil şebekeler, mesaj aracıları) başarısızlık durumunda niyetten bağımsız olarak retry yapar ve idempotent olmayan bir endpoint, her retry’ı bir duplicate yazmaya çevirir: çift ücretlendirme, tekrarlanan siparişler, iki kez işlenen işler.
Bu işin üç katmanı var. HTTP metot semantiği, tekrarlanan bir PUT ya da DELETE’in hiçbir şeyi değiştirmediğini zaten garanti eder; yani en ucuz çözüm çoğu zaman doğru metodu seçmektir. POST olmak zorunda olan işlemlerde varsayılan bir idempotency key’dir: client’ın ürettiği, sunucunun yanıtla birlikte sakladığı bir ID. İkisinin de altında veritabanı durur; bir unique constraint ya da conditional write, üstteki katmanlar sızdırdığında hattı tutar.
Idempotency Ne Demek#
Matematiksel tanımı f(f(x)) = f(x): çıktıyı tekrar girdi olarak vermek hiçbir şeyi değiştirmez. Pratikte, aynı endpoint’i aynı girdiyle iki kez çağırırsanız sistem, tek çağırmışsınız gibi aynı duruma varır.
Buradaki ayrım üzerinde durmaya değer. Idempotency, duplicate isteklerin sisteme ulaşmasını engellemek değil; duplicate isteklerin duplicate etki oluşturmamasını sağlamaktır. Ağ, siz istesiniz ya da istemeyin retry yapacak; tekrarı soğuracak parça da handler’dır.
Üç İlgili Terim#
- Safe: hiç yan etki yok (GET isteği)
- Idempotent: yan etki oluşur ama tekrarlar yeni bir şey eklemez (PUT, DELETE)
- Pure: deterministik, yan etkisiz, dış state’e bağımsız (matematik fonksiyonları)
Her safe işlem idempotent’tir. Her idempotent işlem safe değildir.
Çift Ücretlendirme Problemi#
Bir ödeme akışını düşünün:
İlk ödeme sunucuda başarıyla gerçekleşti ama yanıt client’a hiç ulaşmadı. Client retry yaptı ve müşteri iki kez ücretlendirildi. Sunucunun, ikinci isteğin birincinin retry’ı olduğunu anlamasının bir yolu yoktu.
Çözüm bir idempotency key: client’ın bir kez üretip aynı mantıksal işlemin tüm retry’larında yeniden kullandığı benzersiz bir ID.
HTTP Metotları ve Idempotency#
RFC 9110, standart HTTP metotları için sözleşmeyi tanımlar:
| Metot | Safe | Idempotent | Tipik Kullanım |
|---|---|---|---|
| GET | Evet | Evet | Veri okuma |
| PUT | Hayır | Evet | Tam değiştirme |
| DELETE | Hayır | Evet | Kaynak silme |
| POST | Hayır | Hayır | Yeni kaynak oluşturma |
| PATCH | Hayır | Duruma göre | Kısmi güncelleme |
Aynı gövdeyle üç kez çağrılan bir PUT /users/123, kullanıcı kaydını aynı durumda bırakır. İki kez çağrılan DELETE /orders/456 yine siparişin silinmiş olmasıyla sonuçlanır. Ama POST /orders her seferinde yeni bir sipariş oluşturur, dolayısıyla varsayılan olarak idempotent değildir.
Bu önemli, çünkü tarayıcılar, proxy’ler ve HTTP client’ları GET, PUT ve DELETE isteklerini kendiliğinden retry edebilir; dayanakları, spesifikasyonun tekrarın hiçbir şeyi değiştirmeyeceği yönündeki vaadidir. Spesifikasyon bu retry’a izin verir; hiçbir client’ı buna zorlamaz, dolayısıyla otomatik retry’ı üzerine tasarım kuracağınız bir garanti olarak değil, bir ihtimal olarak görün. PUT uygun olan yerde POST kullanırsanız bu izni de kaybedersiniz.
Idempotency Key Deseni#
POST işlemlerini idempotent yapmanın standart yolu idempotency key. Bu deseni Stripe yaygınlaştırdı.
Nasıl Çalışır#
- Client, isteği göndermeden önce bir UUID üretir.
- Client onu bir
Idempotency-Keyheader’ında gönderir. - Sunucu, hızlı bir store’da (Redis, DynamoDB) bu key’i arar.
- Key yeniyse sunucu isteği işler ve tam yanıtı TTL ile saklar.
- Key zaten varsa sunucu, iş mantığını yeniden çalıştırmadan saklanan yanıtı döndürür.
Minimal Bir Express Uygulaması#
import { Request, Response, NextFunction } from "express";
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
interface StoredResponse {
status: number;
body: unknown;
}
async function idempotency(req: Request, res: Response, next: NextFunction) {
const key = req.header("Idempotency-Key");
if (!key) return next();
// Tenant'lar arası çakışmayı önlemek için kullanıcı ve endpoint ile scope
const storeKey = `idem:${req.user?.id}:${req.path}:${key}`;
const cached = await redis.get(storeKey);
if (cached) {
const stored: StoredResponse = JSON.parse(cached);
return res.status(stored.status).json(stored.body);
}
// Eşzamanlı retry'ları yönetmek için key'i 30 saniye kilitle
const locked = await redis.set(storeKey + ":lock", "1", {
NX: true,
EX: 30,
});
if (!locked) {
return res.status(409).json({ error: "Request in progress" });
}
// Yanıtı yakala ki saklayabilelim
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
const toStore: StoredResponse = { status: res.statusCode, body };
// 24 saatlik TTL, Stripe'ın varsayılanı ile aynı
redis.set(storeKey, JSON.stringify(toStore), { EX: 86400 });
return originalJson(body);
};
next();
}
Bu middleware temelleri ele alıyor: kullanıcıya göre scope, eşzamanlılık için kilit, tam yanıtı saklama ve retry’da tekrar oynatma. Production sistemleri genelde daha fazlasını ekler: işleniyor/tamamlandı ayrımı, request body’nin saklanan key ile eşleştiğini doğrulama ve daha zengin hata yönetimi.
Client Tarafı#
async function chargeCustomer(amount: number) {
// Web Crypto global: tarayıcılarda ve Node v19'dan itibaren mevcut
const idempotencyKey = crypto.randomUUID();
// Aynı key'i bu mantıksal işlemin tüm retry'larında yeniden kullan
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch("/api/charge", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ amount }),
});
if (res.ok) return res.json();
} catch (err) {
// Ağ hatası, aynı key ile retry
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
throw new Error("Charge failed after retries");
}
Önemli nokta: client key’i bir kez, ilk denemeden önce üretir ve her retry’da aynısını kullanır. Her denemede yeni bir UUID üretmek, tüm deseni geçersiz kılar.
Veritabanı Seviyesinde Idempotency#
Bazen bu işi veritabanı sizin için yapabilir. Unique constraint’ler ve conditional write’lar, neredeyse hiç uygulama kodu olmadan idempotency sağlar.
PostgreSQL Upsert#
INSERT INTO orders (id, user_id, amount, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (id) DO NOTHING
RETURNING *;
Çağıran taraf sipariş ID’sini sağlıyorsa bunu iki kez çalıştırmak tabloda aynı satırı bırakır. İkinci çağrı DO NOTHING yüzünden hiçbir şey döndürmez ve handler’ınız bunu başarılı bir no-op olarak değerlendirebilir.
DynamoDB Conditional Write#
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
const client = new DynamoDBClient({});
async function createOrder(orderId: string, data: Record<string, string>) {
try {
await client.send(
new PutItemCommand({
TableName: "Orders",
Item: {
id: { S: orderId },
data: { S: JSON.stringify(data) },
},
ConditionExpression: "attribute_not_exists(id)",
}),
);
return { created: true };
} catch (err: any) {
if (err.name === "ConditionalCheckFailedException") {
// Zaten var, başarılı say
return { created: false };
}
throw err;
}
}
attribute_not_exists koşulu write’ın sadece ilk seferde başarılı olmasını sağlar. Retry’lar catch bloğuna düşer ve no-op olur.
Mesaj Kuyruklarında Idempotency#
Kuyrukların çoğu at-least-once teslimat garantisi verir. SQS, Kafka, RabbitMQ ve Pub/Sub; hepsi aynı mesajı birden fazla kez teslim edebilir. Tüketiciler ack atmadan önce çöker, visibility timeout’lar dolar, producer’lar retry yapar. Tüketiciniz replay’leri tolere etmek zorunda.
Mesaj ID’si ile anahtarlanan, retention penceresinden biraz uzun TTL’li basit bir dedup tablosu genelde yeterli. Daha güçlü garantiler için yan etkiyi ve “işlendi” işaretini aynı veritabanı transaction’ında birleştirin (outbox deseni).
Exactly-Once Efsanesi#
Dağıtık sistemlerde exactly-once teslimat imkânsızdır; klasik kanıtı İki General Problemi’dir. Başarabileceğiniz şey exactly-once işlemedir: at-least-once teslimatı idempotent handler’larla birleştirirsiniz.
at-least-once teslimat + idempotent işleme = etkin olarak exactly-once
Kafka’nın “exactly-once semantics”i Kafka ekosistemi içinde çalışır; ama bir e-posta gönderdiğiniz veya dış bir API çağırdığınız anda yine idempotent handler’lara ihtiyacınız var.
Yaygın Tuzaklar#
Pratikte idempotency’yi bozan hatalar bunlar.
1. Handler İçinde Wall-Clock Time Kullanmak#
Handler’ınız her çağrıda created_at = NOW() hesaplıyorsa, saklanan satırlar ilk çağrı ile retry arasında farklılaşır. İşlem artık katı anlamda idempotent değildir.
Çözüm: timestamp’leri bir kez yakalayın ve idempotency key ile birlikte saklayın ya da parametre olarak geçirin.
2. Idempotency Sınırı Dışındaki Yan Etkiler#
Yaygın bir desen: önce veritabanına commit, sonra e-posta gönder. E-posta gönderimi başarısız olup client retry yaparsa veritabanı write’ı iki kez gerçekleşir (korunmuyorsa) ya da e-posta iki kez gider (korunuyorsa).
Çözüm: e-posta niyetini aynı transaction içinde veritabanına yazın ve ayrı bir worker’ın idempotent şekilde teslim etmesini sağlayın.
3. Idempotency Key Olarak Timestamp#
user-123-1696000000 gibi key’ler yük altında çakışır ve saat kayması altında bozulur. UUID v4 veya v7 kullanın. Yalnızca wall-clock time’a asla güvenmeyin.
4. Yanıt Gövdesini Saklamayı Unutmak#
Bir key’i “işlendi” olarak işaretleyip yanıtı saklamamak, replay yapamamanıza yol açar. Retry ya hata verir ya da mantığı yeniden çalıştırır.
Çözüm: tam yanıtı (status, header, body) işlendi işareti ile atomik olarak saklayın.
5. Eşzamanlılığı Yok Saymak#
Aynı key ile iki eşzamanlı istek, ikisi de cache’i ıskalar, ikisi de handler’ı çalıştırır, ikisi de sonuç saklar. Biri kazanır ama her iki yan etki de gerçekleşmiştir.
Çözüm: “işleniyor olarak işaretle” adımında key üzerinde kilit ya da unique constraint kullanın.
6. Tenant’lar Arasında Key Sızıntısı#
Scope’suz global bir key store, bir müşterinin key’inin başkasının işlemiyle eşleşmesine izin verir.
Çözüm: key’leri tenant_id:user_id:endpoint:key olarak scope’layın.
7. Çok Kısa TTL#
Key’ler client pes etmeden önce sona ererse, geç gelen bir retry ikinci bir execution’a yol açar.
Çözüm: herhangi makul bir retry penceresinden daha uzun bir TTL seçin. 24 saat makul bir varsayılan.
Ne Zaman Neyi Kullanmalı#
- Salt okunur endpoint: GET kullanın. Başka bir şey gerekmiyor.
- Tam değiştirme: PUT kullanın. Sözleşme gereği idempotent.
- Kaynak silme: DELETE kullanın. Sözleşme gereği idempotent.
- Client’ın bildiği ID ile oluşturma: PUT kullanın ya da ID üzerinde unique constraint olan POST.
- Sunucunun ürettiği ID ile oluşturma:
Idempotency-Keyheader’lı POST kullanın. - Mesaj kuyruğu tüketicisi: dedup tablosu ya da outbox deseni, her zaman.
- Ödeme ya da sipariş: idempotency key ve saklanan bir yanıt gövdesi; aynı key’i ödeme sağlayıcısına da geçirin ki çökme aralığını onun kendi dedup’ı kapatsın.
- E-posta ya da veritabanınızın dışındaki başka bir yan etki: aynı transaction’da yazılan bir outbox satırı ve onu teslim eden bir worker. Süreç gönderimden sonra, yanıtı saklamadan çökerse tek başına key store yine iki kez gönderir.
Sonuç#
Idempotency key yaygın durumda geçerlidir: gerçek dünyada yan etkisi olan bir POST, client’ın ID üretebildiği ve sunucunun yanıtı bir gün tutabildiği hâller. İki durumda doğru araç olmaktan çıkar. Çağıran taraf kaynak ID’sini zaten biliyorsa PUT ya da unique constraint daha ucuzdur ve hiç key store gerektirmez. Yan etki veritabanınızın dışında yaşıyorsa, yani bir e-posta ya da üçüncü taraf çağrısıysa, tek başına key sizi kurtarmaz; asıl işi outbox deseni yapar.
Hangi katman uygunsa, kararı endpoint’i tasarlarken verin. Idempotency’yi ilk çift ücretlendirmeden sonra eklemek, çoktan yazdığınız satırları elle mutabık kılmak demektir.
Kaynaklar#
- RFC 9110: HTTP Semantics - Idempotent Methods (yeni sekmede açılır) - Hangi metotların idempotent olduğunu tanımlayan resmi HTTP spesifikasyonu
- MDN Web Docs: Idempotent (yeni sekmede açılır) - HTTP metot idempotency’sinin anlaşılır açıklaması
- Stripe API Documentation: Idempotent Requests (yeni sekmede açılır) - Ödeme API’sinde idempotency key’lerinin kanonik örneği
- Stripe Engineering: Designing Robust and Predictable APIs (yeni sekmede açılır) - Stripe’ın iç implementasyonuna derinlemesine bakış
- AWS Lambda Powertools: Idempotency Utility (yeni sekmede açılır) - Lambda için DynamoDB tabanlı production kütüphanesi
- AWS SQS FIFO: Exactly-Once Processing (yeni sekmede açılır) - Deduplication üzerine AWS dokümantasyonu
- IETF Draft: The Idempotency-Key HTTP Header Field (yeni sekmede açılır) - Header için gelişen standart
- PostgreSQL Documentation: INSERT ON CONFLICT (yeni sekmede açılır) - Idempotent insert’ler için upsert söz dizimi
- DynamoDB Conditional Writes (yeni sekmede açılır) - Idempotent write’lar için condition expression’lar
- Apache Kafka: Semantics and Idempotent Producer (yeni sekmede açılır) - Exactly-once semantics’in sınırları
- The Two Generals Problem (yeni sekmede açılır) - Exactly-once teslimatın neden imkansız olduğu
- Square Developer Docs: Idempotency (yeni sekmede açılır) - Başka bir ödeme sağlayıcısından alternatif bakış
İlgili yazılar
Event odaklı sistemlerin arkasındaki zihinsel değişimi öğrenin: komut vermek yerine olguları duyurmak. İsimlendirme, ayrıştırma, nihai tutarlılık ve idempotency.
event-driven · messaging · idempotency +2
Bir UI parçasının arkasındaki ince sunum servisi yapışkan koda dönüşür. Port-ve-adaptör, çekirdeği somut hiçbir şeye bağımlı bırakmayarak bunu sürdürülebilir tutar.
architecture · nodejs · typescript +3
Tek bir backend üzerinde çalışan web SPA ve mobil uygulama için uzun süreli işlere dair tek bir varsayılan desen ve onu geçersiz kılmanız gereken durumlar.
api-design · real-time · webhooks +4
Node.js sunucudan sunucuya çağrılarda neden varsayılan undici olmalı, Axios, native fetch ve Effect ne zaman daha doğru tercih
nodejs · http · functional-programming +3
DynamoDB'de OFFSET, keyfi ORDER BY ve ucuz COUNT yok. İmzalı next/prev cursor sunun, sıralamayı sort key ile modelleyin, toplam sayıyı listeden çıkarın.
dynamodb · aws · data-storage-orm +2