İçeriğe geç

REST Tasarımı ve Idempotency

Başlangıç 10 dk Çok sık karşılaşılır

30 saniyede özet

Bir isteğin cevabı gelmezse iki ihtimal var: istek hiç ulaşmadı ya da iş yapıldı ama cevap yolda kayboldu. İstemci bunları ayıramaz. Tekrar denenecek her istek, iki kez gelse de işi bir kez yapmalı.

Bir arkadaşına mesaj attın ve cevap gelmedi. Mesaj ulaşmadı mı, yoksa cevabı mı kayboldu? Ağ üzerinden yapılan her çağrı da böyle üç şekilde biter: başarı, açık hata ve bilinmeyen.

  1. Bayt: Müşteri bir kez sipariş verdiğine yemin ediyor, ama sistemde üç sipariş var!

  2. Sen: Belki üç kez tıkladı?

  3. Bayt: Tek tıkladı. Uygulama cevap alamayınca kendi kendine iki kez daha denedi.

  4. Bayt: Peki sunucu ilk isteği almış mıydı, almamış mıydı?

Kaybolan cevap, kaybolan istekten farklı ama istemciye aynı görünür.
Adım adım oku
  1. İstemci siparişi gönderir; sunucu işi yapar ve siparişi kaydeder.
  2. Cevap dönüş yolunda kaybolur; istemci yalnızca zaman aşımı görür.
  3. İstemci körü körüne tekrar denerse sunucu ikinci bir sipariş oluşturur.
  4. Aynı Idempotency-Key ile tekrar gelirse sunucu anahtarı tanır, işi yapmaz ve sakladığı cevabı döner.

İki farklı kayıp, istemci için aynı görünür

İstek kayboldu: istemci ──✕ sunucu → hiçbir şey olmadı
Cevap kayboldu: istemci ────────→ sunucu ✕─── → İŞ YAPILDI

İstemci her iki durumda da timeout alır. Aradaki farkı göremez. Tekrar denerse:

  • İstek kaybolmuşsa → doğru davranış, iş bir kez yapılır
  • Cevap kaybolmuşsa → iş ikinci kez yapılır
Kafam karıştı, daha basit anlat

Cevap gelmediğinde iki ihtimal var: mektup hiç ulaşmadı ya da ulaştı ama cevap yolda kayboldu. Senin tarafından ikisi aynı görünür.

Kendin gör

Idempotency — ağ güvenilmezken tekrar denemek

Tohum 1
POST /orders

Denemeler

henüz gönderilmedi

Sunucudaki kayıtlar

— kayıt yok —

Hız
Adım 0

Şu an ne oldu?

1. deneme gönderiliyor

İstemci isteği ilk kez gönderiyor.

Aklında kalsın: Retry mekanizması kaçınılmazdır — timeout, yeniden başlatma, yük dengeleyici hepsi tekrar üretir. Soru retry yapılıp yapılmayacağı değil, tekrarın güvenli olup olmadığıdır.

Olay günlüğü (0)

Henüz olay yok. Oynat veya adımla.

Varsayılan: anahtarsız POST, cevap kayboluyor, üç deneme.

  • Varsayılanı çalıştır. Sunucuda üç sipariş var, ikisi kopya. İstemci başarı aldı. Hiçbir yerde hata logu yok.
  • Ağ hatası → İstek kayboldu yap. Tek sipariş. İstek sunucuya hiç ulaşmadığı için tekrar zararsız oldu.
  • POST + Idempotency-Key seç, cevap kaybolmaya devam etsin. Sunucu anahtarı tanıyor, işi tekrar yapmıyor. Tek sipariş.
  • PUT ve DELETE dene. Kaç kez tekrarlanırsa tekrarlansın sunucu durumu aynı.
Hızlı kontrolBaşlangıç

Idempotent ile safe (güvenli) HTTP metodu arasındaki fark nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

İki kez gelse de bir kez yap

Idempotent: aynı isteği bir kez ya da beş kez göndermek, sunucu durumunu aynı bırakır.

