Idempotency-Key, istemcinin idempotent olmayan bir isteği (tipik olarak POST) güvenle yeniden deneyebilmesi için isteğe eklediği benzersiz bir anahtardır. İstemci her mantıksal işlem için bir anahtar üretir ve bunu Idempotency-Key başlığında gönderir; sunucu anahtarı ilk kez görüyorsa işlemi yapar ve yanıtı anahtarla birlikte saklar, aynı anahtar yeniden gelirse işlemi tekrarlamadan saklanan yanıtı döndürür. Böylece ağ hatası sonrası tekrar gönderilen bir ödeme isteği ikinci bir ödeme yaratmaz.

Bu yazıda sorunun HTTP tarafındaki kaynağını, anahtarın yaşam döngüsünü, aynı anahtarla farklı gövde veya aynı anda iki istek gelmesi gibi kenar durumlarını ve FastAPI ile çalışan bir sunucu ile istemci örneğini ele alıyoruz. İdempotent metot kavramının genel tanımı için HTTP istek ve yanıt yapısı, serinin tamamı için REST API tasarımı öğrenme rehberi sayfasına bakabilirsiniz.

Sorun: yanıt kaybolduğunda ne olur

Bir istemci POST /odemeler isteği gönderiyor, sunucu ödemeyi alıyor ve kaydediyor, ama yanıt istemciye ulaşmadan bağlantı kopuyor ya da istemci zaman aşımına uğruyor. İstemcinin elinde iki kötü seçenek kalır: isteği tekrar gönderirse ödeme iki kez alınabilir, göndermezse ödeme hiç alınmamış olabilir. İstemci, yalnızca kendi tarafında olan bilgiyle hangisinin doğru olduğunu bilemez.

RFC 9110 bu durumu metotların idempotentlik özelliğiyle ele alır. Bir metodun idempotent olması, aynı isteğin birden çok kez gönderilmesinin sunucu üzerindeki amaçlanan etkisinin tek istekle aynı olmasıdır. PUT, DELETE ve güvenli metotlar bu sınıftadır; bu yüzden bağlantı yanıt okunmadan koparsa otomatik olarak yeniden denenebilirler. POST ve PATCH ise idempotent değildir ve RFC 9110'a göre istemci, isteğin anlamının aslında idempotent olduğunu veya ilk isteğin hiç uygulanmadığını bilmenin bir yolu olmadan böyle bir isteği otomatik olarak yeniden denememelidir. Vekil sunucuların ise idempotent olmayan istekleri otomatik yeniden denemesi tamamen yasaktır.

Idempotency-Key tam olarak RFC 9110'un sözünü ettiği "bilmenin yolu"nu sağlar: istemci ile sunucu, belirli bir anahtarla gelen isteğin tek bir mantıksal işlem olduğu konusunda anlaşır. Bu anahtar, bu yazıda dayandığımız RFC'lerde tanımlı bir HTTP başlığı değildir; sunucu ile istemcinin üzerinde anlaştığı yaygın bir sözleşmedir (convention) ve davranışı API belgenizde tarif etmeniz gerekir.

Nasıl çalışır

Nasıl çalışır: 1. Anahtar üretimi, 2. İlk istek, 3. Tekrar istek, 4. Süre dolumu
http
POST /odemeler HTTP/1.1
Host: api.ornek.com
Content-Type: application/json
Idempotency-Key: 4f1c9a2e-7b3d-4c55-9e8a-2d6f0b1a7c34

{"tutar": 100, "para_birimi": "TRY"}

HTTP/1.1 201 Created
Content-Type: application/json
Location: /odemeler/1

{"id": 1, "tutar": 100, "para_birimi": "TRY", "durum": "onaylandi"}
  • 1. Anahtar üretimi: İstemci mantıksal işlem başına bir anahtar üretir; rastgele bir UUID veya sipariş numarası gibi işleme bağlı benzersiz bir değer olabilir.
  • 2. İlk istek: Sunucu anahtarı deposunda bulamaz, işlemi gerçekleştirir, durum kodunu ve yanıt gövdesini anahtarla birlikte saklar ve yanıtı döner.
  • 3. Tekrar istek: Yanıtı alamayan istemci aynı isteği aynı anahtarla gönderir. Sunucu anahtarı bulur, işlemi yeniden yapmaz, saklanan yanıtı döndürür.
  • 4. Süre dolumu: Saklanan kayıt belirli bir süre sonra silinir. Bu örnekte süre 24 saat (86400 saniye) seçildi; bu bir standart değil, tasarım kararıdır.

