Webhook Retry Mekanizması: Başarısız İstekleri Yönetme

Production ortamında webhook yönetiyorsanız, er ya da geç şu soruyla yüz yüze geleceksiniz: “Karşı taraf isteği alamadıysa ne olacak?” Ağ kesintileri, geçici sunucu hataları, zaman aşımı problemleri… Bunların hepsi gerçek dünyada yaşanan ve webhook entegrasyonlarınızı sessizce kıran şeyler. Retry mekanizması kurmadan webhook çalıştırmak, yangın alarmı olmayan bir binada çalışmak gibi bir şey. Alarm çalmadığı için her şey yolunda sandınız, ama aslında olmadı.

Bu yazıda webhook retry mekanizmalarını sıfırdan ele alacağız. Hem alıcı (receiver) hem de gönderici (sender) tarafında neler yapılması gerektiğini, exponential backoff mantığını, idempotency konusunu ve production’da işe yarayan gerçek çözümleri konuşacağız.

Webhook Retry Mantığını Anlamak

Webhook dediğimiz şey özünde bir HTTP POST isteği. Bir olay gerçekleştiğinde, kaynak sistem hedef URL’nize veri gönderir. Bu kadar basit. Ama şöyle bir senaryo düşünün: Ödeme sisteminiz başarılı bir işlemi size bildiriyor, siz o anda deployment yapıyorsunuz ve servisiniz 30 saniye boyunca yanıt veremiyor. İstek kayboldu. Ödeme kaydedilmedi. Müşteri ürünü aldı ama sipariş sisteminizde kayıt yok.

İşte retry mekanizması tam bu noktada devreye giriyor. Başarısız isteklerin belirli aralıklarla yeniden denenmesi, veri kaybını önlemenin en temel yöntemi.

Bir webhook isteğinin başarısız sayılması için genellikle şu durumlar gerekir:

  • HTTP 2xx dışında bir yanıt kodu dönmesi (3xx, 4xx, 5xx)
  • Bağlantı zaman aşımı (connection timeout)
  • DNS çözümleme hatası
  • SSL/TLS hatası
  • Yanıt gelmeden bağlantının kapanması

Exponential Backoff: Temel Strateji

Her başarısız istekten hemen sonra tekrar denemek, hem sizin hem de karşı tarafın sunucusunu boğar. Özellikle karşı taraf zaten sorunluysa, üstüne bir de retry yağmuruna tutmak durumu daha da kötüleştirir.

Exponential backoff, her başarısız denemeden sonra bekleme süresini katlanarak artıran bir strateji. Temel formül şu: bekleme_suresi = temel_sure * (2 ^ deneme_sayisi)

Buna biraz rastgelelik (jitter) eklemezseniz, birden fazla client aynı anda retry yapmaya başladığında hepsi aynı anda sunucuya yığılır. Bu yüzden jitter şart.

import time
import random
import requests
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def send_webhook_with_retry(url, payload, max_retries=5, base_delay=1):
    """
    Exponential backoff ve jitter ile webhook gönderimi
    """
    for attempt in range(max_retries + 1):
        try:
            response = requests.post(
                url,
                json=payload,
                timeout=10,
                headers={
                    'Content-Type': 'application/json',
                    'X-Webhook-Attempt': str(attempt + 1),
                    'X-Webhook-ID': payload.get('event_id', 'unknown')
                }
            )
            
            if response.status_code in range(200, 300):
                logger.info(f"Webhook basariyla gonderildi. Deneme: {attempt + 1}")
                return True
                
            # 4xx hatalar icin retry yapma (kalici hata)
            if response.status_code in range(400, 500):
                logger.error(f"Kalici hata {response.status_code}, retry iptal.")
                return False
                
            logger.warning(f"Gecici hata {response.status_code}, deneme {attempt + 1}/{max_retries}")
            
        except requests.exceptions.Timeout:
            logger.warning(f"Zaman asimi, deneme {attempt + 1}/{max_retries}")
        except requests.exceptions.ConnectionError as e:
            logger.warning(f"Baglanti hatasi: {e}, deneme {attempt + 1}/{max_retries}")
        
        if attempt < max_retries:
            # Exponential backoff + jitter
            delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
            delay = min(delay, 300)  # Maksimum 5 dakika
            logger.info(f"Bir sonraki deneme {delay:.2f} saniye sonra...")
            time.sleep(delay)
    
    logger.error("Maksimum deneme sayisina ulasildi. Webhook gonderilemedi.")
    return False

