Skip to content
~/sph.sh

Idempotency: API'lerde Güvenli Retry için Başlangıç Rehberi

API, ödeme akışı ve mesaj tüketicisi geliştiren yazılımcılar için idempotency'ye pratik bir giriş. HTTP metot semantiği, idempotency key'leri, veritabanı upsert ve yaygın tuzakları çalışan Node.js örnekleriyle anlatır.

Ağ bağlantıları kopar. İstekler zaman aşımına uğrar. Client'lar retry yapar. API'niz gerçek yeni bir istek ile önceki bir isteğin retry'ı arasındaki farkı anlayamıyorsa kullanıcılarınız iki kez ücretlendirilir, siparişleri tekrarlanır ve destek kutunuz dolar.

Idempotency, retry'ları güvenli hale getiren özelliktir. Bu rehber ne anlama geldiğini, neden önemli olduğunu ve gerçek kodda nasıl uygulanacağını anlatıyor.

Idempotency Ne Demek

Bir işlem idempotent ise, onu birden fazla kez çalıştırmak bir kez çalıştırmakla aynı sonucu üretir. Matematiksel tanımı f(f(x)) = f(x). Pratikte: aynı endpoint'i aynı girdiyle iki kez çağırırsanız sistem, tek çağırmışsınız gibi aynı duruma varır.

İncelik burada. Idempotency, duplicate isteklerin sisteme ulaşmasını engellemek değildir. Duplicate isteklerin duplicate etki oluşturmamasını sağlamaktır. Ağ, siz istesiniz ya da istemeyin retry yapacak. Sizin göreviniz buna tolerans göstermek.

Üç İ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'tır. Her idempotent işlem safe değildir.

Neden Önemli: Ç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:

MetotSafeIdempotentTipik Kullanım
GETEvetEvetVeri okuma
PUTHayırEvetTam değiştirme
DELETEHayırEvetKaynak silme
POSTHayırHayırYeni kaynak oluşturma
PATCHHayırDuruma göreKı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, 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 otomatik retry edebilir, güvenli olduklarını varsayarlar. PUT uygun olan yerde POST kullanırsanız bu garantiyi kaybedersiniz.

Idempotency Key Deseni

Bu, POST işlemlerini idempotent yapmanın standart yolu ve Stripe tarafından yaygınlaştırıldı.

Nasıl Çalışır

  1. Client, isteği göndermeden önce bir UUID üretir.
  2. Client onu bir Idempotency-Key header'ında gönderir.
  3. Sunucu, hızlı bir store'da (Redis, DynamoDB) bu key'i arar.
  4. Key yeniyse sunucu isteği işler ve tam yanıtı TTL ile saklar.
  5. Key zaten varsa sunucu, iş mantığını yeniden çalıştırmadan saklanan yanıtı döndürür.

Minimal Bir Express Uygulaması

typescript
import express, { Request, Response, NextFunction } from "express";import { createClient } from "redis";import { randomUUID } from "crypto";
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ı

typescript
import { randomUUID } from "crypto";
async function chargeCustomer(amount: number) {  const idempotencyKey = 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

sql
INSERT INTO orders (id, user_id, amount, created_at)VALUES ($1, $2, $3, NOW())ON CONFLICT (id) DO NOTHINGRETURNING *;

Ç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

typescript
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 exactly-once değil, 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 Bir Mit

Dağıtık sistemlerde exactly-once teslimat imkansızdır. Bu teorik bir sonuç (İki Generaller Problemi, daha fazlası için okuyabilirsiniz). Başarabileceğiniz şey, at-least-once teslimat ile idempotent handler'ları birleştirerek exactly-once işleme:

at-least-once teslimat + idempotent isleme = etkili 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ç 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-Key header'lı POST kullanın.
  • Mesaj kuyruğu tüketicisi: dedup tablosu ya da outbox deseni, her zaman.
  • Ödeme, sipariş, e-posta: idempotency key'leri pazarlık dışıdır.

Sonuç

Idempotency, ölçeklenmeye başladığınızda öğreneceğiniz ileri bir konu değil. Retry mantığı olan her API için temel bir gereksinimdir ki bu da her API demek. HTTP metotlarını doğru seçin, gerçek dünya yan etkileri olan POST endpoint'lerine idempotency key ekleyin, mümkün olan yerde veritabanı constraint'lerini kullanın ve kuyruk tüketicilerinizi replay'leri tolere edecek şekilde tasarlayın.

Desenler karmaşık değil. Önemli olan, ilk çift ücretlendirmenizden sonra değil, öncesinde bunları bilinçli uygulamak.

Kaynaklar

İlgili Yazılar