İlk DELETE /orders/42 çağrısı 204 döndü, aynı çağrının tekrarı 404. Cevaplar farklı. DELETE yine de idempotent mi? Cevabı göster

Evet. Idempotency aynı cevabı değil, aynı durumu ister. İki çağrıdan sonra da durum aynıdır: sipariş 42 yok.

MetotGüvenli (safe)Idempotent
GETEvetEvet
HEADEvetEvet
PUTHayırEvet
DELETEHayırEvet
POSTHayırHayır
PATCHHayırDuruma göre

PATCH ilginç olanıdır. {"total": 100} idempotenttir. {"op": "increment", "by": 10} değildir — her tekrar başka bir sonuç üretir.

Kafam karıştı, daha basit anlat

Idempotent, “aynı isteği beş kez göndersen de sonuç bir kez göndermişsin gibi” demektir. Asansör düğmesine beş kez basmak asansörü beş kez çağırmaz.

Hızlı kontrolBaşlangıç

Her HTTP metodunu idempotent olup olmadığına göre yerleştir.

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Sınıflandırılmamış

Idempotent

Tekrarlamak sunucu durumunu değiştirmez

    Idempotent değil

    Her tekrar yeni bir etki üretir

      POST için çözüm: Idempotency-Key.

      İstemci sabit bir anahtar üretir
      POST /orders
      Idempotency-Key: 8f14e45f-ea3b-4f0c-9bc2-7a1d2e3f4a5b
      Content-Type: application/json
      { "items": [...] }

      Sunucu tarafı üç adımdır:

      1. Anahtarı daha önce gördün mü? Gördüysen işi yapma, sakladığın cevabı döndür.
      2. Görmediysen işi yap.
      3. Anahtarı sonucuyla birlikte sakla (bir TTL ile, örneğin 24 saat).
      OrderController.java
      @PostMapping("/orders")
      public ResponseEntity<OrderResponse> create(
      @RequestHeader("Idempotency-Key") String key,
      @RequestBody CreateOrder request) {
      return idempotencyStore.find(key)
      .orElseGet(() -> idempotencyStore.save(key, orderService.create(request)));
      }

      Kaydı saklarken yarış durumuna da dikkat: iki eşzamanlı istek aynı anahtarla gelebilir. Anahtar sütununa unique index koymak en basit ve en sağlam çözümdür.

      Bankada bu bir havaledir: mobil uygulama “Gönder”e basıldığında bir anahtar üretir, zayıf bağlantıda aynı anahtarla tekrar dener. Aşağıdaki servis parayı yalnızca bir kez gönderir.

      Derinleş · Havale: aynı anahtar, tek gönderim 5 dosya · ~94 satır · ilk okumada atlayabilirsin
      Proje dosyaları

      src/main/resources/db/migration/ V12__transfer_idempotency_keys.sql Tablo: anahtar müşteri başına tekildir. Birincil anahtar, eşzamanlı iki isteğin ikisinin birden kazanmasını imkânsız kılar.

      src/main/resources/db/migration/V12__transfer_idempotency_keys.sql
      CREATE TABLE transfer_idempotency_key (
      customer_no VARCHAR(20) NOT NULL,
      idem_key VARCHAR(64) NOT NULL,
      request_hash CHAR(64) NOT NULL, -- SHA-256 of the canonical request body
      transfer_id UUID NOT NULL,
      response_body JSONB NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
      -- Keys are scoped per customer: two customers may pick the same UUID-shaped key.
      PRIMARY KEY (customer_no, idem_key)
      );
      -- A nightly job deletes rows past the retention window the API documents.
      CREATE INDEX idx_transfer_idem_created_at ON transfer_idempotency_key (created_at);

      src/main/java/com/bank/transfer/api/ TransferController.java Controller: anahtarı başlıktan zorunlu alır, müşteriyi token'dan okur. İstemcinin gövdedeki müşteri numarasına güvenmez.

      src/main/java/com/bank/transfer/api/TransferController.java
      @RestController
      @RequestMapping("/transfers")
      class TransferController {
      private final IdempotentTransfers transfers;
      TransferController(IdempotentTransfers transfers) {
      this.transfers = transfers;
      }
      @PostMapping
      ResponseEntity<TransferResponse> send(
      @AuthenticationPrincipal Jwt token,
      @RequestHeader("Idempotency-Key") @Size(min = 16, max = 64) String key,
      @Valid @RequestBody TransferRequest request) {
      // The customer comes from the verified token, never from the request body.
      String customerNo = token.getClaimAsString("customer_no");
      TransferResponse response = transfers.execute(customerNo, key, request);
      return ResponseEntity.status(HttpStatus.CREATED).body(response);
      }
      }

      src/main/java/com/bank/transfer/ IdempotentTransfers.java Koordinatör: önce ilk denemeyi dener. Anahtar zaten varsa saklanan cevabı döner; aynı anahtar farklı bir gövdeyle geldiyse reddeder.

      src/main/java/com/bank/transfer/IdempotentTransfers.java
      // Deliberately not @Transactional: a failed insert aborts a PostgreSQL transaction,
      // so the duplicate check has to catch the error outside of it.
      @Service
      class IdempotentTransfers {
      private final FirstAttempt firstAttempt;
      private final IdempotencyKeys keys;
      IdempotentTransfers(FirstAttempt firstAttempt, IdempotencyKeys keys) {
      this.firstAttempt = firstAttempt;
      this.keys = keys;
      }
      TransferResponse execute(String customerNo, String key, TransferRequest request) {
      String hash = RequestHash.sha256(request);
      try {
      return firstAttempt.run(customerNo, key, hash, request);
      } catch (DuplicateKeyException alreadySeen) {
      // A concurrent twin waits on the primary key until the winner commits,
      // so by the time we get here the stored response is complete.
      StoredResponse stored = keys.find(customerNo, key).orElseThrow();
      if (!stored.requestHash().equals(hash)) {
      throw new IdempotencyKeyReusedException(key);
      }
      return stored.body();
      }
      }
      }

      src/main/java/com/bank/transfer/ FirstAttempt.java İlk deneme: anahtarı yazmak, parayı göndermek ve cevabı saklamak tek transaction. Gönderim başarısız olursa anahtar da geri alınır, istemci güvenle tekrar dener.

      src/main/java/com/bank/transfer/FirstAttempt.java
      // A separate bean so the call from IdempotentTransfers goes through the proxy
      // and @Transactional applies.
      @Service
      class FirstAttempt {
      private final IdempotencyKeys keys;
      private final TransferService transfers;
      FirstAttempt(IdempotencyKeys keys, TransferService transfers) {
      this.keys = keys;
      this.transfers = transfers;
      }
      // Claim the key, move the money, store the answer: all three commit or none do.
      @Transactional
      TransferResponse run(String customerNo, String key, String hash, TransferRequest request) {
      UUID transferId = UUID.randomUUID();
      keys.claim(customerNo, key, hash, transferId); // INSERT; throws on a duplicate
      TransferResponse response = transfers.post(transferId, customerNo, request);
      keys.storeResponse(customerNo, key, response);
      return response;
      }
      }

      src/main/java/com/bank/transfer/ IdempotencyKeyReusedException.java Aynı anahtarın başka bir havale için kullanılması istemci hatasıdır: 422.

      src/main/java/com/bank/transfer/IdempotencyKeyReusedException.java
      // Same key, different transfer: the client has a bug, retrying will not help.
      @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
      class IdempotencyKeyReusedException extends RuntimeException {
      IdempotencyKeyReusedException(String key) {
      super("idempotency key " + key + " was already used for a different transfer");
      }
      }
      Hızlı kontrolOrta

      Aynı `Idempotency-Key` ile iki istek geliyor. İkinci istek ne alır?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.
      odeme.http
      1POST /payments
      2Idempotency-Key: 7f3a-91bc
      3{ "tutar": 250, "siparis": 42 }
      4--> 201 Created { "id": "pay_881" }
      5
      6POST /payments
      7Idempotency-Key: 7f3a-91bc
      8{ "tutar": 250, "siparis": 42 }
      9--> ?
      HTTPUTF-8LF

      Idempotency-Key'i kim üretmelidir?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      Durum kodları ve kaynak adlandırma

      KodNe zaman
      200 OKBaşarılı GET, PUT, PATCH
      201 CreatedYeni kaynak oluştu — Location başlığı ekle
      202 AcceptedKabul edildi, henüz işlenmedi (asenkron)
      204 No ContentBaşarılı ama dönecek gövde yok — tipik DELETE
      400 Bad Requestİstek biçimsel olarak hatalı
      401 / 403Kimlik yok / yetki yok
      404 Not FoundKaynak yok
      409 ConflictDurum çakışması — örneğin aynı e-posta zaten kayıtlı
      422 UnprocessableBiçim doğru, iş kuralı ihlal edildi
      429 Too Many RequestsHız sınırı — Retry-After başlığı ekle

      400 ile 422 ayrımı ince ama işe yarar: 400 “bu JSON’u okuyamadım”, 422 “okudum ama bu tarih geçmişte olamaz”.

      Kaynak adlandırma.

      ✕ GET /getUserOrders?userId=42
      ✓ GET /users/42/orders
      ✕ POST /orders/42/cancel
      ✓ POST /orders/42/cancellation (iptal bir kaynak)
      veya
      ✓ PATCH /orders/42 { "status": "CANCELLED" }

      Kural: URL’de isim, metotta fiil. Ama dogmatik olmamak gerekir — bazı işlemler gerçekten fiildir (/orders/42/refund) ve zorlama bir kaynak icat etmek okunabilirliği düşürür.

      Kafam karıştı, daha basit anlat

      400, “yazdığını okuyamadım” demektir. 422 ise “okudum ama içindeki bilgi kurala uymuyor”. İkisi de istemcinin düzeltebileceği hatalardır.

      Hızlı kontrolBaşlangıç

      `PUT /orders/42` ile `PATCH /orders/42` arasındaki fark nedir?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      Sayfalama ve tuzakları

      Offset (?page=3&size=20)Cursor (?after=eyJpZCI6MTAwfQ)
      UygulamasıKolayDaha zor
      Rastgele sayfaya atlamaMümkünHayır
      Büyük offset performansıKötü — DB satırları sayarSabit
      Araya kayıt eklenirseKayıtlar kayar, tekrar/atlama olurKararlı

      Sonsuz kaydırma veya büyük veri için cursor"Şu kayıttan sonrakiler" diyerek sayfalama. Offset'in aksine sabit maliyetlidir ve araya kayıt girse bile atlama yapmaz; bedeli rastgele bir sayfaya atlayamamaktır.Sözlükte gör →; yönetim panelleri gibi sayfa numarası gereken yerlerde offset.

      Kendini sına

      Şimşek turu1/5

      Zaman aşımı alan istemci, isteğin sunucuya ulaşıp ulaşmadığını bilemez.

      Soru 1/4Başlangıç

      Bir isteğin cevabı ağda kayboldu. İstemci için bu durum, isteğin hiç ulaşmamasından nasıl ayırt edilir?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      Aklında kalacak üç şey

      1. 1 Idempotency aynı cevabı döndürmek değil, sunucuyu aynı durumda bırakmaktır. İkinci DELETE 404 dönebilir ve yine de idempotenttir.
      2. 2 Tehlikeli durum şu: cevap kaybolduğunda sunucu işi yapmıştır ama istemci bilmez. İstemci için kaybolan istek ile kaybolan cevap aynı görünür.
      3. 3 POST için çözüm: istemci bir Idempotency-Key üretir, sunucu anahtarı sonucuyla saklar ve aynı anahtar gelirse işi yapmadan aynı cevabı döner.
      Sonraki kapı Tabloya indeks ekledin ama sorgu hâlâ yavaş. Veritabanı indeksi neden görmezden gelir? SQL İndeksleme ve EXPLAIN · 10 dk

      5 kart sonraki derste seni bekliyor

      0/5 kart bu dersten toplandı

      Bu dersin üstüne kurulanlar

      Bunlar bu dersi temel alıyor; hazır olduğunda devam edebilirsin.