Idempotent Webhook İşleme: Tekrar Eden İstekleri Yönetme

Üretim ortamında bir webhook altyapısı kurduğunuzda, en sık karşılaşılan ve en sinir bozucu problemlerden biri şudur: aynı event birden fazla kez işleniyor. Ödeme tamamlandı bildirimi iki kez geliyor, kullanıcı iki kez oluşturuluyor ya da stok iki kez düşürülüyor. Bu durum hem veri tutarsızlığına hem de ciddi iş kayıplarına neden olabilir. İşte bu noktada idempotent webhook işleme kavramı devreye giriyor.

Idempotency Nedir ve Neden Webhook’larda Kritiktir

Matematikten gelen bu kavram, basitçe şu anlama gelir: aynı işlemi kaç kez yaparsanız yapın, sonuç değişmez. HTTP metodları açısından düşündüğünüzde GET ve DELETE doğaları gereği idempotent’tir. POST ise değildir. Webhook’lar da genellikle POST üzerinden çalıştığı için, tekrar eden istekleri yönetmek tamamen sizin sorumluluğunuzdadır.

Peki neden webhook’lar tekrar eder? Birkaç temel neden var:

  • Ağ zaman aşımları: Gönderen sistem, webhook endpoint’inizden 200 OK alamazsa isteği yeniden gönderir
  • Provider tarafı retry mekanizmaları: Stripe, GitHub, Shopify gibi platformlar başarısız teslimleri otomatik olarak yeniden dener
  • At-least-once delivery garantisi: Çoğu webhook sistemi “en az bir kez iletilir” garantisi verir, “tam olarak bir kez” değil
  • Ağınızdaki geçici hatalar: Load balancer yeniden yönlendirme, pod restart gibi durumlar

Gerçek hayattan bir senaryo düşünelim: E-ticaret sitenize entegre ettiğiniz Stripe, bir ödeme başarıyla tamamlandığında payment_intent.succeeded event’ini gönderir. Endpoint’iniz isteği aldı, siparişi oluşturdu, ancak veritabanı işlemi yavaş olduğu için Stripe’a 200 dönemedi ve timeout aldı. Stripe aynı event’i 5 dakika sonra tekrar gönderir. Artık aynı sipariş iki kez oluşturulmuş olur.

Temel Kavramlar: Idempotency Key ve Event ID

Her webhook isteğinde bir benzersiz kimlik bulunur. Bu kimliği doğru şekilde kullanmak idempotency’nin temelidir.

Stripe örneğinde her event’in bir id alanı vardır:

# Stripe webhook payload örneği
curl -X POST https://yoursite.com/webhooks/stripe 
  -H "Content-Type: application/json" 
  -H "Stripe-Signature: t=1614556996,v1=abc123..." 
  -d '{
    "id": "evt_1J2Y3K4L5M6N7O8P",
    "type": "payment_intent.succeeded",
    "data": {
      "object": {
        "id": "pi_1J2Y3K4L5M6N7O8P",
        "amount": 9900,
        "currency": "try"
      }
    }
  }'

GitHub webhook’larında ise X-GitHub-Delivery header’ı kullanılır:

# GitHub webhook header kontrolü
X-GitHub-Event: push
X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958
X-Hub-Signature-256: sha256=abc123def456...

Bu ID’leri işleme almadan önce bir veritabanında veya önbellekte kontrol etmek, tekrar eden işlemlerin önüne geçmenin en güvenilir yoludur.

Veritabanı Tabanlı Idempotency

En sağlam yöntemlerden biri, işlenen webhook event’lerini bir veritabanı tablosunda saklamaktır.

# PostgreSQL'de webhook_events tablosu oluşturma
psql -U postgres -d myapp << 'EOF'
CREATE TABLE webhook_events (
    id SERIAL PRIMARY KEY,
    event_id VARCHAR(255) UNIQUE NOT NULL,
    provider VARCHAR(50) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload JSONB,
    processed_at TIMESTAMP DEFAULT NOW(),
    status VARCHAR(20) DEFAULT 'processed'
);

CREATE INDEX idx_webhook_events_event_id ON webhook_events(event_id);
CREATE INDEX idx_webhook_events_processed_at ON webhook_events(processed_at);
EOF