Dead Letter Queue: Kaybolan İstekler İçin Güvenli Liman

Retry mantığı da dahil olmak üzere, bazen hiçbir şey işe yaramaz. Belki endpoint kalıcı olarak kapandı, belki payload bozuk. Bu durumda isteği tamamen atmak yerine bir Dead Letter Queue (DLQ) veya başarısız istek depolamasına almak gerekiyor.

Redis ile basit bir kuyruk sistemi kuralım:

import redis
import json
import time
from datetime import datetime

class WebhookQueue:
    def __init__(self, redis_host='localhost', redis_port=6379):
        self.redis = redis.Redis(host=redis_host, port=redis_port, decode_responses=True)
        self.queue_key = 'webhook:pending'
        self.failed_key = 'webhook:failed'
        self.processing_key = 'webhook:processing'
    
    def enqueue(self, webhook_data):
        """Webhook'u kuyruğa ekle"""
        job = {
            'id': webhook_data['event_id'],
            'url': webhook_data['url'],
            'payload': webhook_data['payload'],
            'attempts': 0,
            'created_at': datetime.utcnow().isoformat(),
            'next_retry': time.time()
        }
        self.redis.lpush(self.queue_key, json.dumps(job))
        return job['id']
    
    def move_to_failed(self, job, error_message):
        """Basarisiz webhook'u DLQ'ya tasi"""
        job['failed_at'] = datetime.utcnow().isoformat()
        job['last_error'] = error_message
        self.redis.lpush(self.failed_key, json.dumps(job))
        # 7 gun sonra otomatik sil
        self.redis.expire(self.failed_key, 604800)
    
    def get_failed_webhooks(self, limit=100):
        """Basarisiz webhook'lari listele"""
        items = self.redis.lrange(self.failed_key, 0, limit - 1)
        return [json.loads(item) for item in items]
    
    def retry_failed(self, webhook_id):
        """Belirli bir webhook'u yeniden kuyruğa ekle"""
        items = self.redis.lrange(self.failed_key, 0, -1)
        for i, item in enumerate(items):
            job = json.loads(item)
            if job['id'] == webhook_id:
                job['attempts'] = 0
                job['next_retry'] = time.time()
                self.redis.lrem(self.failed_key, 1, item)
                self.redis.lpush(self.queue_key, json.dumps(job))
                return True
        return False

Worker: Kuyruğu İşleyen Servis

Kuyruğu doldurmak yetmez, biri onu işlemek zorunda. Bir webhook worker servisi yazalım:

#!/usr/bin/env python3
import time
import json
import signal
import sys
import logging
from webhook_queue import WebhookQueue
from webhook_sender import send_webhook_with_retry

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('webhook-worker')

