API Tasarımında Idempotency: Güvenli İstek Yönetimi

Bir production sisteminde ödeme API’si çalıştırıyorsanız ve kullanıcı “Satın Al” butonuna iki kez bastığında ne olduğunu hiç düşündünüz mü? Ya da ağ kesintisi nedeniyle istek yarıda kalırsa? İşte tam bu noktada idempotency devreye girer ve bu konuyu anlamadan REST API tasarımı yapmak, güvenlik kemeri takmadan araç kullanmak gibidir.

Idempotency Nedir?

Matematikten gelen bu kavram, bir operasyonun bir kez veya birden fazla kez uygulanmasının aynı sonucu vermesi anlamına gelir. API dünyasına çevirdiğimizde: aynı isteği kaç kez gönderirseniz gönderin, sistem durumu değişmez ve aynı yanıtı alırsınız.

HTTP metodları açısından bakarsak:

  • GET: Doğası gereği idempotent. Veri okur, değiştirmez.
  • PUT: İdempotent olmalıdır. Aynı kaynağı aynı veriyle güncellemek her zaman aynı sonucu verir.
  • DELETE: İdempotent olmalıdır. Silinmiş bir kaynağı tekrar silmeye çalışmak 404 döndürür ama sistem durumu değişmez.
  • POST: Doğası gereği idempotent değildir. Her çağrı yeni kaynak oluşturabilir.
  • PATCH: Genellikle idempotent değildir, içeriğe bağlıdır.

Sorun şu ki POST metodunu idempotent hale getirmek zorunda kaldığımız senaryolar var ve bu senaryolar production’da en çok baş ağrısı yaratan durumlar.

Neden Bu Kadar Önemli?

Gerçek dünyadan bir örnek düşünelim. E-ticaret platformunuzda kullanıcı ödeme yapıyor. İstek sunucuya ulaştı, ödeme işlendi, database’e yazılıyor… tam bu sırada bağlantı koptu. Kullanıcı tarafında timeout aldı. Kullanıcı sayfayı yeniledi ve tekrar denedi. Peki ödeme bir kez mi alındı yoksa iki kez mi?

Bu senaryo her gün binlerce sistemde yaşanıyor. Idempotency olmadan:

  • Çift ödeme alma
  • Çift sipariş oluşturma
  • Duplicate email gönderimi
  • Stok tutarsızlıkları
  • Kullanıcı şikayetleri ve chargeback’ler

gibi sorunlarla karşılaşırsınız.

Idempotency Key Yaklaşımı

En yaygın çözüm, her istek için benzersiz bir anahtar kullanmaktır. Stripe bu yaklaşımı mükemmel şekilde uyguluyor ve biz de aynı pattern’i kendi sistemlerimizde kullanabiliriz.

Temel mantık şu: İstemci her kritik istek için benzersiz bir UUID üretir ve bunu header’a ekler. Sunucu bu anahtarı görünce önce cache’e bakar, daha önce işlenmiş mi diye kontrol eder.

# İstemci tarafından idempotency key oluşturma
curl -X POST https://api.example.com/payments 
  -H "Content-Type: application/json" 
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" 
  -d '{
    "amount": 150.00,
    "currency": "TRY",
    "customer_id": "cust_123"
  }'

Sunucu tarafında bu anahtarı nasıl yönetirsiniz? Python/Flask ile basit bir örnek:

# Redis kurulumu ve idempotency için temel yapı testi
redis-cli SET "idempotency:550e8400-e29b-41d4-a716-446655440000" 
  '{"status": "completed", "response": {"payment_id": "pay_abc123"}}' 
  EX 86400

# Anahtarın var olup olmadığını kontrol et
redis-cli EXISTS "idempotency:550e8400-e29b-41d4-a716-446655440000"
# Çıktı: 1 (var) veya 0 (yok)

# Saklanan yanıtı al
redis-cli GET "idempotency:550e8400-e29b-41d4-a716-446655440000"