Python ile basit bir idempotency kontrolü:

# Python Flask webhook handler - psycopg2 ile
cat > /opt/webhooks/stripe_handler.py << 'EOF'
import psycopg2
import json
from flask import Flask, request, jsonify

app = Flask(__name__)

def get_db_connection():
    return psycopg2.connect(
        host="localhost",
        database="myapp",
        user="webhook_user",
        password="güçlü_şifre_buraya"
    )

def is_event_processed(event_id, provider):
    """Event daha önce işlendi mi kontrol et"""
    conn = get_db_connection()
    cur = conn.cursor()
    try:
        cur.execute(
            "SELECT id FROM webhook_events WHERE event_id = %s AND provider = %s",
            (event_id, provider)
        )
        result = cur.fetchone()
        return result is not None
    finally:
        cur.close()
        conn.close()

def mark_event_processed(event_id, provider, event_type, payload):
    """Event'i işlendi olarak işaretle"""
    conn = get_db_connection()
    cur = conn.cursor()
    try:
        cur.execute(
            """INSERT INTO webhook_events (event_id, provider, event_type, payload)
               VALUES (%s, %s, %s, %s)
               ON CONFLICT (event_id) DO NOTHING""",
            (event_id, provider, event_type, json.dumps(payload))
        )
        conn.commit()
        return cur.rowcount > 0
    finally:
        cur.close()
        conn.close()

@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.get_json()
    event_id = payload.get('id')
    event_type = payload.get('type')

    # Duplicate kontrolü
    if is_event_processed(event_id, 'stripe'):
        app.logger.info(f"Duplicate event tespit edildi: {event_id}, atlanıyor")
        return jsonify({"status": "already_processed"}), 200

    # Event'i işle
    success = process_stripe_event(payload)

    if success:
        mark_event_processed(event_id, 'stripe', event_type, payload)
        return jsonify({"status": "processed"}), 200
    else:
        return jsonify({"status": "error"}), 500

def process_stripe_event(payload):
    event_type = payload.get('type')
    if event_type == 'payment_intent.succeeded':
        # Sipariş oluşturma mantığı buraya
        pass
    return True

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)
EOF

Buradaki kritik nokta ON CONFLICT (event_id) DO NOTHING kısmıdır. Race condition durumunda, iki istek neredeyse eş zamanlı gelirse, veritabanının UNIQUE constraint’i sayesinde yalnızca biri kayıt oluşturabilir.

Redis ile Yüksek Performanslı Idempotency

Veritabanı yaklaşımı güvenilir olsa da yüksek trafikli sistemlerde gecikmeye neden olabilir. Redis, bu durumda çok daha hızlı bir alternatif sunar.

# Redis'e idempotency key kaydetme - bash örneği
REDIS_CLI="redis-cli -h localhost -p 6379"

# Event'in daha önce işlenip işlenmediğini kontrol et
check_event() {
    local event_id=$1
    local provider=$2
    local key="webhook:processed:${provider}:${event_id}"

    result=$($REDIS_CLI GET "$key")
    if [ -n "$result" ]; then
        echo "DUPLICATE"
        return 1
    else
        echo "NEW"
        return 0
    fi
}

# Event'i işlendi olarak işaretle (24 saat TTL ile)
mark_event() {
    local event_id=$1
    local provider=$2
    local key="webhook:processed:${provider}:${event_id}"
    local ttl=86400  # 24 saat

    # NX flag'i: sadece key yoksa set et (atomik işlem)
    result=$($REDIS_CLI SET "$key" "1" NX EX "$ttl")
    if [ "$result" = "OK" ]; then
        echo "MARKED"
        return 0
    else
        echo "ALREADY_EXISTS"
        return 1
    fi
}

# Kullanım
EVENT_ID="evt_1J2Y3K4L5M6N7O8P"
PROVIDER="stripe"

if check_event "$EVENT_ID" "$PROVIDER" | grep -q "DUPLICATE"; then
    echo "Bu event zaten işlendi, atlıyorum"
    exit 0
fi

# İşlemi yap
echo "Event işleniyor..."
# ... işlem mantığı ...

# Başarılıysa işaretle
mark_event "$EVENT_ID" "$PROVIDER"

