in

Idempotent API Tasarımı: Çift Sipariş ve Ödemeyi Önlemek

Zaman aşımı, mobil yeniden deneme ve gateway retry'ı aynı isteği iki kez gönderir; sonucu çift sipariş ve çift ödemedir. Idempotency-Key deseninin sunucu tarafında nasıl uygulandığını, iş anahtarlarıyla doğal idempotency'yi, ETag ile koşullu güncellemeyi ve mesaj tüketicilerindeki karşılığını Spring Boot örnekleriyle anlatıyoruz.

Aynı Idempotency-Key ile gelen tekrar isteğin saklanan yanıtla karşılandığını gösteren akış

Bir mobil uygulama "Siparişi Onayla" düğmesine basıldığında isteği gönderir, yanıt 30 saniye gelmeyince yeniden dener. API gateway 502 aldığında otomatik retry yapar. Kullanıcı sabırsızlanıp ikinci kez tıklar. Bu üç senaryonun ortak sonucu aynıdır: aynı istek sunucuya iki kez ulaşır ve iki sipariş, iki ödeme, iki fatura oluşur. Yeniden deneme dağıtık sistemlerde kaçınılmazdır; sorun yeniden denemede değil, API'nin buna hazır olmamasındadır.

Idempotent bir işlem, kaç kez tekrarlanırsa tekrarlansın tek bir etki üretir. Bu yazıda HTTP'de neyin doğal olarak idempotent olduğunu, Idempotency-Key deseninin sunucu tarafında nasıl uygulandığını, iş anahtarlarıyla doğal idempotency'yi, ETag ile koşullu güncellemeyi ve mesaj tüketicilerindeki karşılığını Spring Boot örnekleriyle anlatıyoruz.

HTTP'de doğal idempotency

Metot

Idempotent mi?

Not

GET, HEAD

Evet

Yan etkisiz olmalı; "GET ile sil" tasarımı bu garantiyi bozar

PUT

Evet

Kaynağın tamamını verilen duruma getirir; ikinci PUT aynı durumu tekrar yazar

DELETE

Evet

İkinci silme 404 dönebilir, ama etki tektir

POST

Hayır

Her çağrı yeni kaynak yaratır; idempotency açıkça tasarlanmalı

PATCH

Genellikle hayır

"stok += 5" gibi göreli güncellemeler tekrarda birikir

Kural: yaratma işlemleri (POST) ve göreli güncellemeler idempotency garantisi gerektirir. Diğerleri tasarım bozulmadıkça güvenlidir.

Idempotency-Key deseni

İstemci her yaratma isteğine benzersiz bir anahtar (UUID) koyar; sunucu bu anahtarla istek özetini ve üretilen yanıtı saklar. Aynı anahtar ikinci kez gelirse iş mantığı çalıştırılmaz, saklanan yanıt aynen döner.

POST /api/siparisler HTTP/1.1
Idempotency-Key: 7f3c9e2a-5b1d-4c8e-9a0f-2d6b8e4c1a77
Content-Type: application/json

{ "musteriId": "M-1042", "kalemler": [ { "urun": "10001", "miktar": 3 } ] }

Durum

Sunucu davranışı

HTTP yanıtı

Anahtar ilk kez görülüyor

İşi çalıştır, yanıtı anahtarla sakla

201 Created

Aynı anahtar, aynı istek gövdesi

Saklanan yanıtı döndür; iş mantığı çalışmaz

Orijinalle aynı (201)

Aynı anahtar, farklı gövde

Reddet; istemci anahtarı yanlış kullanıyor

422 Unprocessable Content

Aynı anahtar, ilk istek hâlâ işleniyor

Bekletme ya da reddet

409 Conflict (Retry-After ile)

Anahtar yok

Politikaya göre: reddet ya da normal işle

400 (zorunluysa)

Sunucu tarafı uygulama

Kritik nokta anahtarı atomik olarak sahiplenmektir; iki eşzamanlı istek aynı anahtarı aynı anda "ilk kez" görmemelidir. Bunun en güvenilir yolu veritabanındaki unique kısıttır:

CREATE TABLE idempotency_keys (
    key            VARCHAR(64) PRIMARY KEY,
    scope          VARCHAR(64) NOT NULL,          -- kullanıcı / kiracı; anahtar kapsam içinde benzersiz
    request_hash   CHAR(64)    NOT NULL,          -- gövdenin SHA-256'sı
    status         VARCHAR(16) NOT NULL,          -- IN_PROGRESS | DONE
    response_code  INT,
    response_body  JSONB,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
@Component
public class IdempotentYurutucu {
    private final IdempotencyRepository repo;
    private final TransactionTemplate tx;

    public <T> Sonuc<T> calistir(String anahtar, String kapsam, String govdeHash, Supplier<Sonuc<T>> is) {
        // 1) Anahtarı sahiplen: INSERT ... ON CONFLICT DO NOTHING → 0 satır = daha önce görülmüş
        boolean sahiplenildi = tx.execute(s -> repo.sahiplen(anahtar, kapsam, govdeHash));
        if (!sahiplenildi) {
            IdempotencyKaydi k = repo.bul(anahtar, kapsam);
            if (!k.requestHash().equals(govdeHash)) throw new AnahtarUyusmazligi();        // 422
            if (k.status() == Status.IN_PROGRESS)   throw new HalaIsleniyor();             // 409
            return Sonuc.of(k.responseCode(), k.responseBody());                            // saklanan yanıt
        }
        // 2) İşi çalıştır ve yanıtı anahtarla birlikte tamamla
        try {
            Sonuc<T> sonuc = is.get();
            tx.executeWithoutResult(s -> repo.tamamla(anahtar, kapsam, sonuc.code(), sonuc.bodyJson()));
            return sonuc;
        } catch (RuntimeException e) {
            tx.executeWithoutResult(s -> repo.sil(anahtar, kapsam));   // başarısız denemede anahtar serbest kalır
            throw e;
        }
    }
}
@PostMapping("/api/siparisler")
ResponseEntity<SiparisYaniti> olustur(@RequestHeader("Idempotency-Key") String anahtar,
                                      @AuthenticationPrincipal Kullanici k,
                                      @Valid @RequestBody SiparisIstegi istek) {
    Sonuc<SiparisYaniti> sonuc = yurutucu.calistir(anahtar, k.kiraciId(), Hash.sha256(istek),
        () -> Sonuc.created(siparisServisi.olustur(istek.toKomut())));
    return ResponseEntity.status(sonuc.code()).body(sonuc.body());
}

İki ayrıntı önemlidir. Anahtar kapsam içinde benzersizdir (kullanıcı ya da kiracı); aksi hâlde bir istemcinin ürettiği UUID başkasının yanıtını döndürebilir. Ve iş mantığının kendi transaction'ı ile anahtar tamamlama tek transaction'da olmalıdır; aksi hâlde "sipariş oluştu, anahtar DONE olmadı" durumunda tekrar istek ikinci siparişi üretir. Yukarıdaki örnekte bunu is.get() içindeki servis transaction'ına repo.tamamla'yı dahil ederek sağlarsınız; alternatif olarak dış transaction sınırı kullanılır.

Doğal idempotency: anahtarı iş verisinden almak

Her yerde Idempotency-Key gerekmez. Çoğu kurumsal senaryoda iş verisinin kendisi benzersiz bir anahtar taşır:

  • İstemci tarafında üretilen kaynak kimliği: Sipariş numarasını istemci üretir ve PUT /api/siparisler/{no} ile gönderir; ikinci PUT aynı kaynağı yeniden yazar, tablo üzerindeki unique kısıt çift kaydı engeller.

  • Dış referans: ERP'ye aktarılan siparişte müşteri referansı, e-faturada belge numarası; karşı sistem "bu referans zaten var" der.

  • Upsert: INSERT ... ON CONFLICT (musteri_id, tarih) DO UPDATE ile günlük özet kayıtları kaç kez yazılırsa yazılsın tek satırdır.

Güncellemelerde: ETag ve koşullu istek

Güncellemenin sorunu tekrar değil, kayıp güncellemedir: iki kullanıcı aynı kaydı okur, ikisi de yazar, biri diğerini ezer. Çözüm optimistic locking'in HTTP karşılığıdır:

GET /api/musteriler/M-1042           → 200, ETag: "v7"
PUT /api/musteriler/M-1042
If-Match: "v7"                        → sürüm hâlâ 7 ise 200; değiştiyse 412 Precondition Failed

Spring'de @Version alanı ETag olarak dışa verilir; If-Match eşleşmezse ObjectOptimisticLockingFailureException 412'ye çevrilir. İstemci kaydı yeniden okur ve kullanıcıya çakışmayı gösterir.

Mesaj tüketicilerinde idempotency

HTTP'de yeniden deneme neyse Kafka ve kuyruklarda yeniden teslim odur; en az bir kez teslim garantisi, aynı mesajın iki kez işlenebileceği anlamına gelir. Karşılık aynıdır: tüketici, işlediği mesaj kimliklerini kendi transaction'ında bir tabloya yazar ve tekrar gelen kimliği atlar. Bu deseni outbox pattern yazısında ayrıntılı ele aldık.

Ödeme senaryosu: sonucu bilinmeyen istek

En zor durum, ödeme sağlayıcısına gönderilen isteğin zaman aşımına uğramasıdır: para çekildi mi, çekilmedi mi? Yeniden denemek çift çekim riskidir; denememek kayıp satıştır. Doğru sıra: (1) kendi idempotency anahtarınızı sağlayıcının anahtar mekanizmasına iletin (çoğu ödeme API'si destekler), (2) zaman aşımında önce sağlayıcıdan işlemin durumunu sorgulayın, (3) sonuç bilinmiyorsa aynı anahtarla yeniden deneyin. Anahtarsız yeniden deneme ödeme sistemlerinde kabul edilemez.

Uygulama ayrıntıları

  • Saklama süresi: Anahtarlar 24–72 saat tutulur, sonra silinir; istemciler yeniden denemeyi bu pencerede yapar.

  • Gövde özeti: Aynı anahtarla farklı gövdeyi yakalamak için SHA-256; alan sırasından etkilenmemek için kanonik JSON.

  • Yanıt saklama: Yalnızca durum kodu ve gövde; büyük yanıtlarda kaynak kimliğini saklayıp yeniden üretmek daha ucuzdur.

  • Gözlemlenebilirlik: "Tekrar isteğin yakalanma oranı" metriği hem istemci hatalarını hem ağ sorunlarını erken gösterir.

  • Dokümantasyon: OpenAPI tanımında başlığı zorunlu parametre olarak yazın; istemci ekipleri desenin varlığını bilmezse uygulamaz.

Tuzaklar

  1. Anahtarı bellek içi önbellekte tutmak. Uygulama yeniden başlayınca ya da ikinci örnek devreye girince garanti kaybolur; paylaşılan veritabanı ya da Redis gerekir.

  2. Anahtarı kontrol edip sonra yazmak. "SELECT varsa dön, yoksa INSERT" iki eşzamanlı istekte ikisini de geçirir; atomik INSERT şarttır.

  3. Başarısız denemenin anahtarını kilitli bırakmak. İş mantığı hata verdiyse anahtar serbest kalmalı; aksi hâlde istemci düzeltip yeniden deneyemez.

  4. Kapsamsız anahtar. Kiracılar arası anahtar çakışması veri sızıntısıdır.

Sık sorulan sorular

Idempotency-Key'i istemci mi sunucu mu üretmeli?

İstemci; çünkü yeniden denemeyi istemci yapar ve aynı anahtarı göndermesi gerekir. Sunucunun ürettiği anahtar ancak "önce anahtar al, sonra gönder" biçiminde iki adımlı akışla çalışır ve ek gecikme getirir.

Redis mi veritabanı mı?

Yanıt hızı kritik ve anahtar ömrü kısaysa Redis (SET NX EX ile atomik sahiplenme) uygundur. Ödeme gibi kaybı kabul edilemeyen işlemlerde iş verisiyle aynı veritabanı, tek transaction garantisi verdiği için daha güvenlidir.

Tüm POST uçlarına uygulamalı mıyız?

Etkisi geri alınamayan ya da maliyetli olanlara: sipariş, ödeme, fatura, e-posta/SMS gönderimi, dış sisteme yazma. Arama ve rapor gibi yan etkisiz POST'lar için gerekmez.

Sonuç

Yeniden deneme dağıtık sistemlerin doğasıdır; API'nin görevi bunu güvenli kılmaktır. Yaratma işlemlerinde Idempotency-Key ya da iş anahtarı, güncellemelerde ETag/If-Match, tüketicilerde işlenen mesaj tablosu — bu üçlü, çift sipariş ve çift ödeme sınıfındaki hataları tasarım düzeyinde ortadan kaldırır.

Mevcut API'lerinizi tekrar eden isteklere karşı güvenli hale getirmek için entegrasyon ve otomasyon ve web ve e-ticaret geliştirme hizmetlerimize göz atabilirsiniz.