class WebhookWorker:
    def __init__(self):
        self.queue = WebhookQueue()
        self.running = True
        self.max_attempts = 5
        
        signal.signal(signal.SIGTERM, self.handle_shutdown)
        signal.signal(signal.SIGINT, self.handle_shutdown)
    
    def handle_shutdown(self, signum, frame):
        logger.info("Kapatma sinyali alindi, mevcut isler tamamlandiktan sonra durulacak...")
        self.running = False
    
    def calculate_next_retry(self, attempts):
        """Bir sonraki deneme zamanini hesapla"""
        delays = [60, 300, 900, 3600, 7200]  # 1dk, 5dk, 15dk, 1sa, 2sa
        if attempts < len(delays):
            return time.time() + delays[attempts]
        return time.time() + delays[-1]
    
    def process_job(self, job):
        """Tek bir webhook isini isle"""
        if job['next_retry'] > time.time():
            # Henuz zamani gelmemis, geri koy
            self.queue.redis.rpush(self.queue.queue_key, json.dumps(job))
            return
        
        job['attempts'] += 1
        success = send_webhook_with_retry(
            url=job['url'],
            payload=job['payload'],
            max_retries=1,  # Worker kendi retry mantığını yönetiyor
            base_delay=1
        )
        
        if success:
            logger.info(f"Webhook {job['id']} basariyla tamamlandi.")
        elif job['attempts'] >= self.max_attempts:
            logger.error(f"Webhook {job['id']} maksimum denemeye ulasti. DLQ'ya aliniyor.")
            self.queue.move_to_failed(job, "Maksimum deneme sayisina ulasildi")
        else:
            job['next_retry'] = self.calculate_next_retry(job['attempts'])
            logger.warning(f"Webhook {job['id']} basarisiz. Deneme {job['attempts']}/{self.max_attempts}. Sonraki: {job['next_retry']}")
            self.queue.redis.rpush(self.queue.queue_key, json.dumps(job))
    
    def run(self):
        logger.info("Webhook worker baslatildi.")
        while self.running:
            try:
                item = self.queue.redis.brpop(self.queue.queue_key, timeout=5)
                if item:
                    _, data = item
                    job = json.loads(data)
                    self.process_job(job)
            except Exception as e:
                logger.error(f"Worker hatasi: {e}")
                time.sleep(1)
        
        logger.info("Worker durduruldu.")

if __name__ == '__main__':
    worker = WebhookWorker()
    worker.run()

İdempotency: Aynı İşlemi İki Kez Yapmamak

Retry mekanizması kurduğunuzda yeni bir problem ortaya çıkar: aynı webhook birden fazla kez işlenebilir. Ödeme için örnek verelim; bir siparişi iki kez işlemek felaket olur.

Idempotency, aynı işlemi birden fazla kez yapmanın sonucunun tek bir kez yapmakla aynı olmasıdır. Webhook alıcınızda bunu sağlamak için her isteğin benzersiz bir ID’si olmalı ve bu ID’yi daha önce işleyip işlemediğinizi kontrol etmelisiniz.

from flask import Flask, request, jsonify
import redis
import json
import hashlib

app = Flask(__name__)
r = redis.Redis(decode_responses=True)

def get_idempotency_key(webhook_id, event_type):
    """Idempotency anahtari olustur"""
    return f"webhook:processed:{event_type}:{webhook_id}"

@app.route('/webhook/receive', methods=['POST'])
def receive_webhook():
    # Webhook ID'sini headerdan al
    webhook_id = request.headers.get('X-Webhook-ID')
    event_type = request.headers.get('X-Event-Type', 'unknown')
    
    if not webhook_id:
        # ID yoksa payload'dan hash üret
        payload_str = request.get_data(as_text=True)
        webhook_id = hashlib.sha256(payload_str.encode()).hexdigest()
    
    idempotency_key = get_idempotency_key(webhook_id, event_type)
    
    # Daha once islendi mi?
    existing = r.get(idempotency_key)
    if existing:
        result = json.loads(existing)
        return jsonify({
            'status': 'already_processed',
            'processed_at': result['processed_at'],
            'result': result['result']
        }), 200
    
    # Islemeye basla - once kilit al
    lock_key = f"webhook:lock:{webhook_id}"
    lock_acquired = r.set(lock_key, '1', nx=True, ex=30)
    
    if not lock_acquired:
        return jsonify({'status': 'processing', 'message': 'Baska bir worker isliyor'}), 409
    
    try:
        payload = request.get_json()
        result = process_webhook_event(event_type, payload)
        
        # Sonucu kaydet (24 saat saklayacagiz)
        processed_data = {
            'processed_at': time.time(),
            'result': result
        }
        r.setex(idempotency_key, 86400, json.dumps(processed_data))
        
        return jsonify({'status': 'success', 'result': result}), 200
        
    except Exception as e:
        return jsonify({'status': 'error', 'message': str(e)}), 500
    finally:
        r.delete(lock_key)