Redis’teki SET key value NX EX ttl komutu atomik çalışır. NX (Not eXists) flag’i sayesinde race condition olmadan idempotency sağlanır. TTL eklemek de önemlidir; sonsuza kadar veri biriktirmemek için 24-48 saatlik bir süre genellikle yeterlidir.

Nginx ile Webhook Signature Doğrulama

Idempotency’den önce gelen bir katman da signature doğrulamadır. Sahte webhook isteklerini işlemeden önce filtrelemek, hem güvenlik hem de gereksiz işlem yükünü azaltma açısından kritiktir.

# Nginx'te webhook için temel yapılandırma
cat > /etc/nginx/sites-available/webhooks << 'EOF'
server {
    listen 443 ssl;
    server_name webhooks.sirketiniz.com;

    ssl_certificate /etc/letsencrypt/live/webhooks.sirketiniz.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/webhooks.sirketiniz.com/privkey.pem;

    # Webhook boyut limiti - büyük payload'ları reddet
    client_max_body_size 1m;

    location /webhooks/stripe {
        # Rate limiting - aynı IP'den fazla istek gelirse yavaşlat
        limit_req zone=webhook_limit burst=20 nodelay;

        proxy_pass http://localhost:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # Timeout ayarları - webhook provider'ına göre ayarlayın
        proxy_connect_timeout 10s;
        proxy_read_timeout 30s;
        proxy_send_timeout 30s;
    }
}

# Rate limiting zone tanımı - http bloğuna ekleyin
# limit_req_zone $binary_remote_addr zone=webhook_limit:10m rate=30r/m;
EOF

nginx -t && systemctl reload nginx

Stripe signature doğrulama için shell script:

#!/bin/bash
# stripe_signature_check.sh
# Stripe webhook signature'ını doğrula

WEBHOOK_SECRET="whsec_sizin_secret_anahtariniz"
PAYLOAD=$1
STRIPE_SIGNATURE=$2

# Signature header'ı parse et
TIMESTAMP=$(echo "$STRIPE_SIGNATURE" | grep -oP 't=K[^,]+')
V1_SIG=$(echo "$STRIPE_SIGNATURE" | grep -oP 'v1=K[^,]+')

# Beklenen imzayı oluştur
SIGNED_PAYLOAD="${TIMESTAMP}.${PAYLOAD}"
EXPECTED_SIG=$(echo -n "$SIGNED_PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')

# İmzaları karşılaştır
if [ "$V1_SIG" = "$EXPECTED_SIG" ]; then
    echo "VALID"
    exit 0
else
    echo "INVALID"
    exit 1
fi

Queue Tabanlı Yaklaşım: Daha Güçlü Bir Mimari

Büyük ölçekli sistemlerde webhook’ları doğrudan işlemek yerine bir queue’ya atmak ve oradan tüketmek çok daha sağlıklıdır. Bu yaklaşım hem idempotency’yi kolaylaştırır hem de sistemin dayanıklılığını artırır.

# RabbitMQ ile webhook queue yapılandırması
# rabbitmq.conf içine veya management UI üzerinden

cat > /tmp/setup_webhook_queue.sh << 'EOF'
#!/bin/bash

RABBITMQ_HOST="localhost"
RABBITMQ_USER="webhook_user"
RABBITMQ_PASS="güçlü_şifre"
VHOST="webhooks"

# Vhost oluştur
rabbitmqctl add_vhost "$VHOST"
rabbitmqctl set_permissions -p "$VHOST" "$RABBITMQ_USER" ".*" ".*" ".*"

# Exchange ve queue oluştur (management API üzerinden)
BASE_URL="http://localhost:15672/api"
AUTH="-u ${RABBITMQ_USER}:${RABBITMQ_PASS}"

# Dead letter exchange oluştur
curl $AUTH -X PUT "$BASE_URL/exchanges/$VHOST/webhook.dlx" 
  -H "Content-Type: application/json" 
  -d '{"type":"direct","durable":true}'

# Dead letter queue oluştur
curl $AUTH -X PUT "$BASE_URL/queues/$VHOST/webhook.dead" 
  -H "Content-Type: application/json" 
  -d '{"durable":true}'

# Ana webhook queue - dead letter ile
curl $AUTH -X PUT "$BASE_URL/queues/$VHOST/webhook.stripe" 
  -H "Content-Type: application/json" 
  -d '{
    "durable": true,
    "arguments": {
      "x-dead-letter-exchange": "webhook.dlx",
      "x-message-ttl": 86400000,
      "x-max-length": 10000
    }
  }'

