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.