def process_webhook_event(event_type, payload):
    """Olay tipine gore isleme yap"""
    handlers = {
        'payment.completed': handle_payment,
        'order.created': handle_order,
        'subscription.cancelled': handle_cancellation
    }
    
    handler = handlers.get(event_type)
    if handler:
        return handler(payload)
    return {'message': f'Bilinmeyen olay tipi: {event_type}'}

Webhook İzleme ve Alerting

Retry mekanizmanız çalışıyor ama kaç tane başarısız oldu, ne kadarı DLQ’da bekliyor? Bunu görmezseniz sessiz sedasız büyüyen bir problem yaşayabilirsiniz. Prometheus metrikleri ekleyelim:

from prometheus_client import Counter, Histogram, Gauge, start_http_server
import time

# Metrikler
webhook_sent_total = Counter(
    'webhook_sent_total',
    'Toplam gonderilen webhook sayisi',
    ['status', 'event_type']
)

webhook_retry_total = Counter(
    'webhook_retry_total',
    'Toplam retry sayisi',
    ['event_type']
)

webhook_failed_total = Counter(
    'webhook_failed_total',
    'Kalici basarisizlik sayisi',
    ['event_type', 'reason']
)

webhook_processing_duration = Histogram(
    'webhook_processing_duration_seconds',
    'Webhook isleme suresi',
    buckets=[0.1, 0.5, 1, 2, 5, 10, 30]
)

webhook_queue_depth = Gauge(
    'webhook_queue_depth',
    'Kuyrukta bekleyen webhook sayisi',
    ['queue_type']
)

def update_queue_metrics(queue):
    """Kuyruk metriklerini guncelle"""
    pending_count = queue.redis.llen(queue.queue_key)
    failed_count = queue.redis.llen(queue.failed_key)
    
    webhook_queue_depth.labels(queue_type='pending').set(pending_count)
    webhook_queue_depth.labels(queue_type='failed').set(failed_count)

# Prometheus metrikleri 8001 portunda sun
start_http_server(8001)

Nginx ile Webhook Alıcı Yapılandırması

Alıcı tarafta da bazı şeylere dikkat etmek gerekiyor. Özellikle büyük payload’lar ve timeout değerleri kritik. İşte production’da kullandığım bir Nginx yapılandırması:

upstream webhook_backend {
    server 127.0.0.1:8000;
    server 127.0.0.1:8001 backup;
    keepalive 32;
}

server {
    listen 443 ssl http2;
    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 endpoint'i
    location /webhook/ {
        proxy_pass http://webhook_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        
        # Timeout değerleri - webhook işleme sürenize göre ayarlayın
        proxy_connect_timeout 10s;
        proxy_read_timeout 30s;
        proxy_send_timeout 30s;
        
        # Body boyutu limiti
        client_max_body_size 5M;
        client_body_buffer_size 128k;
        
        # Header'ları ilet
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # Rate limiting - IP basina dakikada 100 istek
        limit_req zone=webhook_limit burst=20 nodelay;
        limit_req_status 429;
        
        # Hizli yanit vermek icin async isle
        proxy_buffering off;
    }
    
    # Rate limit zone tanımı (http bloğunda olmalı)
    # limit_req_zone $binary_remote_addr zone=webhook_limit:10m rate=100r/m;
}

Systemd ile Worker Servisi Olarak Çalıştırma