Bu akışın sonucu, istemcinin gözünden POST isteğinin idempotent hale gelmesidir: aynı anahtarla kaç kez gönderilirse gönderilsin, sunucu üzerindeki etki tek bir isteğin etkisidir ve istemci her seferinde aynı yanıtı alır. Metodun kendisi değişmez; değişen, sunucunun aynı işlemi tanıyabilmesidir.

Hangi uç noktalar için gerekir

Anahtar desteği en çok, tekrarlanması gerçek bir zarara yol açan POST uç noktalarında (endpoint) değer kazanır: ödeme, para transferi, sipariş oluşturma, e-posta veya SMS gönderme, fatura kesme gibi. Serinin tasarım kontrol listesi yazısında da bu madde "ödeme ve kritik uç noktalar için idempotentlik" olarak geçer. Toplu işlem uç noktaları da iyi bir adaydır, çünkü tekrarlanan bir toplu oluşturma isteği yüzlerce kaydı çoğaltabilir.

PUT ve DELETE tanımı gereği idempotenttir ama burada da ince bir nokta vardır. RFC 9110, isteği tekrarlamanın aynı amaçlanan etkiyi doğuracağını, ancak yanıtın farklı olabileceğini belirtir. İlk DELETE /odemeler/1 isteği 204 dönüp ikincisi 404 dönebilir; kaynak yine de bir kez silinmiştir. İstemci bu farkı sorun olarak görmemelidir. Yanıtların da aynı olması gereken durumlarda anahtar bu metotlarla da kullanılabilir. PATCH için durum yama biçimine bağlıdır; ayrıntısı PUT ve PATCH farkı yazısında.

FastAPI ile sunucu tarafı

FastAPI ile sunucu tarafı: Aynı anahtar, farklı gövde, Aynı anda gelen iki istek, Saklama süresi, Hangi yanıtlar saklanmalı

Aşağıdaki örnek bir ödeme uç noktasına anahtar desteği ekler. Anahtarlar bellek içi bir sözlükte tutuluyor; gerçek bir kurulumda birden çok sunucu süreci çalıştığı için bu kayıtların tüm süreçlerin eriştiği paylaşılan bir depoda (ör. bir anahtar-değer veritabanında) tutulması gerekir, yoksa tekrar istek başka bir sürece düştüğünde anahtar tanınmaz. Zamanın geçişini göstermek için saat elle ilerletiliyor.

python
import hashlib
import json
from fastapi import FastAPI, Header
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

SURE = 86400                 # anahtarı 24 saat sakla
app = FastAPI()
anahtarlar = {}              # örnek için sözlük; üretimde tüm sunucuların paylaştığı bir depo
odemeler = []
saat = [1_000_000.0]         # örnekte zamanı elle ilerletebilmek için

class Odeme(BaseModel):
    tutar: int = Field(gt=0)
    para_birimi: str = Field(min_length=3, max_length=3)

def parmak_izi(govde: dict) -> str:
    return hashlib.sha256(json.dumps(govde, sort_keys=True).encode()).hexdigest()

@app.post("/odemeler")
def odeme_olustur(odeme: Odeme, idempotency_key: str = Header(min_length=8, max_length=255)):
    iz = parmak_izi(odeme.model_dump())
    kayit = anahtarlar.get(idempotency_key)
    if kayit and saat[0] - kayit["zaman"] > SURE:      # süresi dolmuş anahtar unutulur
        del anahtarlar[idempotency_key]
        kayit = None
    if kayit:
        if kayit["iz"] != iz:
            return JSONResponse(status_code=422, content={"detail": "Bu anahtar farklı bir istek gövdesiyle kullanılmış"})
        if kayit["durum"] is None:
            return JSONResponse(status_code=409, content={"detail": "Aynı anahtarlı istek hâlâ işleniyor"})
        return JSONResponse(status_code=kayit["durum"], content=kayit["govde"],
                            headers={"Location": f"/odemeler/{kayit['govde']['id']}"})
    # İlk kez görülen anahtar: önce "işleniyor" olarak işaretle, sonra işi yap.
    anahtarlar[idempotency_key] = {"iz": iz, "durum": None, "govde": None, "zaman": saat[0]}
    try:
        yeni = {"id": len(odemeler) + 1, **odeme.model_dump(), "durum": "onaylandi"}
        odemeler.append(yeni)
    except Exception:
        del anahtarlar[idempotency_key]                 # sunucu hatasında istemci yeniden deneyebilsin
        raise
    anahtarlar[idempotency_key].update(durum=201, govde=yeni)
    return JSONResponse(status_code=201, content=yeni, headers={"Location": f"/odemeler/{yeni['id']}"})

