WEB GELİŞTİRME22.10.202627 dk okuma5

POST İsteği İki Kez Gelirse Ne Olur? Idempotency Key ile Güvenli API Tasarımı

Bir POST isteğinin cevabı kaybolduğunda aynı sipariş iki kez oluşabilir. Bu yazıda Idempotency-Key yaklaşımını Node.js, TypeScript ve PostgreSQL ile atomic claim, transaction, request hash ve retry senaryoları üzerinden inceliyoruz.

#BACKEND#NODE.JS#TYPESCRIPT#POSTGRESQL#IDEMPOTENCY#REST API#CONCURRENCY#TRANSACTION#RETRY

Bir POST isteğinin istemci tarafında başarısız görünmesi, sunucudaki işlemin de başarısız olduğu anlamına gelmez. Sunucu işlemi tamamladığı hâlde cevap ağda kaybolabilir; istemci aynı isteği tekrar gönderdiğinde ise sipariş, ödeme veya rezervasyon gibi işlemler birden fazla kez gerçekleşebilir.

Bu yazıda sipariş oluşturma senaryosu üzerinden Idempotency-Key yaklaşımını Node.js, TypeScript ve PostgreSQL ile adım adım inceleyeceğiz. Yazı boyunca:

  • Bir POST isteğinin neden tekrar gönderilebildiğini,
  • Idempotent işlemin ne anlama geldiğini,
  • Yalnızca uygulama seviyesindeki kontrolün neden yeterli olmadığını,
  • UNIQUE constraint, INSERT ... ON CONFLICT ve transaction kullanımını,
  • Aynı anahtarın farklı istek içeriğiyle kullanılmasını nasıl tespit edeceğimizi,
  • Anahtarın saklama süresini ve idempotency yaklaşımının sınırlarını

ele alacağız.

Amacımız yalnızca tekrar gönderilen isteği engellemek değil; ağ hataları ve eşzamanlı istekler karşısında davranışı açıkça tanımlanmış, güvenilir bir API tasarımı oluşturmaktır.

1. Problem: Sipariş Oluştu Fakat Cevap İstemciye Ulaşmadı

Bir e-ticaret uygulamasında yeni sipariş oluşturmak için aşağıdaki endpoint’in kullanıldığını düşünelim:

POST /api/orders

Node.js ve TypeScript ile hazırlanmış basit bir endpoint şu şekilde olabilir:

app.post("/api/orders", async (req, res, next) => {
  try {
    const order = await orderService.createOrder({
      userId: req.user.id,
      items: req.body.items,
    });

    return res.status(201).json(order);
  } catch (error) {
    next(error);
  }
});

İstemci sipariş oluşturma isteğini gönderdiğinde sunucu gerekli kontrolleri yapar, siparişi PostgreSQL veritabanına kaydeder ve oluşturulan siparişi 201 Created cevabıyla döndürür.

İstemci                         Sunucu                         PostgreSQL
   │                               │                               │
   ├── POST /api/orders ──────────►│                               │
   │                               ├── INSERT INTO orders ────────►│
   │                               │◄── Sipariş oluşturuldu ───────┤
   │◄── 201 Created ───────────────┤                               │

Ancak veritabanı işleminin başarılı olması, cevabın istemciye kesin olarak ulaşacağı anlamına gelmez. Sipariş kaydedildikten sonra aşağıdaki durumlardan biri yaşanabilir:

  • İstemcinin internet bağlantısı kesilebilir.
  • İstek zaman aşımına uğrayabilir.
  • Sunucu ile istemci arasındaki bağlantı kopabilir.
  • Bir proxy veya API gateway cevabı iletemeyebilir.
  • Mobil uygulama cevap gelmeden yeniden bağlantı kurabilir.

Bu durumda işlem şu şekilde gerçekleşir:

İstemci                         Sunucu                         PostgreSQL
   │                               │                               │
   ├── POST /api/orders ──────────►│                               │
   │                               ├── INSERT INTO orders ────────►│
   │                               │◄── Sipariş oluşturuldu ───────┤
   │                               │                               │
   │          Bağlantı koptu       │                               │
   │◄─────────── X ────────────────┤                               │

Sunucu açısından işlem başarıyla tamamlanmıştır. Sipariş veritabanında bulunmaktadır. Ancak istemci cevabı alamadığı için işlemin başarılı mı yoksa başarısız mı olduğunu bilemez.

Bu belirsizlik nedeniyle istemci aynı isteği tekrar gönderebilir:

İstemci                         Sunucu                         PostgreSQL
   │                               │                               │
   ├── POST /api/orders ──────────►│                               │
   │                               ├── INSERT INTO orders ────────►│
   │                               │◄── Sipariş 101 oluşturuldu ───┤
   │          Cevap kayboldu       │                               │
   │◄─────────── X ────────────────┤                               │
   │                               │                               │
   ├── POST /api/orders ──────────►│                               │
   │                               ├── INSERT INTO orders ────────►│
   │                               │◄── Sipariş 102 oluşturuldu ───┤
   │◄── 201 Created ───────────────┤                               │

İkinci istek teknik olarak geçerli yeni bir POST isteğidir. Sunucu, bunun önceki isteğin tekrarı olduğunu anlayamaz ve yeni bir sipariş oluşturur.

Sonuç olarak aynı kullanıcı işlemi için veritabanında iki farklı sipariş bulunur:

Sipariş 101 → Kullanıcının ilk isteği
Sipariş 102 → Aynı isteğin tekrar gönderilmesi

Dolayısıyla çözmemiz gereken soru şudur:

Sunucu siparişi başarıyla oluşturduğu hâlde istemci cevabı alamazsa, aynı POST isteği yeni bir sipariş oluşturmadan nasıl tekrar gönderilebilir?

Bu soruyu cevaplayabilmek için önce idempotent işlem kavramını ve aynı isteğin neden birden fazla kez gönderilebileceğini anlamamız gerekir.

2. Aynı POST İsteği Neden Tekrar Gönderilebilir?

Bir POST isteğinin yinelenmesi yalnızca kullanıcının butona iki kez basmasından kaynaklanmaz. Burada iki kavramı ayırmak gerekir:

  • Mantıksal işlem: Kullanıcının tek bir sipariş oluşturma niyeti
  • İstek denemesi: Bu işlemi gerçekleştirmek için gönderilen her HTTP isteği

Tek bir mantıksal işlem; çift tıklama, istemci tarafındaki otomatik retry, mobil bağlantının kesilmesi veya proxy ve API gateway gibi altyapı bileşenlerinin yeniden denemesi nedeniyle birden fazla HTTP isteğine dönüşebilir.

En kritik durum, sunucunun siparişi oluşturduğu fakat cevabın istemciye ulaşmadığı senaryodur:

Timeout yalnızca istemcinin zamanında cevap alamadığını gösterir; işlemin sunucuda başarısız olduğunu kanıtlamaz. İstemci bu belirsizlik nedeniyle aynı isteği yeniden gönderdiğinde sunucu iki denemeyi bağımsız kabul ederse ikinci bir sipariş oluşabilir.

Butonu ilk tıklamadan sonra devre dışı bırakmak ve retry sayısını sınırlamak faydalıdır; ancak bunlar backend tarafında güvence sağlamaz. Sunucunun, farklı HTTP denemelerinin aynı mantıksal işleme ait olduğunu anlayabilmesi gerekir. Bu ihtiyacı idempotency yaklaşımı karşılar.

3. Idempotent İşlem Ne Demektir?

Bir işlem, aynı girdilerle birden fazla kez uygulandığında sistemin nihai durumunu ilk uygulamadan sonra değiştirmiyorsa idempotent kabul edilir.

Bunu basit bir ifadeyle şöyle gösterebiliriz:

İşlemi bir kez uygulamak  → Sistemin durumu: X
İşlemi tekrar uygulamak   → Sistemin durumu: X

İkinci ve sonraki uygulamalar sistemde yeni bir yan etki oluşturmuyorsa işlem idempotent davranır.

Idempotent Bir İşlem Örneği

Bir siparişin durumunu CANCELLED olarak değiştiren işlemi ele alalım:

UPDATE orders
SET status = 'CANCELLED'
WHERE id = 101;

Bu sorgu bir kez çalıştırıldığında siparişin durumu CANCELLED olur:

İlk durum       → CONFIRMED
Birinci işlem   → CANCELLED
İkinci işlem    → CANCELLED
Üçüncü işlem    → CANCELLED

Aynı işlem tekrarlandığında siparişin durumu değişmeye devam etmez. Sistemin nihai durumu her çalıştırmadan sonra aynı kaldığı için bu işlem idempotent davranır.

Idempotent Olmayan Bir İşlem Örneği

Sipariş oluşturma işlemi ise varsayılan olarak idempotent değildir:

INSERT INTO orders (user_id, status)
VALUES ('user-123', 'PENDING');

Aynı sorgu iki kez çalıştırılırsa iki farklı kayıt oluşabilir:

Birinci işlem → Sipariş 101 oluşturuldu
İkinci işlem  → Sipariş 102 oluşturuldu

Her tekrar sistemin durumunu yeniden değiştirdiği için bu işlem idempotent değildir.

Benzer şekilde aşağıdaki bakiye güncellemesi de idempotent değildir:

UPDATE accounts
SET balance = balance - 500
WHERE id = 'account-123';

Sorgu her çalıştırıldığında bakiyeden yeniden 500 düşülür:

Başlangıç bakiyesi → 2000
Birinci işlem      → 1500
İkinci işlem       → 1000
Üçüncü işlem       → 500

Burada aynı işlemin tekrar edilmesi yeni bir yan etki oluşturur.

Aynı Cevap ile Aynı Etki Aynı Şey Değildir

Idempotency, bütün isteklerin birebir aynı HTTP cevabını üretmesi gerektiği anlamına gelmez. Temel ölçüt, sistem üzerinde oluşan etkidir.

Örneğin bir kaynak ilk istekte başarıyla silinebilir. Aynı silme isteği tekrar gönderildiğinde kaynak artık bulunmadığı için farklı bir durum kodu dönebilir. Cevaplar farklı olsa bile kaynak silinmiş olarak kalır ve ikinci istek yeni bir durum değişikliği oluşturmaz.

Dolayısıyla iki ayrı noktayı değerlendirmek gerekir:

  • İsteğin döndürdüğü HTTP cevabı
  • İsteğin sistem üzerinde oluşturduğu kalıcı etki

Idempotency esas olarak ikinci noktayla ilgilidir.

Sipariş Oluşturma İşlemini Nasıl Idempotent Hâle Getirebiliriz?

Normal bir POST /api/orders isteği her çalıştırıldığında yeni bir sipariş oluşturabilir. Sunucunun iki isteğin aynı mantıksal işleme ait olduğunu anlayabilmesi için istemcinin bu işlemi tanımlayan benzersiz bir değer göndermesi gerekir.

Örneğin:

POST /api/orders
Idempotency-Key: 7d5e98f4-72e1-45c3-b9df-68eaf67cb171

İlk istekte sunucu bu anahtarla ilişkilendirilmiş siparişi oluşturur:

Idempotency-Key → 7d5e98f4-72e1-45c3-b9df-68eaf67cb171
Sipariş         → 101

Aynı anahtara sahip istek tekrar gönderildiğinde sunucu yeni bir sipariş oluşturmak yerine daha önce tamamlanan işlemin sonucunu kullanır:

Birinci istek → Sipariş 101 oluşturuldu
İkinci istek  → Yeni sipariş oluşturulmadı
Sonuç         → Sipariş 101

Bu davranış yalnızca aynı Idempotency-Key kapsamında geçerlidir. İstemci yeni bir sipariş oluşturmak istediğinde yeni bir anahtar üretmelidir.

Idempotency-Key, tek başına isteği güvenli hâle getiren sihirli bir değer değildir. Sunucunun bu anahtarı doğrulaması, saklaması ve aynı anahtarla gelen isteklerde doğru davranışı uygulaması gerekir.

4. HTTP Metotları ve Idempotency

HTTP standardı bazı metotları idempotent kabul ederken POST ve PATCH için varsayılan bir garanti vermez:

HTTP metodu Safe Idempotent Temel amacı
GET, HEAD, OPTIONS Evet Evet Veri okumak veya iletişim seçeneklerini öğrenmek
PUT Hayır Evet Belirli bir kaynağı oluşturmak veya tamamen değiştirmek
DELETE Hayır Evet Belirli bir kaynağı silmek
POST Hayır Hayır Yeni bir kaynak veya işlem oluşturmak
PATCH Hayır Garanti edilmez Kaynağın bir bölümünü değiştirmek

Safe metotlar, istemcinin sunucuda durum değişikliği talep etmediği metotlardır. Idempotent ise aynı isteğin tekrar uygulanmasının ilk uygulamadan sonra yeni bir beklenen etki oluşturmaması demektir. Bu nedenle her safe metot idempotent kabul edilir; fakat PUT ve DELETE gibi her idempotent metot safe değildir.

Idempotency aynı HTTP cevabının dönmesini gerektirmez. Örneğin ilk DELETE /api/orders/101 isteği 204, ikincisi 404 döndürebilir. Her iki isteğin sonunda da kaynak mevcut olmadığı için beklenen nihai etki aynıdır.

POST /api/orders ise her çalıştığında yeni bir sipariş oluşturabilir:

Birinci POST → Sipariş 101
İkinci POST  → Sipariş 102

PATCH davranışı da yapılan değişikliğe bağlıdır. Bir alanı sabit bir değere ayarlamak idempotent olabilirken sayacı bir artırmak her tekrarda yeni bir etki üretir. Dolayısıyla yalnızca HTTP metodunun adı yeterli değildir; endpoint’in uygulaması da bu semantiğe uygun olmalıdır.