Worker’ı systemd servisi olarak ayağa kaldıralım. Bu sayede sunucu yeniden başladığında otomatik devreye girer ve çöktüğünde kendi kendine yeniden başlar:

# /etc/systemd/system/webhook-worker.service
[Unit]
Description=Webhook Worker Service
After=network.target redis.service
Requires=redis.service

[Service]
Type=simple
User=webhook
Group=webhook
WorkingDirectory=/opt/webhook-worker
Environment="PYTHONPATH=/opt/webhook-worker"
Environment="REDIS_HOST=localhost"
Environment="REDIS_PORT=6379"
ExecStart=/opt/webhook-worker/venv/bin/python worker.py
Restart=always
RestartSec=5
StartLimitInterval=60
StartLimitBurst=3

# Guvenlik ayarlari
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/opt/webhook-worker/logs

# Log ayarlari
StandardOutput=journal
StandardError=journal
SyslogIdentifier=webhook-worker

[Install]
WantedBy=multi-user.target
# Servisi etkinlestir ve baslat
sudo systemctl daemon-reload
sudo systemctl enable webhook-worker
sudo systemctl start webhook-worker

# Durumu kontrol et
sudo systemctl status webhook-worker

# Log takibi
sudo journalctl -u webhook-worker -f

# Birden fazla worker instance calıstırmak icin
# [email protected] seklinde template olusturun
sudo systemctl start webhook-worker@1
sudo systemctl start webhook-worker@2

Gerçek Dünya Senaryosu: E-ticaret Ödeme Entegrasyonu

Diyelim ki bir ödeme sağlayıcısıyla entegrasyon yapıyorsunuz. Ödeme onaylandığında size webhook geliyor, siz de siparişi tamamlanmış olarak işaretliyorsunuz. Bu senaryoda neler yanlış gidebilir?

  • Webhook geldiğinde veritabanı geçici olarak erişilemez durumda
  • Aynı ödeme için iki webhook gelebilir (retry sonucu)
  • Network paketi kaybı nedeniyle webhook hiç ulaşmayabilir
  • Ödeme sağlayıcısı webhook gönderdikten sonra timeout alabilir ve tekrar gönderebilir

Bunların hepsini ele alan bir yapı:

from dataclasses import dataclass
from typing import Optional
import uuid

@dataclass
class PaymentWebhookEvent:
    event_id: str
    payment_id: str
    order_id: str
    amount: float
    currency: str
    status: str
    timestamp: str

class PaymentWebhookHandler:
    def __init__(self, db_session, queue):
        self.db = db_session
        self.queue = queue
    
    def handle(self, event: PaymentWebhookEvent) -> dict:
        # 1. Idempotency kontrolu
        if self.is_already_processed(event.event_id):
            return {'status': 'duplicate', 'message': 'Bu olay zaten islendi'}
        
        # 2. Imza dogrulama (webhook provider'ınıza göre değişir)
        if not self.verify_signature(event):
            raise ValueError("Gecersiz webhook imzasi")
        
        # 3. Siparisi bul
        order = self.db.query(Order).filter_by(id=event.order_id).first()
        if not order:
            # 404 dönmeyin, 200 dönün ama log atın
            # Neden? Çünkü 404 dönünce karşı taraf retry yapar
            # Ama sipariş gerçekten yoksa retry'ın anlamı yok
            self.log_orphan_event(event)
            return {'status': 'ok', 'message': 'Siparis bulunamadi, event loglandı'}
        
        # 4. Odeme durumunu guncelle
        with self.db.begin():
            order.payment_status = event.status
            order.payment_id = event.payment_id
            
            if event.status == 'completed':
                order.status = 'processing'
                self.trigger_fulfillment(order)
            elif event.status == 'failed':
                order.status = 'payment_failed'
                self.notify_customer(order, 'payment_failed')
            
            # Idempotency kaydini olustur
            self.mark_as_processed(event.event_id)
        
        return {'status': 'success', 'order_id': event.order_id}
    
    def is_already_processed(self, event_id: str) -> bool:
        return self.db.query(ProcessedWebhook).filter_by(
            event_id=event_id
        ).first() is not None
    
    def mark_as_processed(self, event_id: str):
        record = ProcessedWebhook(
            event_id=event_id,
            processed_at=datetime.utcnow()
        )
        self.db.add(record)
    
    def verify_signature(self, event: PaymentWebhookEvent) -> bool:
        # Ödeme sağlayıcısına göre implementasyon değişir
        # Genellikle HMAC-SHA256 ile imza dogrulamasi yapilir
        return True  # Placeholder