c = TestClient(app)
GOVDE = {"tutar": 100, "para_birimi": "TRY"}

r = c.post("/odemeler", json=GOVDE)
print(r.status_code, r.json()["detail"][0]["type"], r.json()["detail"][0]["loc"])
# çıktı: 422 missing ['header', 'idempotency-key']

r = c.post("/odemeler", json=GOVDE, headers={"Idempotency-Key": "siparis-1001-odeme"})
print(r.status_code, r.headers["location"], r.json(), len(odemeler))
# çıktı: 201 /odemeler/1 {'id': 1, 'tutar': 100, 'para_birimi': 'TRY', 'durum': 'onaylandi'} 1

r = c.post("/odemeler", json=GOVDE, headers={"idempotency-key": "siparis-1001-odeme"})
print(r.status_code, r.json(), len(odemeler))
# çıktı: 201 {'id': 1, 'tutar': 100, 'para_birimi': 'TRY', 'durum': 'onaylandi'} 1

r = c.post("/odemeler", json={"tutar": 250, "para_birimi": "TRY"}, headers={"Idempotency-Key": "siparis-1001-odeme"})
print(r.status_code, r.json(), len(odemeler))
# çıktı: 422 {'detail': 'Bu anahtar farklı bir istek gövdesiyle kullanılmış'} 1

# İlk isteği hâlâ işleniyormuş gibi gösterelim:
anahtarlar["siparis-1002-odeme"] = {"iz": parmak_izi(GOVDE), "durum": None, "govde": None, "zaman": saat[0]}
r = c.post("/odemeler", json=GOVDE, headers={"Idempotency-Key": "siparis-1002-odeme"})
print(r.status_code, r.json())
# çıktı: 409 {'detail': 'Aynı anahtarlı istek hâlâ işleniyor'}

saat[0] += SURE + 1                                    # 24 saatten fazla zaman geçti
r = c.post("/odemeler", json=GOVDE, headers={"Idempotency-Key": "siparis-1001-odeme"})
print(r.status_code, r.json()["id"], len(odemeler))
# çıktı: 201 2 2

İlk istek başlık olmadan geldiği için FastAPI'nin otomatik doğrulamasıyla 422 aldı. FastAPI, idempotency_key parametre adını alt çizgiyi tireye çevirerek idempotency-key başlığıyla eşleştirir; HTTP başlık adları büyük-küçük harfe duyarlı olmadığı için üçüncü istekteki küçük harfli yazım da aynı anahtar sayıldı. Anahtarı bu örnekteki gibi zorunlu kılmak, istemcinin anahtarı unutmasını erkenden yakalar; isteğe bağlı bırakmak ise eski istemcilerle uyumu korur.

İkinci ve üçüncü istekler akışın özünü gösteriyor: aynı anahtarla gelen ikinci istek yeni bir ödeme yaratmadı, ödeme listesinin uzunluğu 1 kaldı ve istemci ilk yanıtın aynısını, aynı durum kodu ve Location başlığıyla aldı. Durum kodunu da saklamak önemlidir; tekrar isteğe 201 yerine 200 dönmek, istemcinin "kayıt bu istekle mi oluştu" mantığını bozabilir.

Aynı anahtar, farklı gövde

Dördüncü istek aynı anahtarı farklı bir tutarla gönderiyor. Bu, istemci kodundaki bir hataya işaret eder: anahtar yanlışlıkla iki farklı işlem için kullanılmıştır. Sunucu bu durumda saklanan yanıtı döndürürse istemci 250 liralık ödemenin yapıldığını sanır; işlemi yaparsa anahtarın anlamı kaybolur. Bu yüzden örnekte istek gövdesinin bir özeti (SHA-256 parmak izi) anahtarla birlikte saklanıyor ve uyuşmazlıkta istek reddediliyor. Örnekte 422 seçildi; isteğin hedef kaynağın mevcut durumuyla çakıştığını vurgulamak için 409 Conflict de savunulabilir bir seçimdir. Önemli olan seçiminizi belgelemektir.

Aynı anda gelen iki istek