Metotların temel rolünü daha önce REST API Nedir? Node.js ve Express ile İlk REST API yazısında ele almıştık. Standarttaki ayrıntılı idempotency tanımı için RFC 9110’un ilgili bölümüne bakılabilir.

Bu yazıda POST metodunu kullanmaya devam edecek, tekrar gönderilen denemeleri Idempotency-Key ile ilişkilendireceğiz.

5. Idempotency Key Nasıl Çalışır?

Idempotency-Key, istemcinin tek bir mantıksal işlemi tanımlamak için ürettiği benzersiz değerdir. Sipariş kimliği değildir; sipariş oluşturma denemesini tanımlar.

POST /api/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 7d5e98f4-72e1-45c3-b9df-68eaf67cb171

Anahtar genellikle yeni bir işlem başlatılırken istemci tarafından UUID olarak üretilir:

const idempotencyKey = crypto.randomUUID();

await fetch("/api/orders", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  },
  body: JSON.stringify(order),
});

Aynı siparişin retry denemeleri aynı anahtarı kullanmalıdır; gerçekten yeni bir sipariş içinse yeni anahtar üretilmelidir. Her retry sırasında yeni anahtar üretmek, sunucunun denemeleri ilişkilendirmesini engeller.

İlk istekte sunucu anahtarı kaydeder, siparişi oluşturur ve sonucu anahtarla ilişkilendirir. Örneğin kayıtta şu bilgiler bulunabilir:

key-1
status: COMPLETED
response_status: 201
response_body: Sipariş 101

Aynı anahtar yeniden geldiğinde sipariş oluşturma sorgusu tekrar çalıştırılmaz; daha önce saklanan durum kodu ve response body döndürülür. Böylece iki HTTP isteği bulunsa da veritabanında tek sipariş kalır.

Anahtarın kapsamı API sözleşmesinde açıkça tanımlanmalıdır. Bu yazıda kapsamı user_id + operation + idempotency_key birleşimi oluşturur. Böylece farklı kullanıcıların veya farklı operasyonların aynı anahtar değerini üretmesi birbirini etkilemez.

Aynı anahtar farklı bir request body ile kullanılmamalıdır. Bu durumu ayırt etmek için doğrulanmış istek içeriğinden üretilen request_hash değeri saklanır; ayrıntısını 11. bölümde ele alacağız.

Header’ın gönderilmesi tek başına yeterli değildir. Sunucu anahtarı doğrulamalı, kapsamını belirlemeli, sonucu saklamalı ve eşzamanlı isteklerde yalnızca bir işlemin anahtarı kazanmasını sağlamalıdır. Aşağıdaki kontrol bu son koşulu garanti etmez:

const existing = await findIdempotencyKey(key);

if (!existing) {
  await createOrder();
  await saveIdempotencyKey(key);
}

İki istek aynı anda existing değerini boş görebilir. Bir sonraki bölümde bu race condition’ın nasıl oluştuğunu inceleyeceğiz.

Standart durumu: Idempotency-Key yaygın kullanılan bir API tasarım yaklaşımıdır ancak yazının hazırlandığı tarih itibarıyla tamamlanmış, evrensel bir HTTP standardı değildir. MDN bunu deneysel ve standart dışı olarak işaretler. İlgili IETF çalışma taslağının 07 sürümünün süresi 18 Nisan 2026’da dolmuş ve belge arşivlenmiştir. Bu nedenle anahtar formatı, geçerlilik süresi ve hata davranışları API dokümantasyonunda açıkça tanımlanmalıdır.

6. Naif Kontrol Neden Yeterli Değildir?

İlk akla gelen çözüm, anahtarı sorgulayıp bulunamazsa siparişi oluşturmaktır:

const existing = await findIdempotencyKey(key);

if (existing) {
  return existing.response;
}

const order = await createOrder();
await saveIdempotencyResult(key, order);
return order;

İstekler sırayla geldiğinde bu kod çalışır. Fakat birbirine yakın iki istek şu şekilde iç içe geçebilir:

Request A                         Request B
    │                                 │
    ├─ SELECT key-1 → bulunamadı      ├─ SELECT key-1 → bulunamadı
    ├─ Sipariş 101 oluşturuldu        ├─ Sipariş 102 oluşturuldu
    └─ key-1 kaydedildi               └─ key-1 kaydedilmeye çalışıldı

Normal bir SELECT, diğer isteğin aynı sorguyu çalıştırmasını engellemez. Üstelik key-1 satırı henüz oluşmadığı için SELECT ... FOR UPDATE ile kilitlenecek mevcut bir satır da yoktur. Sorun iki sorgunun tam olarak aynı anda başlaması değil, kontrol ile kayıt arasına başka bir işlemin girebilmesidir. Bu bir TOCTOU race condition’dır.

Bu davranış aynı anahtarla iki isteği birlikte başlatarak test edilebilir:

const idempotencyKey = crypto.randomUUID();

await Promise.all([
  sendCreateOrderRequest(idempotencyKey),
  sendCreateOrderRequest(idempotencyKey),
]);

Tabloya UNIQUE (user_id, idempotency_key) eklemek aynı anahtar kaydının iki kez yazılmasını engeller; fakat tek başına çift siparişi engellemeyebilir. Naif akışta sipariş önce oluşturulursa ikinci isteğin anahtar kaydı hata verdiğinde Sipariş 102 çoktan kalıcı olmuş olabilir:

Sipariş 101 → oluşturuldu
Sipariş 102 → oluşturuldu
key-1       → yalnızca Sipariş 101 ile ilişkilendirildi

Bu sorgular tek transaction içinde değilse son INSERT hatası önceki sipariş kaydını otomatik olarak geri almaz.

Güvenceyi Node.js içindeki if kontrolüne bırakamayız. Veritabanı iki konuda karar vermelidir:

  • Aynı kapsamda yalnızca bir idempotency kaydına izin vermek için UNIQUE constraint
  • Anahtarı tek SQL ifadesinde almak için INSERT ... ON CONFLICT

Önce bu mekanizmanın kullanacağı tabloyu tasarlayalım.

7. PostgreSQL Tablo Tasarımı

Sunucunun tekrar gelen bir isteği tanıyabilmesi için idempotency key ile işlem sonucunu kalıcı olarak ilişkilendirmesi gerekir. Bu bilgileri yalnızca Node.js belleğinde tutmak yeterli değildir.

Uygulama yeniden başlatılabilir, birden fazla sunucu instance’ı çalışabilir veya sonraki istek farklı bir instance’a ulaşabilir. Bu nedenle idempotency kayıtlarını PostgreSQL üzerinde saklayacağız.

Sipariş oluşturma örneğimiz için aşağıdaki tabloyu kullanabiliriz:

CREATE TABLE idempotency_keys (
  id BIGSERIAL PRIMARY KEY,

  user_id UUID NOT NULL REFERENCES users(id),
  operation VARCHAR(100) NOT NULL,
  idempotency_key VARCHAR(255) NOT NULL,
  request_hash CHAR(64) NOT NULL,

  status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING'
    CHECK (status IN ('PROCESSING', 'COMPLETED', 'FAILED')),

  order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
  response_status SMALLINT
    CHECK (response_status BETWEEN 100 AND 599),
  response_body JSONB,

  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at TIMESTAMPTZ NOT NULL,

  CONSTRAINT idempotency_keys_scope_unique
    UNIQUE (user_id, operation, idempotency_key)
);

Süresi dolmuş kayıtları bulabilmek için expires_at alanına ayrıca index ekleyebiliriz:

CREATE INDEX idempotency_keys_expires_at_idx
ON idempotency_keys (expires_at);

Anahtarın Kapsamı

Bir idempotency key’i yalnızca gönderilen anahtar değeriyle tanımlamıyoruz. Kapsamı şu üç alan oluşturuyor:

Kullanıcı + Operasyon + Idempotency Key

Örneğin:

user-123 + CREATE_ORDER + key-1

user_id, farklı kullanıcıların aynı anahtarı kullanabilmesini sağlar. Bu değer request body’den değil, kimliği doğrulanmış kullanıcının session veya access token bilgisinden alınmalıdır.

operation alanı ise anahtarın hangi işleme ait olduğunu belirtir:

CREATE_ORDER
CANCEL_ORDER
CREATE_PAYMENT

Ham endpoint adresi yerine CREATE_ORDER gibi mantıksal bir isim kullanmak, API adresi değiştiğinde kayıtların anlamını korumayı kolaylaştırır.

Aynı Anahtar Farklı İçerikle Kullanılırsa

request_hash, aynı idempotency key’in farklı sipariş bilgileriyle kullanılmasını tespit etmek için tutulur:

key-1 + product-123 → hash-a
key-1 + product-456 → hash-b

Aynı anahtarla gelen ikinci isteğin hash değeri kayıtlı değerle uyuşmuyorsa istek reddedilmelidir.
Hash değerinin amacı request body’yi şifrelemek değildir. Yalnızca iki isteğin aynı içeriği temsil edip etmediğini karşılaştırmak için kullanılır.

İşlemin Durumu ve Kaydedilen Cevap

Yeni bir anahtar oluşturulduğunda kayıt PROCESSING durumunda başlar:

PROCESSING → Sipariş oluşturuluyor
COMPLETED  → İşlem tamamlandı ve cevap kaydedildi
FAILED     → İşlem tamamlanamadı

Sipariş başarıyla oluşturulduğunda aşağıdaki bilgiler kayda yazılır:

order_id        → 101
response_status → 201
response_body   → { "id": 101, "status": "PENDING" }
status          → COMPLETED

Aynı anahtar tekrar geldiğinde sunucu orders tablosuna yeniden kayıt eklemek yerine saklanan response_status ve response_body değerlerini döndürür.

Anahtarın Geçerlilik Süresi

expires_at, idempotency kaydının ne zamana kadar geçerli olduğunu gösterir. Eklediğimiz index, temizlik işleminin süresi dolmuş kayıtları daha hızlı bulmasına yardımcı olur:

DELETE FROM idempotency_keys
WHERE expires_at < NOW();

expires_at zamanının geçmesi satırı otomatik olarak silmez. Bunun için zamanlanmış bir temizlik işlemi gerekir.

Ayrıca satır silinmediği sürece UNIQUE constraint çalışmaya devam eder. Anahtarın süresinin dolmuş olması, aynı anahtarın otomatik olarak yeniden kullanılabileceği anlamına gelmez.

Asıl Güvence: UNIQUE Constraint

Tablonun en önemli bölümü aşağıdaki benzersizlik kısıtıdır:

CONSTRAINT idempotency_keys_scope_unique
  UNIQUE (user_id, operation, idempotency_key)

Bu constraint aynı kullanıcı, operasyon ve anahtar kombinasyonunun yalnızca bir kez kaydedilmesine izin verir.

İki istek aynı anda aşağıdaki kaydı oluşturmaya çalışırsa:

user-123 + CREATE_ORDER + key-1

PostgreSQL yalnızca bir kaydın oluşturulmasına izin verir. Böylece hangi isteğin anahtarı kullanma hakkını kazandığına uygulamadaki if kontrolü değil, veritabanı karar verir.

UNIQUE constraint bu alanlar için otomatik olarak benzersiz bir index oluşturur. Bu nedenle aynı üç alan için ayrıca bir index oluşturmamız gerekmez.

Örnek olarak tamamlanmış bir kayıt şöyle görünür:

user_id operation idempotency_key request_hash status order_id response_status
user-123 CREATE_ORDER key-1 hash-a COMPLETED 101 201

Ancak UNIQUE constraint’in bulunması tek başına yeterli değildir. Sipariş oluşturulmadan önce hangi isteğin anahtarı kullanma hakkını kazandığını belirlememiz gerekir.

Bir sonraki bölümde anahtarı INSERT ... ON CONFLICT DO NOTHING kullanarak atomik biçimde kaydedeceğiz.

8. Anahtarı Atomik Olarak Kaydetmek

Önce SELECT, ardından INSERT çalıştırmak iki sorgu arasına başka bir isteğin girmesine izin verir. Bu kontrol-kullanım aralığı, daha önce TOCTOU ve Race Condition yazısında incelediğimiz problemin aynısıdır.

Kararı tek bir SQL ifadesinde PostgreSQL’e bırakabiliriz:

INSERT INTO idempotency_keys (
  user_id,
  operation,
  idempotency_key,
  request_hash,
  status,
  expires_at
)
VALUES ($1, $2, $3, $4, 'PROCESSING', $5)
ON CONFLICT (user_id, operation, idempotency_key)
DO NOTHING
RETURNING id, status;

Bu sorgunun dayanağı şu benzersizlik kuralıdır:

UNIQUE (user_id, operation, idempotency_key)

Kayıt yoksa INSERT başarılı olur ve RETURNING oluşturulan satırı döndürür. Aynı kapsamdaki anahtar zaten varsa PostgreSQL yeni satır eklemez ve sonuç boş gelir:

const claimResult = await client.query(
  \`INSERT INTO idempotency_keys (
     user_id, operation, idempotency_key,
     request_hash, status, expires_at
   )
   VALUES ($1, $2, $3, $4, 'PROCESSING', $5)
   ON CONFLICT (user_id, operation, idempotency_key)
   DO NOTHING
   RETURNING id, status\`,
  [userId, "CREATE_ORDER", idempotencyKey, requestHash, expiresAt]
);

const claimed = claimResult.rowCount === 1;

Aynı anahtarla iki istek eşzamanlı geldiğinde PostgreSQL unique index üzerinden çakışmayı yönetir. Gerekirse ikinci INSERT, ilk transaction’ın sonucunu bekler:

Request A                     Request B
    │                             │
    ├─ INSERT key-1               ├─ INSERT key-1
    │                             └─ bekler
    ├─ COMMIT
    │                             └─ DO NOTHING
    └─ anahtarı kazandı              anahtarı kazanamadı

İlk transaction rollback ederse ikinci istek kaydı oluşturabilir. Buradaki atomik davranış, “anahtar var mı?” kontrolü ile “anahtarı ekle” kararı arasına başka bir işlemin girememesidir; karar uygulamadaki iki ayrı sorguyla değil, tek SQL ifadesiyle verilir.

Anahtar alınamadığında mevcut kayıt okunur. request_hash, status ve kayıtlı cevap bilgileri kullanılarak isteğin reddedilmesine, beklemesine veya önceki cevabın yeniden dönülmesine karar verilir. ON CONFLICT ve RETURNING ayrıntıları PostgreSQL INSERT dokümantasyonunda bulunabilir.

Anahtarı tek bir isteğin kazanması yeterli değildir; sipariş ile idempotency kaydının da birbirinden kopmaması gerekir. Bu nedenle sıradaki adım transaction kullanmaktır.

9. Sipariş ve Idempotency Kaydını Transaction İçinde Oluşturmak

Sipariş oluşturma akışı üç veritabanı değişikliğinden oluşur:

1. Idempotency kaydını oluştur
2. Siparişi oluştur
3. Sonucu idempotency kaydına yaz

Bu adımlardan yalnızca bir kısmı kalıcı olursa PROCESSING durumunda takılmış bir kayıt veya kayıtlı cevabı bulunmayan bir sipariş oluşabilir. Üç adımı aynı PostgreSQL transaction’ında çalıştırarak ya tamamının kalıcı olmasını ya da hiçbirinin etkisinin kalmamasını sağlarız.

  • BEGIN transaction’ı başlatır.
  • COMMIT bütün değişiklikleri kalıcı hâle getirir.
  • ROLLBACK transaction içinde o ana kadar yapılan değişiklikleri geri alır.

Node.js tarafında transaction boyunca aynı veritabanı bağlantısı kullanılmalıdır:

const client = await pool.connect();

try {
  await client.query("BEGIN");

  // Idempotency kaydını almaya çalış
  // Siparişi oluştur
  // Kayıtlı cevabı güncelle

  await client.query("COMMIT");
} catch (error) {
  await client.query("ROLLBACK");
  throw error;
} finally {
  client.release();
}

Sorguları bu akışın içinde pool.query() ile çalıştırmak doğru değildir. Pool her sorguyu farklı bir bağlantıya gönderebilir; bu durumda BEGIN, iş sorguları ve COMMIT aynı transaction’a ait olmaz. Bütün sorgular client.query() üzerinden yürütülmelidir. node-postgres transaction dokümantasyonu da bu gerekliliği özellikle belirtir.

Başarılı akış şöyledir:

BEGIN
  ↓
Idempotency kaydı PROCESSING olarak eklendi
  ↓
Sipariş oluşturuldu
  ↓
Cevap kaydedildi ve durum COMPLETED oldu
  ↓
COMMIT

Cevap daha sonra ağda kaybolsa bile sipariş ve idempotency sonucu birlikte kayıtlıdır. Aynı anahtarla gelen sonraki istek yeni sipariş oluşturmak yerine önceki cevabı döndürebilir.

Herhangi bir sorgu başarısız olursa ROLLBACK hem siparişi hem idempotency kaydını geri alır. Aynı anahtarla gelen sonraki istek anahtarı yeniden kazanarak işlemi tekrar deneyebilir. FAILED durumunu kalıcı saklamak istersek bunu rollback sonrasında ayrı bir transaction ile yazmamız gerekir.

Aynı anda gelen ikinci istek, ilk transaction’ın unique constraint sonucunu bekleyebilir. İlk işlem commit ederse ikinci istek anahtarı kazanamaz; ilk işlem rollback ederse anahtarı kazanıp devam edebilir. Transaction davranışının temeli PostgreSQL dokümantasyonunda açıklanır.

Bu garanti yalnızca aynı PostgreSQL transaction’ındaki değişiklikleri kapsar. Ödeme servisine gönderilen HTTP isteği gibi harici yan etkiler ROLLBACK ile geri alınamaz.

10. Node.js, TypeScript ve PostgreSQL Uygulaması

Şimdi önceki bölümlerde oluşturduğumuz parçaları tek bir POST /api/orders endpoint’inde birleştirelim.

Örnekte Express ve pg kullanıyoruz. Request body’nin daha önce doğrulandığını ve req.user.id değerinin authentication middleware tarafından eklendiğini varsayıyoruz.

Request Hash Oluşturmak

Aynı anahtarın farklı içerikle kullanılmasını tespit etmek için request body’den SHA-256 hash oluşturacağız:

import { createHash } from "node:crypto";

function canonicalize(value: unknown): string {
  if (value === null || typeof value !== "object") {
    return JSON.stringify(value) ?? "null";
  }

  if (Array.isArray(value)) {
    return `[${value.map(canonicalize).join(",")}]`;
  }

  const object = value as Record<string, unknown>;

  const properties = Object.keys(object)
    .sort()
    .map(
      (key) =>
        `${JSON.stringify(key)}:${canonicalize(object[key])}`
    );

  return `{${properties.join(",")}}`;
}

function createRequestHash(value: unknown): string {
  return createHash("sha256")
    .update(canonicalize(value))
    .digest("hex");
}

canonicalize nesne alanlarını sıralar. Böylece alan sıraları farklı olan ancak aynı veriyi taşıyan JSON nesneleri aynı hash değerini üretebilir.

Sipariş Oluşturma Servisi

interface CreateOrderInput {
  items: Array<{
    productId: string;
    quantity: number;
  }>;
}

interface ServiceResult {
  statusCode: number;
  body: unknown;
}

async function createOrderIdempotently(
  userId: string,
  idempotencyKey: string,
  input: CreateOrderInput
): Promise<ServiceResult> {
  const operation = "CREATE_ORDER";
  const requestHash = createRequestHash(input);
  const client = await pool.connect();

  let transactionOpen = false;

  try {
    await client.query("BEGIN");
    transactionOpen = true;

    const claimResult = await client.query<{ id: string }>(
      `INSERT INTO idempotency_keys (
         user_id,
         operation,
         idempotency_key,
         request_hash,
         status,
         expires_at
       )
       VALUES (
         $1,
         $2,
         $3,
         $4,
         'PROCESSING',
         NOW() + INTERVAL '24 hours'
       )
       ON CONFLICT (
         user_id,
         operation,
         idempotency_key
       )
       DO NOTHING
       RETURNING id`,
      [userId, operation, idempotencyKey, requestHash]
    );

    const claimedRecord = claimResult.rows[0];

    if (!claimedRecord) {
      const existingResult = await client.query<{
        request_hash: string;
        status: "PROCESSING" | "COMPLETED" | "FAILED";
        response_status: number | null;
        response_body: unknown;
      }>(
        `SELECT
           request_hash,
           status,
           response_status,
           response_body
         FROM idempotency_keys
         WHERE user_id = $1
           AND operation = $2
           AND idempotency_key = $3`,
        [userId, operation, idempotencyKey]
      );

      const existing = existingResult.rows[0];

      if (!existing) {
        throw new Error("Idempotency record could not be found");
      }

      if (existing.request_hash !== requestHash) {
        await client.query("COMMIT");
        transactionOpen = false;

        return {
          statusCode: 409,
          body: {
            error: "IDEMPOTENCY_KEY_REUSED",
            message:
              "This idempotency key was used with a different request.",
          },
        };
      }

      if (
        existing.status === "COMPLETED" &&
        existing.response_status !== null
      ) {
        await client.query("COMMIT");
        transactionOpen = false;

        return {
          statusCode: existing.response_status,
          body: existing.response_body,
        };
      }

      await client.query("COMMIT");
      transactionOpen = false;

      return {
        statusCode: 409,
        body: {
          error: "REQUEST_NOT_COMPLETED",
          message:
            "A request with this idempotency key has not completed.",
        },
      };
    }

    const orderResult = await client.query<{
      id: string;
      status: string;
      items: CreateOrderInput["items"];
      created_at: Date;
    }>(
      `INSERT INTO orders (
         user_id,
         status,
         items
       )
       VALUES ($1, 'PENDING', $2::jsonb)
       RETURNING id, status, items, created_at`,
      [userId, JSON.stringify(input.items)]
    );

    const order = orderResult.rows[0];

    const responseBody = {
      id: order.id,
      status: order.status,
      items: order.items,
      createdAt: order.created_at,
    };

    const updateResult = await client.query(
      `UPDATE idempotency_keys
       SET
         status = 'COMPLETED',
         order_id = $1,
         response_status = 201,
         response_body = $2::jsonb,
         updated_at = NOW()
       WHERE id = $3`,
      [
        order.id,
        JSON.stringify(responseBody),
        claimedRecord.id,
      ]
    );

    if (updateResult.rowCount !== 1) {
      throw new Error("Idempotency record could not be completed");
    }

    await client.query("COMMIT");
    transactionOpen = false;

    return {
      statusCode: 201,
      body: responseBody,
    };
  } catch (error) {
    if (transactionOpen) {
      await client.query("ROLLBACK");
    }

    throw error;
  } finally {
    client.release();
  }
}

Örneği sade tutmak için sipariş kalemlerini orders.items isimli bir JSONB alanında sakladık. Gerçek bir projede ayrı bir order_items tablosu kullanılıyorsa bu kayıtlar da aynı transaction içinde oluşturulmalıdır.

Express Endpoint’i

Servisi endpoint üzerinde şu şekilde kullanabiliriz:

import type {
  Request,
  Response,
  NextFunction,
} from "express";

app.post(
  "/api/orders",
  requireAuth,
  validateCreateOrder,
  async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const idempotencyKey =
      req.get("Idempotency-Key")?.trim();

    if (
      !idempotencyKey ||
      idempotencyKey.length > 255
    ) {
      return res.status(400).json({
        error: "INVALID_IDEMPOTENCY_KEY",
        message:
          "A valid Idempotency-Key header is required.",
      });
    }

    try {
      const result = await createOrderIdempotently(
        req.user.id,
        idempotencyKey,
        req.body
      );

      return res
        .status(result.statusCode)
        .json(result.body);
    } catch (error) {
      next(error);
    }
  }
);

Transaction içindeki bütün sorgular aynı client üzerinden çalıştırılır. node-postgres da transaction boyunca aynı client instance’ının kullanılması gerektiğini açıkça belirtir; pool.query() kullanmak sorguların farklı bağlantılarda çalışmasına neden olabilir. Ayrıntılar node-postgres transaction dokümantasyonunda bulunabilir.

İlk İstek Geldiğinde

İlk istek yeni bir anahtar gönderir:

POST /api/orders
Idempotency-Key: key-1

Sunucunun gerçekleştirdiği işlemler:

BEGIN
  ↓
key-1 PROCESSING olarak oluşturuldu
  ↓
Sipariş 101 oluşturuldu
  ↓
Cevap key-1 ile ilişkilendirildi
  ↓
key-1 COMPLETED yapıldı
  ↓
COMMIT

İstemciye şu cevap gönderilir:

{
  "id": "101",
  "status": "PENDING",
  "items": [
    {
      "productId": "product-123",
      "quantity": 1
    }
  ]
}

Veritabanında yalnızca bir sipariş bulunur:

orders
└── Sipariş 101

En önemli sıralama şudur:

Siparişi oluştur
        ↓
Idempotency sonucunu kaydet
        ↓
COMMIT
        ↓
HTTP cevabını gönder

HTTP cevabı COMMIT işleminden sonra kaybolsa bile aynı anahtarla gelen retry isteği kaydedilen sonucu bulabilir.

Bir sonraki bölümde aynı idempotency key’in farklı bir request body ile gönderilmesini ve request_hash kontrolünün neden gerekli olduğunu ayrıntılı olarak ele alacağız.

11. Aynı Idempotency Key Farklı Bir Request Body ile Kullanılırsa Ne Olur?

Bir idempotency key yalnızca tek bir mantıksal işlem için kullanılmalıdır. Aynı key-1 önce product-123, daha sonra product-456 içeren bir siparişle gönderilirse ikinci istek normal retry değildir.

Sunucu yalnızca anahtarı kontrol ederse ikinci istekte yeni sipariş oluşturmaz fakat ilk siparişin cevabını döndürür. Çift kayıt engellenmiş olsa da farklı içerikteki istek sessizce yanlış sonuçla eşleştirilir.

Bu nedenle iki kontrol gerekir:

  • idempotency_key: Anahtar daha önce kullanıldı mı?
  • request_hash: Yeni istek, ilk istekle aynı içeriğe mi sahip?

Hash istemciden alınmamalı; sunucu tarafından doğrulanmış request body üzerinden oluşturulmalıdır:

const validatedBody = createOrderSchema.parse(req.body);
const requestHash = createRequestHash({
  items: validatedBody.items,
});

İlk isteğin hash’i idempotency kaydına yazılır. Aynı anahtarla gelen sonraki isteğin hash’i farklıysa işlem reddedilir:

if (existing.request_hash !== requestHash) {
  return {
    statusCode: 409,
    body: {
      error: "IDEMPOTENCY_KEY_REUSED",
      message: "This idempotency key was used with a different request.",
    },
  };
}

JSON İçeriğini Kararlı Hâle Getirmek

Aşağıdaki nesneler aynı veriyi temsil eder; yalnızca alan sıraları farklıdır:

{ "productId": "product-123", "quantity": 1 }
{ "quantity": 1, "productId": "product-123" }

Ham JSON metnini doğrudan hash’lemek bu iki değeri farklı kabul edebilir. Bu nedenle nesne anahtarlarını sıralayan bir canonicalization adımı kullanırız:

function createRequestHash(value: unknown): string {
  return createHash("sha256")
    .update(canonicalize(value))
    .digest("hex");
}

Hash yalnızca işlemin sonucunu etkileyen doğrulanmış istemci verilerini içermelidir. Authorization, User-Agent, request ID, trace header’ları ve gönderim zamanı gibi her denemede değişebilen bilgiler hash’e eklenmemelidir.

Dizi sırasının anlamlı olup olmadığına API karar vermelidir. Sipariş kalemlerinin sırası sonucu etkilemiyorsa canonicalization sırasında sıralanabilir; sıra anlamlıysa korunmalıdır. Hash burada şifreleme amacı taşımaz, yalnızca iki doğrulanmış isteğin aynı içeriği temsil edip etmediğini karşılaştırır.

Sunucunun karar tablosu şöyledir:

Anahtar Request hash Davranış
Aynı Aynı Kayıtlı cevabı döndür
Aynı Farklı İsteği reddet
Farklı Herhangi bir değer Yeni işlem olarak değerlendir

Bu yazıdaki kod, aynı anahtarın farklı içerikle kullanılmasında 409 Conflict döndürüyor. Süresi dolmuş IETF taslağı ile MDN açıklaması ise 422 Unprocessable Content seçeneğini gösterir. Alan tamamlanmış evrensel bir standart olmadığı için önemli olan seçilen davranışı API sözleşmesinde açıkça belirtmek ve uygulama genelinde tutarlı kullanmaktır.

Request hash, yanlış içeriğin eski cevapla eşleştirilmesini engeller. Sıradaki durum, aynı anahtar ilk işlem hâlâ devam ederken yeniden gönderildiğinde ne yapılacağıdır.

12. Devam Eden Bir İşlem Tekrar İstenirse Ne Olur?

Aynı Idempotency-Key ile ikinci istek, ilk işlem tamamlanmadan sunucuya ulaşabilir. Bu isteğin davranışı transaction tasarımına bağlıdır.

Bu Yazıdaki Tek Transaction Yaklaşımı

Bizim örneğimizde idempotency kaydı, sipariş ve kayıtlı cevap aynı transaction içindedir:

BEGIN
  ↓
key-1 PROCESSING olarak eklendi
  ↓
Sipariş oluşturuldu
  ↓
key-1 COMPLETED yapıldı
  ↓
COMMIT

Transaction tamamlanmadan PROCESSING kaydı diğer transaction’lar tarafından görünmez. İkinci istek aynı anahtarı eklemeye çalıştığında PostgreSQL ilk transaction’ın sonucunu bekleyebilir.

  • İlk transaction COMMIT ederse ikinci istek anahtarı kazanamaz, tamamlanmış kaydı okur ve önceki cevabı döndürür.
  • İlk transaction ROLLBACK ederse idempotency kaydı da geri alınır; ikinci istek anahtarı kazanıp devam edebilir.

Kısa ve yalnızca veritabanı işlemlerinden oluşan akışlarda bu yaklaşım basittir. Normal akışta kalıcı bir PROCESSING kaydı bırakmaz.

Uzun Süren İşlemler

Ödeme servisi çağrısı, dosya işleme veya rapor üretme gibi uzun işlemler boyunca PostgreSQL transaction’ını açık tutmak bağlantıların ve kilitlerin gereksiz yere tutulmasına neden olur. Böyle durumlarda PROCESSING kaydı kısa bir transaction ile önce commit edilebilir; iş tamamlandığında ikinci kısa transaction kaydı COMPLETED yapar.

Bu tasarımda tekrar gelen istek commit edilmiş PROCESSING kaydını görebilir ve yeni işlem başlatmadan 409 Conflict ya da API sözleşmesinde belirlenen başka bir “işlem sürüyor” cevabı alabilir.

Ancak uygulama işlem sırasında kapanırsa kayıt PROCESSING durumunda takılı kalabilir. processing_started_at veya locked_until gibi alanlarla eski kayıtlar tespit edilebilir; yine de işlem yeniden başlatılmadan önce harici sistemde tamamlanıp tamamlanmadığı kontrol edilmelidir. Özellikle ödeme gibi yan etkilerde yalnızca süre aşımına bakarak yeniden çalıştırmak çift işleme yol açabilir.

Bu yazıdaki sipariş örneği yalnızca PostgreSQL değişikliklerinden oluştuğu için tek transaction yaklaşımını kullanmaya devam edeceğiz.

13. Idempotency Key Ne Kadar Süre Saklanmalıdır?

Idempotency kayıtlarını süresiz saklamak gerekmez. Ancak kayıt silindiğinde sunucu eski bir isteği artık tanıyamaz. Bu nedenle saklama süresi, API’nin sağladığı güvenli retry süresini de belirler.

Her uygulama için geçerli tek bir süre yoktur. Süre belirlenirken istemcinin retry politikası, işlemin çift gerçekleşme riski ve saklanan veri miktarı dikkate alınmalıdır.

Örneğimizde 24 saatlik bir süre kullanıyoruz:

expires_at = NOW() + INTERVAL '24 hours'

Bu evrensel bir standart değildir. Örneğin Stripe da idempotency kayıtlarını en az 24 saat sakladıktan sonra temizleyebileceğini belirtir. Uygulamalar kendi ihtiyaçlarına göre farklı bir süre belirleyebilir.

expires_at zamanının geçmesi kaydı PostgreSQL’den otomatik olarak silmez. Süresi dolan kayıtlar zamanlanmış bir görevle temizlenmelidir:

DELETE FROM idempotency_keys
WHERE expires_at < NOW()
  AND status IN ('COMPLETED', 'FAILED');

Aktif bir PROCESSING kaydı doğrudan silinmemelidir. Aksi hâlde başka bir istek aynı anahtarı kazanarak işlemi yeniden başlatabilir.

Kayıt silindikten sonra eski istek aynı anahtarla tekrar gönderilirse sunucu bunu yeni bir işlem olarak değerlendirebilir:

key-1 → Sipariş 101 oluşturuldu
key-1 kaydı silindi
key-1 tekrar geldi
key-1 → Sipariş 102 oluşturulabilir

Bu nedenle API dokümantasyonunda anahtarın ne kadar süre geçerli olduğu açıkça belirtilmelidir. İstemci de yeni bir mantıksal işlem için eski anahtarı yeniden kullanmamalıdır.

Ayrıca response_body kişisel veya hassas veri içerebileceği için yalnızca yeniden cevap vermek için gerekli bilgiler saklanmalıdır.

Bir sonraki bölümde idempotency mekanizmasının hangi problemleri çözmediğini ve neden “exactly once” garantisi olarak değerlendirilmemesi gerektiğini ele alacağız.

14. Idempotency Neleri Çözmez?

Farklı Anahtarlarla Gönderilen Tekrarları Engellemez

Aynı sipariş içeriği farklı anahtarlarla gönderilirse iki ayrı işlem olarak kabul edilir:

key-1 + aynı sipariş → Sipariş 101
key-2 + aynı sipariş → Sipariş 102

Idempotency mekanizması, iki farklı anahtarın aslında aynı kullanıcı niyetini temsil ettiğini anlayamaz.

Bu nedenle “bir kullanıcı aynı kampanyadan yalnızca bir kez yararlanabilir” gibi iş kuralları ayrıca veritabanı constraint’leriyle korunmalıdır.

Diğer Race Condition Türlerini Çözmez

Idempotency key yalnızca yinelenen isteği tanır. Aynı stok kaydını güncelleyen farklı siparişlerin oluşturduğu concurrency problemini çözmez:

Sipariş A → Stok azaltıyor
Sipariş B → Aynı stoğu azaltıyor

Bu tür durumlarda ayrıca aşağıdaki yöntemlerden biri gerekebilir:

  • Atomic condition update
  • Optimistic locking
  • Pessimistic locking
  • Veritabanı constraint’leri

Bu konu önceki TOCTOU ve race condition yazısında ele aldığımız veri tutarlılığı probleminden farklı değildir.

Authentication ve Authorization Sağlamaz

Idempotency key bir kimlik doğrulama bilgisi değildir:

Idempotency-Key: key-1

Bu değer kullanıcının kim olduğunu veya ilgili sipariş üzerinde işlem yapma yetkisine sahip olduğunu kanıtlamaz.

Authentication ve authorization kontrolleri normal şekilde uygulanmaya devam etmelidir. Idempotency kaydı da kimliği doğrulanmış kullanıcıyla ilişkilendirilmelidir.

Harici Servisler PostgreSQL Transaction’ına Dâhil Değildir

PostgreSQL transaction’ı yalnızca aynı veritabanındaki işlemleri geri alabilir. Bir ödeme servisine gönderilen HTTP isteğini ROLLBACK ile geri alamaz.

Aşağıdaki senaryoyu düşünelim:

PostgreSQL transaction başladı
        ↓
Ödeme servisi ödemeyi aldı
        ↓
Uygulama kapandı
        ↓
PostgreSQL transaction rollback oldu

Yerel veritabanında ödeme kaydı bulunmayabilir ancak harici ödeme servisi işlemi tamamlamış olabilir. Retry sırasında ödeme yeniden gönderilirse kullanıcıdan ikinci kez ücret alınabilir.

Harici servis idempotency desteği sunuyorsa aynı mantıksal işlem için o servise de aynı veya ilişkilendirilmiş bir idempotency key gönderilmelidir.

Daha karmaşık sistemlerde ayrıca şu yöntemler gerekebilir:

  • Transactional outbox
  • Mesaj kuyruğunda duplicate kontrolü
  • Saga veya telafi işlemleri
  • Harici servisle reconciliation

Bunlar yerel PostgreSQL transaction’ının kapsamının dışındadır.

Anahtar Silindikten Sonra Koruma Devam Etmez

Idempotency kaydının saklama süresi dolup kayıt silinirse eski bir retry yeni işlem olarak kabul edilebilir:

key-1 kaydı mevcut → Tekrar engellenir
key-1 kaydı silindi → Aynı istek yeniden çalışabilir

Bu nedenle idempotency, sonsuza kadar geçerli bir garanti değildir. Koruma yalnızca tanımlanan saklama süresi boyunca devam eder.

“Exactly Once” Garantisi Vermez

Dağıtık sistemlerde isteğin yalnızca bir kez iletilmesini ve bütün yan etkilerin kesin olarak bir kez gerçekleşmesini garanti etmek zordur.

Idempotency key için daha doğru ifade şudur:

Aynı kapsam ve saklama süresi içinde, aynı anahtarla gelen tekrarların yeni bir yan etki oluşturmasını engellemeye çalışır.

Tamamen PostgreSQL içinde gerçekleşen sipariş oluşturma örneğimizde transaction ve UNIQUE constraint güçlü bir koruma sağlar. Ancak harici servisler, farklı anahtarlar, süresi dolan kayıtlar ve uygulama hataları bu korumanın sınırları dışındadır.

Dolayısıyla idempotency key önemli bir güvenlik katmanıdır ancak bütün tutarlılık problemlerinin yerine geçen genel bir çözüm değildir.

Son olarak yazının temel çıkarımlarını toparlayalım.

15. Sonuç

Bir POST isteğinin timeout ile sonuçlanması, sunucudaki işlemin başarısız olduğu anlamına gelmez. Sipariş oluşturulmuş ancak cevap istemciye ulaşmamış olabilir. İstemci aynı isteği tekrar gönderdiğinde sunucu bunu yeni bir işlem olarak değerlendirirse ikinci bir sipariş oluşabilir.

Idempotency-Key, birden fazla HTTP isteğinin aynı mantıksal işleme ait olduğunu sunucuya bildirmemizi sağlar:

Yeni sipariş → Yeni idempotency key
Aynı siparişin retry isteği → Aynı idempotency key

Ancak yalnızca header göndermek yeterli değildir. Güvenli bir uygulama için:

  • Anahtar kullanıcı ve operasyonla ilişkilendirilmelidir.
  • Benzersizlik PostgreSQL UNIQUE constraint ile korunmalıdır.
  • Anahtar INSERT ... ON CONFLICT ile atomik olarak alınmalıdır.
  • Sipariş ve idempotency sonucu aynı transaction içinde kaydedilmelidir.
  • Aynı anahtarın farklı request body ile kullanılması request hash üzerinden reddedilmelidir.
  • Tamamlanan işlemin durum kodu ve response body saklanmalıdır.

Böylece ilk cevap kaybolsa bile retry isteği yeni bir sipariş oluşturmaz:

Birinci istek
    ↓
Sipariş 101 oluşturuldu
    ↓
Cevap kayboldu
    ↓
Aynı key ile retry
    ↓
Sipariş 101'in kayıtlı cevabı döndürüldü

Idempotency, bütün veri tutarlılığı problemlerini çözmez ve dağıtık sistem genelinde kesin bir “exactly once” garantisi sağlamaz. Koruma yalnızca tanımlanan kullanıcı, operasyon, anahtar ve saklama süresi kapsamında geçerlidir. Harici ödeme servisleri gibi PostgreSQL transaction’ının dışındaki sistemler için ayrıca idempotency ve hata kurtarma mekanizmaları gerekir.

Doğru uygulandığında idempotency key, ağ hataları ve retry davranışları karşısında POST endpoint’lerini daha güvenli ve öngörülebilir hâle getirir.

16. Kaynakça ve İleri Okuma