Python ile Middleware Implementasyonu

Gerçek bir Flask uygulamasında idempotency middleware nasıl yazılır:

# Gerekli paketleri kur
pip install flask redis uuid

# Uygulama yapısını oluştur
mkdir -p idempotency_demo/{middleware,routes,tests}
touch idempotency_demo/middleware/idempotency.py
touch idempotency_demo/app.py

Middleware kodu:

# idempotency.py içeriğini oluştur
cat > idempotency_demo/middleware/idempotency.py << 'EOF'
import json
import redis
import hashlib
from functools import wraps
from flask import request, jsonify, g

redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
IDEMPOTENCY_TTL = 86400  # 24 saat

def idempotent(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        idempotency_key = request.headers.get('Idempotency-Key')
        
        if not idempotency_key:
            return jsonify({"error": "Idempotency-Key header zorunludur"}), 400
        
        # Key formatını dogrula (UUID v4)
        import re
        uuid_pattern = re.compile(
            r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
            re.IGNORECASE
        )
        if not uuid_pattern.match(idempotency_key):
            return jsonify({"error": "Gecersiz Idempotency-Key formati"}), 400
        
        cache_key = f"idempotency:{idempotency_key}"
        
        # Onceki yaniti kontrol et
        cached_response = redis_client.get(cache_key)
        if cached_response:
            response_data = json.loads(cached_response)
            response = jsonify(response_data['body'])
            response.status_code = response_data['status_code']
            response.headers['X-Idempotency-Replayed'] = 'true'
            return response
        
        # Islemi gerceklestir
        result = f(*args, **kwargs)
        
        # Yaniti cache'e kaydet
        if hasattr(result, 'get_json'):
            cache_data = {
                'body': result.get_json(),
                'status_code': result.status_code
            }
            redis_client.setex(cache_key, IDEMPOTENCY_TTL, json.dumps(cache_data))
        
        return result
    return decorated_function
EOF
echo "Middleware olusturuldu"

Race Condition Problemi ve Cözümü

İdempotency implementasyonunda en sık atlanan problem: aynı key ile iki istek tam aynı anda gelirse ne olur? İkisi de cache’de bulamaz, ikisi de işleme girer ve double processing yaşarsınız. Bunu önlemek için distributed lock kullanmalısınız.

# Redis ile distributed lock implementasyonu test etme
# SET NX EX kombinasyonu atomic lock saglar

# Lock al (NX: sadece yoksa yaz, EX: 30 saniye TTL)
redis-cli SET "lock:idempotency:550e8400-e29b-41d4-a716-446655440000" 
  "worker-1" NX EX 30

# Eger baska bir worker bu lock'u almaya calisirsa
redis-cli SET "lock:idempotency:550e8400-e29b-41d4-a716-446655440000" 
  "worker-2" NX EX 30
# Cikti: (nil) - lock alinamadi, baskasi isliyor

# Lock sahipligini kontrol et
redis-cli GET "lock:idempotency:550e8400-e29b-41d4-a716-446655440000"
# Cikti: "worker-1"

# Islem bittikten sonra lock'u serbest birak (sadece sahip serbest birakabilir)
redis-cli EVAL "
  if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
  else
    return 0
  end
" 1 "lock:idempotency:550e8400-e29b-41d4-a716-446655440000" "worker-1"

Database Seviyesinde Idempotency

Redis olmayan veya Redis’e güvenemeceğiniz durumlarda, database seviyesinde de idempotency sağlayabilirsiniz. Bu yaklaşım daha dayanıklıdır çünkü transaction’larla birlikte çalışır.

# PostgreSQL idempotency tablosu olustur
psql -U postgres -d myapp << 'EOF'
CREATE TABLE IF NOT EXISTS idempotency_keys (
    key VARCHAR(255) PRIMARY KEY,
    request_hash VARCHAR(64) NOT NULL,
    response_body JSONB,
    response_status INTEGER,
    created_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP DEFAULT NOW() + INTERVAL '24 hours',
    locked_at TIMESTAMP
);

-- Süresi geçmiş kayıtları temizlemek için index
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);