Monitoring Dashboard için Log Yapısı

Tüm bu mekanizmaların düzgün çalışıp çalışmadığını anlamak için structured logging şart. JSON formatında loglar hem izlemeyi kolaylaştırır hem de log aggregation araçlarıyla (ELK, Loki) kolayca sorgulanabilir:

# /etc/logrotate.d/webhook-worker
/opt/webhook-worker/logs/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0640 webhook webhook
    postrotate
        systemctl reload webhook-worker || true
    endscript
}
# Basarisiz webhook'lari izlemek icin basit bash scripti
#!/bin/bash
REDIS_CLI="redis-cli"
FAILED_KEY="webhook:failed"
ALERT_THRESHOLD=10

failed_count=$($REDIS_CLI LLEN $FAILED_KEY)

if [ "$failed_count" -gt "$ALERT_THRESHOLD" ]; then
    echo "UYARI: DLQ'da $failed_count basarisiz webhook var!" | 
    mail -s "[ALERT] Webhook DLQ doldu" [email protected]
    
    # Slack bildirimi
    curl -s -X POST "$SLACK_WEBHOOK_URL" 
        -H 'Content-type: application/json' 
        -d "{"text":"Webhook DLQ uyarisi: $failed_count basarisiz istek bekliyor"}"
fi

Sık Yapılan Hatalar

Production’da webhook retry kurarken en sık gördüğüm yanlışları paylaşayım:

  • Hemen 500 dönmek: Arka planda async işleme yapacaksanız, önce 200 döndürün. Aksi halde gönderici retry başlatır.
  • Timeout’u çok kısa tutmak: 5 saniye bile bazen yetmez. Webhook alıcınız ne kadar sürede yanıt vereceğini test edin.
  • Retry sayısını sınırsız bırakmak: Kalıcı bir hata varsa sonsuz retry döngüsüne girersiniz. Mutlaka maksimum deneme sayısı belirleyin.
  • İmza doğrulamasını atlamak: Herkes webhook endpoint’inize istek atabilir. HMAC doğrulaması ihmal edilemez.
  • DLQ’yu izlememek: DLQ’ya aldınız, tebrikler. Ama orada ne kadar bekliyor? Hiç kontrol etmezseniz DLQ da bir veri mezarlığına dönüşür.

Sonuç

Webhook retry mekanizması kurmak bir defaya mahsus yapılıp unutulan bir iş değil. Sisteminiz büyüdükçe, entegrasyon sayısı arttıkça, bu mekanizmaların bakımı ve izlenmesi de önem kazanıyor.

Özetleyecek olursam; exponential backoff ile akıllıca retry, idempotency ile çift işleme koruması, DLQ ile kayıp veri önleme ve düzgün monitoring bunların hepsi birbirini tamamlayan parçalar. Sadece birini uygulayıp geçerseniz, diğerinin eksikliğini eninde sonunda production’da fark edersiniz.

Başlangıç için en önemli tavsiyem şu: Önce idempotency’yi kurun. Retry olmadan da hayatta kalabilirsiniz, ama çift işleme genellikle çok daha ağır sonuçlar doğurur. Sonra retry ekleyin, sonra DLQ, sonra monitoring. Adım adım ilerleyin ve her aşamayı load test ile doğrulayın.

Bir yanıt yazın

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