İstemci, ilk istek hâlâ işlenirken zaman aşımına uğrayıp ikinci isteği gönderebilir. Bu durumda ikinci istek saklanmış bir yanıt bulamaz, çünkü ilk istek henüz bitmemiştir. Önlem, işe başlamadan önce anahtarı "işleniyor" durumuyla kaydetmektir; örnekte bu, durum alanının None olmasıyla gösterildi ve ikinci istek 409 Conflict aldı. RFC 9110 409 kodunu istemcinin çakışmayı çözüp isteği yeniden gönderebileceği durumlar için tanımlar; burada çözüm biraz bekleyip aynı anahtarla tekrar denemektir. Gerçek bir kurulumda "anahtar yoksa yaz" denetimi paylaşılan depoda atomik bir işlemle yapılmalıdır; önce okuyup sonra yazan iki ayrı adım, aynı anda gelen iki isteğin ikisinin de anahtarı boş bulmasına izin verir.

Saklama süresi

Son istek, süresi dolmuş bir anahtarın riskini gösteriyor: 24 saatten fazla zaman geçtikten sonra aynı anahtarla gelen istek yeni bir ödeme yarattı ve liste uzunluğu 2 oldu. Süre, istemcilerinizin bir işlemi ne kadar süre yeniden deneyebileceğine göre seçilmelidir. Çok kısa süre, uzun kesintilerden sonra yapılan yeniden denemeleri korumasız bırakır; çok uzun süre ise depoyu büyütür. Seçtiğiniz süreyi belgede açıkça yazın ki istemciler bu pencerenin dışında aynı anahtarla yeniden denemeye güvenmesin.

Hangi yanıtlar saklanmalı

Başarılı yanıtlar mutlaka saklanır. Sunucu hatası durumunda ise işlem yapılmamış olabilir; örnekte işlem sırasında bir istisna oluşursa anahtar silinir ve istemci aynı anahtarla yeniden deneyebilir. Doğrulama gibi istemci hataları işlemi hiç başlatmadığı için saklanması şart değildir; ancak saklarsanız istemci düzeltilmiş isteği yeni bir anahtarla göndermek zorunda kalır. Hangi kuralı uygularsanız uygulayın, istemciye anlatın.

İstemci tarafı: anahtar ne zaman üretilir

Anahtarın işe yaraması için istemcinin onu doğru anda üretmesi gerekir: mantıksal işlem başına bir kez, ilk denemeden önce. Yeniden deneme döngüsünün içinde her denemede yeni bir anahtar üretmek, sunucunun tekrarları tanımasını imkânsız kılar. Aşağıdaki örnek, sunucunun isteği işlediği ama yanıtın istemciye ulaşmadığı bir durumu taklit eder ve iki istemci yazımını karşılaştırır:

python
import uuid
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient

# Kısa tutulmuş sunucu: aynı anahtarı ikinci kez görünce kayıtlı yanıtı döndürür.
app = FastAPI()
yanitlar, odemeler = {}, []

@app.post("/odemeler", status_code=201)
def olustur(govde: dict, idempotency_key: str = Header()):
    if idempotency_key not in yanitlar:
        odemeler.append(govde)
        yanitlar[idempotency_key] = {"id": len(odemeler), **govde}
    return yanitlar[idempotency_key]

c = TestClient(app)
kayip = [True]

def gonder(govde, anahtar):
    r = c.post("/odemeler", json=govde, headers={"Idempotency-Key": anahtar})
    if kayip[0]:                       # sunucu isteği işledi ama yanıt istemciye ulaşmadı
        kayip[0] = False
        raise TimeoutError("yanıt alınamadı")
    return r

def ode(govde, deneme=3):
    anahtar = str(uuid.uuid4())        # DOĞRU: işlem başına bir kez, denemelerden önce
    for i in range(deneme):
        try:
            return gonder(govde, anahtar)
        except TimeoutError as e:
            print("deneme", i + 1, "başarısız:", e)
    raise RuntimeError("vazgeçildi")

def ode_yanlis(govde, deneme=3):
    for i in range(deneme):
        try:
            return gonder(govde, str(uuid.uuid4()))   # YANLIŞ: her denemede yeni anahtar
        except TimeoutError as e:
            print("deneme", i + 1, "başarısız:", e)
    raise RuntimeError("vazgeçildi")

r = ode({"tutar": 100})
# çıktı: deneme 1 başarısız: yanıt alınamadı
print(r.status_code, r.json(), len(odemeler))
# çıktı: 201 {'id': 1, 'tutar': 100} 1

odemeler.clear(); yanitlar.clear(); kayip[0] = True
r = ode_yanlis({"tutar": 100})
# çıktı: deneme 1 başarısız: yanıt alınamadı
print(r.status_code, r.json(), len(odemeler))
# çıktı: 201 {'id': 2, 'tutar': 100} 2