-- Otomatik temizlik için cron job benzeri scheduled deletion
CREATE OR REPLACE FUNCTION cleanup_expired_idempotency_keys()
RETURNS INTEGER AS $$
DECLARE
    deleted_count INTEGER;
BEGIN
    DELETE FROM idempotency_keys WHERE expires_at < NOW();
    GET DIAGNOSTICS deleted_count = ROW_COUNT;
    RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
EOF

echo "Idempotency tablosu ve cleanup fonksiyonu olusturuldu"

Database transaction ile birlikte kullanımı:

# Transaction icinde idempotency kontrolu
psql -U postgres -d myapp << 'EOF'
BEGIN;

-- Lock ile idempotency key'i kaydet (ya da mevcut yaniti al)
INSERT INTO idempotency_keys (key, request_hash, locked_at)
VALUES (
    '550e8400-e29b-41d4-a716-446655440000',
    md5('{"amount":150,"currency":"TRY"}'),
    NOW()
)
ON CONFLICT (key) DO UPDATE
    SET locked_at = EXCLUDED.locked_at
    WHERE idempotency_keys.response_body IS NULL
RETURNING key, response_body, response_status;

-- Eger response_body dolu gelirse onceki yanit kullanilacak
-- Bos gelirse islem gerceklestirilecek

COMMIT;
EOF

İstemci Tarafı Retry Stratejisi

İdempotency’nin sadece sunucu tarafı bir problem olmadığını vurgulamak gerekiyor. İstemci tarafında doğru retry mekanizması kurmazsanız, idempotency anahtarını yanlış kullanarak sorunları büyütebilirsiniz.

Kritik kural: Aynı işlemin retry’ında her zaman aynı idempotency key kullanılmalıdır. Farklı key kullanmak, işlemi yeni bir işlem olarak değerlendirir.

# Bash ile retry mekanizmasi ornegi
#!/bin/bash

IDEMPOTENCY_KEY=$(uuidgen)
MAX_RETRIES=3
RETRY_DELAY=2
PAYMENT_DATA='{"amount": 150.00, "currency": "TRY", "customer_id": "cust_123"}'

make_payment() {
    local attempt=$1
    echo "Deneme $attempt - Key: $IDEMPOTENCY_KEY"
    
    HTTP_RESPONSE=$(curl -s -w "n%{http_code}" 
        -X POST https://api.example.com/payments 
        -H "Content-Type: application/json" 
        -H "Idempotency-Key: $IDEMPOTENCY_KEY" 
        -H "Authorization: Bearer $API_TOKEN" 
        --connect-timeout 10 
        --max-time 30 
        -d "$PAYMENT_DATA")
    
    HTTP_BODY=$(echo "$HTTP_RESPONSE" | head -n 1)
    HTTP_CODE=$(echo "$HTTP_RESPONSE" | tail -n 1)
    
    echo "HTTP Status: $HTTP_CODE"
    
    case $HTTP_CODE in
        200|201)
            echo "Basarili: $HTTP_BODY"
            return 0
            ;;
        409)
            # Conflict - islem zaten isleniyor, bekle
            echo "Islem isleniyor, bekleniyor..."
            sleep $((RETRY_DELAY * 2))
            return 1
            ;;
        500|502|503|504)
            # Server hatasi - ayni key ile tekrar dene
            echo "Server hatasi, tekrar denenecek..."
            return 1
            ;;
        400|401|403|422)
            # Client hatasi - retry yapma
            echo "Client hatasi, islem iptal: $HTTP_BODY"
            return 2
            ;;
    esac
}