echo "Queue yapılandırması tamamlandı"
EOF

chmod +x /tmp/setup_webhook_queue.sh
/tmp/setup_webhook_queue.sh

Monitoring ve Alert: Duplicate Tespiti

İdempotency sisteminin çalışıp çalışmadığını izlemek için basit bir monitoring scripti:

#!/bin/bash
# webhook_duplicate_monitor.sh
# Duplicate webhook oranını izle ve alert gönder

DB_HOST="localhost"
DB_NAME="myapp"
DB_USER="monitor_user"
ALERT_EMAIL="[email protected]"
THRESHOLD=5  # Yüzde olarak kabul edilebilir duplicate oranı

# Son 1 saatteki webhook istatistikleri
STATS=$(psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -t -c "
    SELECT
        provider,
        COUNT(*) as total,
        COUNT(CASE WHEN status = 'duplicate' THEN 1 END) as duplicates,
        ROUND(
            COUNT(CASE WHEN status = 'duplicate' THEN 1 END) * 100.0 / COUNT(*),
            2
        ) as duplicate_rate
    FROM webhook_events
    WHERE processed_at > NOW() - INTERVAL '1 hour'
    GROUP BY provider;
")

echo "Son 1 saatlik webhook istatistikleri:"
echo "$STATS"

# Threshold kontrolü
while IFS='|' read -r provider total duplicates rate; do
    provider=$(echo "$provider" | xargs)
    rate=$(echo "$rate" | xargs)

    if (( $(echo "$rate > $THRESHOLD" | bc -l) )); then
        echo "UYARI: ${provider} için duplicate oranı %${rate} - threshold %${THRESHOLD}"

        # Mail alert gönder
        echo "Webhook duplicate oranı kritik seviyede!
Provider: ${provider}
Toplam: ${total}
Duplicate: ${duplicates}
Oran: %${rate}
Zaman: $(date)" | mail -s "WEBHOOK ALERT: Yüksek Duplicate Oranı - ${provider}" "$ALERT_EMAIL"
    fi
done <<< "$STATS"

Bu scripti crontab’a ekleyin:

# Her 15 dakikada bir çalıştır
*/15 * * * * /opt/scripts/webhook_duplicate_monitor.sh >> /var/log/webhook_monitor.log 2>&1

Gerçek Dünya Senaryosu: E-Ticaret Ödeme Akışı

Tüm bu kavramları bir araya getiren gerçek bir senaryoya bakalım. Bir e-ticaret platformundasınız ve Iyzico üzerinden ödeme alıyorsunuz. Ödeme başarıyla tamamlandığında sipariş oluşturulmalı, stok düşürülmeli ve müşteriye mail gönderilmelidir.

Bu işlemlerin tekrar etmemesi için izlenecek adımlar:

  • Adım 1: Webhook geldiğinde önce signature doğrula
  • Adım 2: Event ID’yi Redis’te kontrol et (hızlı ön kontrol)
  • Adım 3: Veritabanında sipariş numarasını kontrol et (ikinci güvenlik katmanı)
  • Adım 4: Tüm işlemleri tek bir veritabanı transaction’ı içinde gerçekleştir
  • Adım 5: Başarılı tamamlanırsa event ID’yi hem Redis’te hem veritabanında işaretle
# transaction_based_webhook.sh
# Idempotent işlem için transaction örneği (psql ile)

process_payment_webhook() {
    local event_id=$1
    local order_id=$2
    local amount=$3
    local user_id=$4

    # Redis kontrolü (hızlı)
    if redis-cli SET "webhook:payment:${event_id}" "1" NX EX 86400 | grep -q "OK"; then
        echo "Event yeni, işleniyor: $event_id"
    else
        echo "Duplicate event, atlıyorum: $event_id"
        return 0
    fi

    # Veritabanı transaction'ı
    psql -h localhost -U app_user -d myapp << EOSQL
BEGIN;

-- Siparişin zaten var olup olmadığını kontrol et
DO $$
DECLARE
    existing_order INTEGER;
BEGIN
    SELECT id INTO existing_order
    FROM orders
    WHERE payment_event_id = '${event_id}'
    FOR UPDATE SKIP LOCKED;

    IF existing_order IS NOT NULL THEN
        RAISE EXCEPTION 'ORDER_EXISTS';
    END IF;

    -- Sipariş oluştur
    INSERT INTO orders (user_id, amount, payment_event_id, status)
    VALUES (${user_id}, ${amount}, '${event_id}', 'confirmed');

    -- Stok güncelle
    UPDATE products SET stock = stock - 1
    WHERE id IN (SELECT product_id FROM cart_items WHERE user_id = ${user_id});

    -- Webhook event logla
    INSERT INTO webhook_events (event_id, provider, event_type, status)
    VALUES ('${event_id}', 'iyzico', 'payment.success', 'processed')
    ON CONFLICT (event_id) DO NOTHING;
END;
$$;

COMMIT;
EOSQL

    local exit_code=$?
    if [ $exit_code -eq 0 ]; then
        echo "Ödeme başarıyla işlendi: $event_id"
        # Mail gönderme kuyruğuna ekle
        redis-cli LPUSH "mail:queue" "{"type":"order_confirmed","user_id":${user_id}}"
    else
        # Redis'teki kilidi kaldır - yeniden deneme için
        redis-cli DEL "webhook:payment:${event_id}"
        echo "İşlem başarısız, retry için kilit kaldırıldı: $event_id"
        return 1
    fi
}

Temizlik ve Bakım

Webhook event tablosu zamanla büyür. Düzenli temizlik işlemi hem performansı korur hem de depolama maliyetini düşürür.

#!/bin/bash
# webhook_cleanup.sh
# 30 günden eski işlenmiş webhook event'lerini temizle

RETENTION_DAYS=30
DB_HOST="localhost"
DB_NAME="myapp"
DB_USER="app_user"

echo "$(date): Webhook temizliği başlıyor..."

DELETED=$(psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -t -c "
    WITH deleted AS (
        DELETE FROM webhook_events
        WHERE processed_at < NOW() - INTERVAL '${RETENTION_DAYS} days'
        AND status = 'processed'
        RETURNING id
    )
    SELECT COUNT(*) FROM deleted;
")

DELETED=$(echo "$DELETED" | xargs)
echo "$(date): ${DELETED} eski webhook event silindi"

# Redis'teki TTL'si dolmuş keyleri zaten otomatik silinir
# Ama manuel kontrol için:
REDIS_WEBHOOK_KEYS=$(redis-cli KEYS "webhook:processed:*" | wc -l)
echo "$(date): Redis'te ${REDIS_WEBHOOK_KEYS} aktif webhook key mevcut"

Bunu crontab’a ekleyin:

# Her gece 03:00'da çalıştır
0 3 * * * /opt/scripts/webhook_cleanup.sh >> /var/log/webhook_cleanup.log 2>&1

Sonuç

Idempotent webhook işleme, “çalışıyor gibi görünen” sistemlerin gerçek anlamda güvenilir hale gelmesi için zorunlu bir yaklaşımdır. Özellikle finans, e-ticaret ve kritik bildirim sistemlerinde tekrar eden istekler ciddi sonuçlar doğurabilir.

Özet olarak dikkat edilmesi gereken noktalar:

  • Signature doğrulamayı her zaman ilk adım olarak yapın
  • İki katmanlı kontrol kullanın: Redis (hızlı) + veritabanı (güvenilir)
  • Veritabanı transaction’larını ve UNIQUE constraint’leri idempotency garantisi için kullanın
  • TTL’li Redis key’leri ile hız ve bellek verimliliğini dengeleyin
  • Dead letter queue ile başarısız event’leri kaybetmeden yönetin
  • Monitoring kurarak duplicate oranınızı takip edin
  • Düzenli temizlik ile veritabanı şişmesini önleyin

En önemli kural şudur: webhook endpoint’iniz her zaman tekrar aranabileceğini varsayarak tasarlanmalıdır. “Bu zaten gelmez” diye düşündüğünüz senaryolar, tam üretim baskısı altında mutlaka gerçekleşir.

Bir yanıt yazın

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