Doğru yazımda ikinci deneme aynı anahtarı taşıdığı için sunucu kayıtlı yanıtı döndürdü ve tek bir ödeme oluştu. Yanlış yazımda ise ikinci deneme yeni bir anahtarla geldi, sunucu onu yeni bir işlem sandı ve ödeme iki kez kaydedildi. Uzun süren işlemlerde anahtarı istemci tarafında da kalıcı olarak saklamak (ör. sipariş kaydının yanında) faydalıdır; uygulama yeniden başlasa bile aynı işlem aynı anahtarla tamamlanabilir. Yeniden denemeler arasına giderek artan bekleme süreleri koymak da sunucunun aşırı yüklenmesini önler; hız sınırlamasıyla karşılaşırsanız rate limiting stratejileri yazısına bakın.

Anahtarın kapsamı ve güvenlik

Anahtarları sunucuda kimliği doğrulanmış istemciyle birlikte saklayın; yani kayıt anahtarı yalnızca Idempotency-Key değeri değil, "müşteri ve anahtar" ikilisi olsun. Aksi halde iki farklı müşterinin tesadüfen veya kasten aynı anahtarı kullanması, birinin diğerinin saklanmış yanıtını görmesine yol açabilir; bu hem bir veri sızıntısı hem de yanlış bir işlem sonucudur. Anahtar bir sır değildir ve yetkilendirme yerine geçmez; kimlik doğrulama yine Authorization başlığıyla yapılır. Ayrıca anahtar uzunluğuna örnekteki gibi bir alt ve üst sınır koymak, çok kısa (çakışmaya açık) veya aşırı uzun değerleri baştan reddeder. Genel güvenlik çerçevesi için REST API güvenlik rehberi yazısına bakın.

Sık yapılan hatalar

Sık yapılan hatalar: Her denemede yeni anahtar üretmek, Gövdeyi anahtarla eşleştirmemek, İşleniyor durumunu tutmamak, Anahtarı tek bir sunucu sürecinin belleğinde tutmak
  • Her denemede yeni anahtar üretmek: Anahtar işlem başına bir kez, yeniden deneme döngüsünün dışında üretilmelidir.
  • Gövdeyi anahtarla eşleştirmemek: Aynı anahtarla farklı bir istek gelirse saklanan yanıtı körü körüne döndürmek yanlış sonuç bildirir.
  • İşleniyor durumunu tutmamak: Eşzamanlı iki tekrar isteği ikisi de işlenebilir.
  • Anahtarı tek bir sunucu sürecinin belleğinde tutmak: Birden çok süreçte veya sunucu yeniden başladığında anahtarlar kaybolur.
  • Yalnızca gövdeyi saklayıp durum kodunu unutmak: Tekrar istekte 201 yerine 200 dönmek istemci mantığını bozar.
  • Anahtarları istemciye göre ayırmamak: Farklı müşterilerin aynı anahtarı kullanması yanıt sızıntısına yol açar.
  • Süreyi belgelememek: İstemci, pencere dışında yaptığı tekrarın korunduğunu sanabilir.

Sık Sorulan Sorular

Idempotency-Key bir HTTP standardı mı?

Bu yazıda dayandığımız RFC'lerde tanımlı değildir. Sunucu ile istemcinin uyguladığı bir sözleşme olarak kullanılır; bu yüzden başlık adını, zorunlu olup olmadığını, saklama süresini ve hata kodlarını API belgenizde tanımlamanız gerekir.

Anahtar olarak ne kullanmalıyım?

Çakışma olasılığı çok düşük rastgele bir değer (ör. UUID) veya işleme bağlı benzersiz bir değer kullanabilirsiniz. İşleme bağlı değer, uygulama yeniden başladığında bile aynı işlem için aynı anahtarı üretebilme avantajı sağlar.

GET istekleri için anahtar gerekir mi?

Hayır. RFC 9110'a göre GET güvenli ve idempotent bir metottur; tekrarlamak sunucuda amaçlanan bir durum değişikliği yaratmaz.

Saklanan yanıt değişmişse ne olur?

Anahtar, işlemin o anki sonucunu tekrar etmek içindir. Ödeme sonradan iade edilmiş olsa bile tekrar istek ilk yanıtı alır; kaynağın güncel halini öğrenmek için istemci Location adresine GET yapar.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: REST API tasarımı öğrenme rehberi · PUT ve PATCH farkı · Toplu işlem endpoint · CRUD endpoint tasarımı · Durum kodu seçimi · HTTP metotları · Rate limiting stratejileri