# Retry dongusu
for i in $(seq 1 $MAX_RETRIES); do
    make_payment $i
    EXIT_CODE=$?
    
    if [ $EXIT_CODE -eq 0 ]; then
        echo "Islem tamamlandi"
        break
    elif [ $EXIT_CODE -eq 2 ]; then
        echo "Kurtarilamaz hata, cikiliyor"
        exit 1
    fi
    
    if [ $i -lt $MAX_RETRIES ]; then
        SLEEP_TIME=$((RETRY_DELAY * i))
        echo "$(($MAX_RETRIES - $i)) deneme kaldi, $SLEEP_TIME saniye bekleniyor..."
        sleep $SLEEP_TIME
    fi
done

Monitoring ve Alerting

Idempotency sisteminizin sağlıklı çalışıp çalışmadığını izlemeniz gerekir. Bazı kritik metrikler:

  • Replay oranı: Hangi sıklıkla aynı key tekrar geliyor?
  • Lock çakışma sayısı: Race condition ne sıklıkla oluşuyor?
  • Cache miss sonrası başarısız işlem: İlk işlem başarılı oldu mu?
# Prometheus metrics endpoint'i icin bash script ornegi
# Idempotency metriklerini Redis'ten topla ve expose et

#!/bin/bash
cat > /usr/local/bin/idempotency-metrics.sh << 'SCRIPT'
#!/bin/bash

REDIS_HOST="localhost"
REDIS_PORT="6379"

# Toplam aktif idempotency key sayisi
ACTIVE_KEYS=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT 
    KEYS "idempotency:*" | wc -l)

# Toplam lock sayisi
ACTIVE_LOCKS=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT 
    KEYS "lock:idempotency:*" | wc -l)

# Metrikleri Prometheus formatinda yaz
cat << EOF
# HELP idempotency_active_keys Aktif idempotency key sayisi
# TYPE idempotency_active_keys gauge
idempotency_active_keys $ACTIVE_KEYS

# HELP idempotency_active_locks Aktif lock sayisi
# TYPE idempotency_active_locks gauge
idempotency_active_locks $ACTIVE_LOCKS
EOF
SCRIPT

chmod +x /usr/local/bin/idempotency-metrics.sh

# Cron ile her dakika calistir
echo "* * * * * root /usr/local/bin/idempotency-metrics.sh > 
  /var/lib/node_exporter/textfile_collector/idempotency.prom" 
  >> /etc/cron.d/idempotency-metrics

Sık Yapılan Hatalar

Sistemleri incelerken tekrar tekrar gördüğüm hatalar:

  • Her endpoint’e idempotency ekleme: GET istekleri zaten idempotent. Sadece durum değiştiren operasyonlar için gerekli.
  • Request body’yi hash’lemeden saklamak: Aynı key farklı body ile gelirse ne yapacaksınız? Request hash’i de saklayın ve karşılaştırın.
  • TTL’siz cache: Sonsuz büyüyen bir Redis yaratırsınız. 24 saat genellikle yeterli.
  • Lock’suz implementasyon: Race condition kaçınılmaz hale gelir.
  • İstemciye yeterli bilgi vermemek: X-Idempotency-Replayed: true header’ı istemciye bu yanıtın cache’den geldiğini söyler.
  • Partial failure durumunu atlamak: İşlem yarıda kaldıysa ve key kayıtlıysa ne olur? Bu durumu açıkça handle edin.

Nginx ile Rate Limiting Entegrasyonu

Idempotency sisteminizi Nginx seviyesinde de destekleyebilirsiniz. Aynı idempotency key’den saniyede onlarca istek geliyorsa, bunları uygulama katmanına taşımadan önce sınırlamalısınız.

# /etc/nginx/conf.d/api-idempotency.conf

# Idempotency key bazli rate limiting zone
limit_req_zone $http_idempotency_key zone=idempotency_per_key:10m rate=5r/s;

# IP bazli genel rate limiting
limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=100r/m;

server {
    listen 443 ssl;
    server_name api.example.com;

    location /payments {
        # Idempotency key varsa key bazli, yoksa IP bazli limit uygula
        limit_req zone=idempotency_per_key burst=3 nodelay;
        limit_req zone=api_per_ip burst=20 nodelay;
        
        # Idempotency key header'ini backend'e ilet
        proxy_pass http://payment_backend;
        proxy_set_header Idempotency-Key $http_idempotency_key;
        
        # Timeout ayarlari - kritik
        proxy_connect_timeout 5s;
        proxy_send_timeout 30s;
        proxy_read_timeout 30s;
    }
}
# Nginx konfigurasyonunu test et ve reload et
nginx -t && systemctl reload nginx

# Idempotency key ile test istegi at
curl -v -X POST https://api.example.com/payments 
  -H "Idempotency-Key: $(uuidgen)" 
  -H "Content-Type: application/json" 
  -d '{"amount": 100, "currency": "TRY"}'

Cleanup ve Maintenance

Production sistemlerde idempotency store’unun bakımını ihmal etmeyin. Süresi dolmuş kayıtları temizlemek hem storage’ı optimize eder hem de lookup performansını korur.

# Idempotency kayitlarini temizleyen maintenance script
#!/bin/bash

LOG_FILE="/var/log/idempotency-cleanup.log"
DATE=$(date '+%Y-%m-%d %H:%M:%S')

echo "[$DATE] Temizlik basliyor..." >> $LOG_FILE

# Redis'te süresi dolmus kayitlari say (TTL zaten var, bu rapor icin)
EXPIRED_COUNT=$(redis-cli KEYS "idempotency:*" | while read key; do
    TTL=$(redis-cli TTL "$key")
    [ "$TTL" -eq -1 ] && echo "$key"  # TTL'siz kayitlar sorunlu
done | wc -l)

echo "[$DATE] TTL'siz kayit sayisi: $EXPIRED_COUNT" >> $LOG_FILE

# TTL'siz kayitlar varsa uyar (bu olmamali)
if [ "$EXPIRED_COUNT" -gt 0 ]; then
    echo "[$DATE] UYARI: $EXPIRED_COUNT kayit TTL olmadan bulundu!" >> $LOG_FILE
    # Alert gonder
    curl -s -X POST "$SLACK_WEBHOOK_URL" 
        -d "{"text": "Idempotency uyarisi: $EXPIRED_COUNT kayit TTL olmadan!"}"
fi

# PostgreSQL'deki süresi dolmus kayitlari temizle
if command -v psql &> /dev/null; then
    DELETED=$(psql -U postgres -d myapp -t -c 
        "SELECT cleanup_expired_idempotency_keys();")
    echo "[$DATE] PostgreSQL'den silinen kayit: $DELETED" >> $LOG_FILE
fi

echo "[$DATE] Temizlik tamamlandi" >> $LOG_FILE
# Cron job olarak kaydet - her saat calistir
echo "0 * * * * root /usr/local/bin/idempotency-cleanup.sh" 
    > /etc/cron.d/idempotency-cleanup

# Script'e calistirma izni ver
chmod +x /usr/local/bin/idempotency-cleanup.sh

Sonuç

Idempotency, bir API’nin güvenilirliğini belirleyen temel taşlardan biri. Para işlemleri, sipariş oluşturma, bildirim gönderme gibi kritik operasyonlarda uygulamak artık bir tercih değil, zorunluluk.

Kendi projelerinizde başlangıç için şu sırayı öneririm:

  • Önce hangi endpoint’lerin idempotent olması gerektiğini belirleyin
  • Redis ile basit bir cache implementasyonu yapın
  • Distributed lock mekanizmasını ekleyin
  • İstemci tarafında doğru retry stratejisi kurun
  • Monitoring ekleyin ve metrikleri takip edin

Bu sistemi bir kez doğru kurduğunuzda, “ödeme iki kez çekildi” veya “sipariş çift oluştu” şikayetleri geçmişte kalır. Ve inanın, bu şikayetleri production’da bir kez yaşamak, idempotency’yi öğrenmek için en etkili ama en pahalı yoldur. Biz size bu dersin kolayını gösterdik.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir