Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi REST API Tasarımı — #07

Concurrency, Idempotency ve Uzun Süren İşlemler: ETag/If-Match, Idempotency-Key, 202 Accepted ve Status Monitor

Mert Özen 15 Kas 2026 13 dk 7 görüntülenme
Concurrency, Idempotency ve Uzun Süren İşlemler: ETag/If-Match, Idempotency-Key, 202 Accepted ve Status Monitor

Eşzamanlı ve tekrarlanan yazmalara karşı contract'ta üç araç var: var olan resource'u değiştiren her istekte zorunlu If-Match (yoksa 428), oluşturan POST'larda Idempotency-Key ve saniyeler süren işler için 202 Accepted ile bir status monitor. Bookstore'da sessizce kaybolan bir fiyat indirimini, aynı anahtarla tekrar gönderilen siparişi, 409 ve 422 durumlarını ve fatura PDF'inin başarılı, başarısız ve iptal edilen LRO akışlarını gerçek yanıtlarla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (ASP.NET Core 10.0.15) ile derlenip denenmiştir; HTTP örnekleri bu uygulamanın gerçek yanıtlarıdır, OpenAPI 3.2 dokümanı Redocly CLI 2.78.0 ve Spectral 6.17.1 ile lint edilmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) back office ekranında Ayşe, Dune'un düzenleme formunu açıyor ve kitaba bir açıklama yazıyor. O sırada, aynı kullanıcı hesabıyla çalışan gece fiyat güncelleme job'ı Dune'un fiyatını 459 TL'ye indiriyor. Ayşe formu kaydediyor. Bir önceki yazının sonundaki API'de olan şu:

$ curl -s -i http://127.0.0.1:52318/books/9780441172719 | grep -E '^ETag|^\{'
ETag: "1"
{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","authors":["Frank Herbert"],"category":"science-fiction","price":{"amount":489.00,"currency":"TRY"},"inStock":true}

$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"price":{"amount":459.00,"currency":"TRY"}}' http://127.0.0.1:52318/books/9780441172719 | jq -c '{title, price}'
{"title":"Dune","price":{"amount":459.00,"currency":"TRY"}}

$ curl -s -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"title":"Dune","author":"Frank Herbert","category":"science-fiction","price":{"amount":489.00,"currency":"TRY"},"description":"The first novel of the Dune saga."}' http://127.0.0.1:52318/books/9780441172719 | jq -c '{title, price, description}'
{"title":"Dune","price":{"amount":489.00,"currency":"TRY"},"description":"The first novel of the Dune saga."}

$ curl -s -i http://127.0.0.1:52318/books/9780441172719 | grep -E '^ETag'
ETag: "3"

Hiçbir istek hata almadı, ama fiyat indirimi kayboldu: form, açıldığı andaki 489 TL'yi geri yazdı. API üçüncü yazıdan beri her kitapta bir ETag dönüyor ve If-Match gelirse kontrol ediyordu. Sorun nerede: Ayşe'nin formunda mı, job'da mı, contract'ta mı? Aynı hafta Ada'nın mobil uygulamasında "Siparişi ver" düğmesi, zaman aşımına uğrayan isteği kendiliğinden tekrar gönderiyor. İlk istek aslında başarılı olmuştu. Bu iki problemin ortak noktası ne?

Optimistic Concurrency'nin Contract'ı

İki problemin ortak noktası, istemcinin server'daki durumu bilmeden yazması. Lost update'te (bir yazmanın arada yapılmış başka bir yazmanın üzerine sessizce geçmesi) istemci, okuduğundan beri resource'un değişip değişmediğini bilmiyor. Tekrar gönderilen siparişte ise ilk isteğin sonucunu bilmiyor. HTTP'nin birincisine cevabı conditional request: istemci okuduğu sürümün ETag'ini If-Match ile geri gönderir, sürüm değişmişse server 412 Precondition Failed döner. Bu mekanizma HTTP'nin RFC'lerinden (Request for Comments; internet standartlarının numaralı dokümanları) 9110 numaralı HTTP Semantics'te tanımlı ve Bookstore'da üçüncü yazıdan beri vardı. Eksik olan, If-Match'in opsiyonel olmasıydı. Gece job'ı da Ayşe'nin formu da header göndermediği için kontrol hiç çalışmadı.

Opsiyonel bir koruma, korumak istediğin istemciyi korumaz. RFC 6585 tam bu durum için 428 Precondition Required'ı tanımlar: server isteğin conditional olmasını şart koşar ve lost update'i önlemek bu status code'un tanımında örnek olarak geçer. Bookstore'un yeni kuralı (BS-60): var olan bir resource'u değiştiren her PUT, PATCH ve DELETE, If-Match göndermek zorunda.

$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -H 'If-Match: "1"' -d '{"price":{"amount":459.00,"currency":"TRY"}}' http://127.0.0.1:52318/books/9780441172719 | jq -c '{title, price}'
{"title":"Dune","price":{"amount":459.00,"currency":"TRY"}}

$ curl -s -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"title":"Dune","author":"Frank Herbert","category":"science-fiction","price":{"amount":489.00,"currency":"TRY"},"description":"The first novel of the Dune saga."}' http://127.0.0.1:52318/books/9780441172719 | jq -c '{status, title, detail}'
{"status":428,"title":"Precondition required","detail":"Send If-Match with the ETag from your last GET."}

$ curl -s -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -H 'If-Match: "1"' -d '{"title":"Dune","author":"Frank Herbert","category":"science-fiction","price":{"amount":489.00,"currency":"TRY"},"description":"The first novel of the Dune saga."}' http://127.0.0.1:52318/books/9780441172719 | jq -c '{status, title, detail}'
{"status":412,"title":"Precondition failed","detail":"If-Match \"1\" does not match the current ETag \"2\"."}

Job artık okuduğu ETag'i gönderiyor ve fiyatı değiştiriyor. Ayşe'nin formu header göndermezse 428, formun açıldığı andaki "1"'i gönderirse 412 alıyor. Angular ekranının 412'de ne yapacağı (güncel hâli gösterip iki değişikliği birleştirmeyi kullanıcıya bırakan bir çakışma ekranı) ve ETag'in veritabanındaki rowversion'dan nasıl üretildiği Çözüm Desenleri serisinin "Optimistic Concurrency Uçtan Uca: rowversion'dan ETag'e, If-Match ve 412 ile Çakışma Ekranı" yazısında. Contract tarafında üç karar kalıyor. Birincisi, yeni bir kitabı PUT ile oluştururken If-Match istenmiyor, çünkü eşleşecek bir sürüm yok. İkincisi, 428 contract'a eklendiği için bir breaking change: header göndermeyen bütün yazan istemciler kırılır. Bookstore'da kitapları yalnız back office yazdığı için bunu bir sürüm açmadan, ekipçe aynı yayında yaptık; dış tüketicisi olan bir resource'ta bu değişiklik 06. yazıdaki gibi yeni bir api-version isterdi. Üçüncüsü, If-Match: * hâlâ kabul ediliyor; bu "resource var olduğu sürece yaz" demek ve bilinçli bir üzerine yazma için kaçış yolu.

Idempotency-Key: Tekrar Gönderilen POST

Ada'nın siparişi farklı bir problem. PUT ve DELETE RFC 9110'a göre idempotent: aynı isteği iki kez göndermek bir kez göndermekle aynı sonucu doğurur, bu yüzden istemci zaman aşımında güvenle tekrar gönderebilir. POST idempotent değil; ikinci POST /orders ikinci bir sipariş demek. İstemci de ilk isteğin sunucuya ulaşıp ulaşmadığını, ulaştıysa işlenip işlenmediğini bilemez. Idempotency-Key bunu çözüyor: istemci her yeni işlem için benzersiz bir anahtar üretir ve tekrar gönderdiği istekte aynı anahtarı kullanır. Server anahtarı ilk kez görüyorsa isteği işler, daha önce görmüşse ilk sonucu tekrar döner.

Header'ın standardı henüz yok. IETF'in (internet standartlarını geliştiren kuruluş) HTTP API'leri için kurduğu çalışma grubunun taslağı 22 Ekim 2026'da -08 sürümüne geldi; datatracker'da durumu "Active Internet-Draft" ve çalışma grubu dokümanı, yani RFC değil ve değişebilir. Taslağa göre değer bir Structured Field String, yani tırnak içinde yazılır; anahtarın ne kadar saklanacağını server belirler ve dokümante eder. Hata durumları da taslakta tanımlı: anahtar gerektiren bir işlemde anahtar yoksa 400, aynı anahtar farklı bir body ile gelirse 422, ilk istek hâlâ işlenirken aynı anahtar gelirse 409. Bookstore bunları birebir uyguluyor ve her durum için katalogda bir problem türü var:

$ KEY='"5f0c6e1a-8b2d-4c3e-9a71-2d4b8e6f1c90"'

$ curl -i -X POST -H "Authorization: Bearer ada" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"items":[{"isbn":"9780441569595","quantity":1}]}' http://127.0.0.1:52318/orders
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:22:24 GMT
Server: Kestrel
Content-Language: en
Location: /orders/01a1dc5e-871b-7891-a6d3-00a058028c9a
Transfer-Encoding: chunked
Api-Version: 2026-09-01
Deprecation: @1794700800
Sunset: Mon, 17 May 2027 00:00:00 GMT
Link: <https://api.example.com/docs/versions/2026-09-01>; rel="deprecation"; type="text/html"

{"id":"01a1dc5e-871b-7891-a6d3-00a058028c9a","status":"Pending","customerId":"ada","createdAt":"2026-11-14T23:22:24.923353Z","items":[{"isbn":"9780441569595","title":"Neuromancer","quantity":1,"unitPrice":{"amount":279.00,"currency":"TRY"}}],"total":{"amount":279.00,"currency":"TRY"}}

$ curl -s -X POST -H "Authorization: Bearer ada" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"items": [{"isbn": "9780441569595", "quantity": 1}]}' http://127.0.0.1:52318/orders | jq -c '{id, status}'
{"id":"01a1dc5e-871b-7891-a6d3-00a058028c9a","status":"Pending"}

$ curl -s -X POST -H "Authorization: Bearer ada" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"items":[{"isbn":"9780441569595","quantity":2}]}' http://127.0.0.1:52318/orders | jq -c '{status, title, detail}'
{"status":422,"title":"Idempotency-Key is already used","detail":"This Idempotency-Key was used with a different request body; use a new key for a new request."}

$ KEY2='"0d3f9b52-7a1e-4c86-b2f4-91e7c05a3d68"'

$ curl -s -o first.json -X POST -H "Authorization: Bearer ada" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY2" -d '{"items":[{"isbn":"9780261102217","quantity":1}]}' http://127.0.0.1:52318/orders & sleep 0.3; curl -s -X POST -H "Authorization: Bearer ada" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY2" -d '{"items":[{"isbn":"9780261102217","quantity":1}]}' http://127.0.0.1:52318/orders | jq -c '{status, title}'; wait; jq -c '{id, status}' first.json
{"status":409,"title":"A request with this Idempotency-Key is in progress"}
{"id":"01a1dc5e-8d2a-7717-bdb1-07294e620c02","status":"Pending"}

$ curl -s -X POST -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"items":[{"isbn":"9780441569595","quantity":1}]}' http://127.0.0.1:52318/orders | jq .
{
  "type": "https://api.example.com/problems/idempotency-key-missing",
  "title": "Idempotency-Key is missing",
  "status": 400,
  "detail": "This operation requires an Idempotency-Key header (a quoted string, e.g. a UUID); see the type URI.",
  "instance": "/orders",
  "traceId": "00-da58baa56ac4b046f7373f7c227114c9-9ea8c2fddddd9d14-00"
}

$ curl -s -H "Authorization: Bearer ada" "http://127.0.0.1:52318/orders?pageSize=3" | jq -c '.items[] | {id, status, total: .total.amount}'
{"id":"01a1dc5e-8d2a-7717-bdb1-07294e620c02","status":"Pending","total":349.00}
{"id":"01a1dc5e-871b-7891-a6d3-00a058028c9a","status":"Pending","total":279.00}
{"id":"01a1cfa1-0a00-7b74-ace2-8223a65ed389","status":"Confirmed","total":568.00}

Örneklerdeki Deprecation ve Sunset header'ları, sürüm göndermeyen istemciye 06. yazıda verdiğimiz uyarı. İkinci istek boşluklarla farklı yazılmış bir JSON, ama ilk siparişin kimliğiyle dönüyor; tekrarın tespiti için kullandığımız fingerprint ham byte'lardan değil, bind edilmiş request'ten hesaplanıyor. Taslak fingerprint'in nasıl üretileceğini server'a bırakır; payload'ın tamamının checksum'ı da seçilmiş alanların karşılaştırılması da olur. Ham byte'ları seçseydik aynı siparişi farklı biçimlendiren bir istemci 422 alırdı. Paralel gelen ikinci istek 409 alıyor ve ilk istek bittiğinde tek bir sipariş var; liste de bunu gösteriyor.

Contract'a yazdığımız kurallar (BS-61, BS-62): anahtar zorunlu, çağırana ve operation'a bağlı (Ada'nın anahtarı Grace'inkiyle çakışmaz), 24 saat saklanıyor. Server hataları saklanmıyor: işlem bir exception'la yarıda kaldıysa bir şey kaydedilmemiştir ve tekrar deneme baştan çalışır. 4xx sonuçlar ise saklanıyor; stok yetersizliği nedeniyle 422 alan bir istek aynı anahtarla tekrar gönderilirse, arada stok gelmiş olsa bile aynı 422'yi alır. Bu bilinçli bir seçim: aynı anahtar her zaman aynı cevabı alır. Anahtar tablosunun veritabanında nasıl tutulduğu, transaction sınırı ve temizliği Çözüm Desenleri serisinin "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" yazısında. Anahtarı gönderen taraf, yani bir dış servise idempotent çağrı yapmak ve "sonucu bilinmeyen" isteği yönetmek, External Service Integration ve Resilience serisinde anlatılıyor. Bu header'ın hassas iş akışlarındaki güvenlik tarafı da API Güvenliği serisinin hassas iş akışları yazısında.

Uzun Süren İşlemler: 202 Accepted

Kurumsal müşteriler her sipariş için PDF fatura istiyor. Faturayı üretmek muhasebe sistemine gidip vergi bilgilerini almak, PDF'i oluşturmak ve arşive yazmak demek; Bookstore'da birkaç saniye sürüyor. Bunu senkron bir GET ya da POST içinde yapmak, okulun entegrasyonundaki 2 saniyelik zaman aşımına, gateway'lerin bağlantı süresi sınırlarına ve "zaman aşımı aldım, iş yapıldı mı?" sorusuna takılmak demek. RFC 9110'daki 202 Accepted bu durum için var: istek kabul edildi ama işlem bitmedi. RFC bu status code'un bilinçli olarak bir şey taahhüt etmediğini söyler ve response'un işlemin durumunu anlatmasını ve bir status monitor'ü göstermesini önerir. Status monitor'ün nasıl görüneceğini ise HTTP söylemez; burada rehberler devreye giriyor.

07-lro-flow-tr.png

$ curl -s -i -X POST -H "Authorization: Bearer linus" http://127.0.0.1:52318/orders/01a1a612-9c80-7e46-8590-67e08b9d2434/invoices | tee accepted.txt
HTTP/1.1 202 Accepted
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:22:40 GMT
Server: Kestrel
Content-Language: en
Retry-After: 1
Transfer-Encoding: chunked
Api-Version: 2026-09-01
Deprecation: @1794700800
Sunset: Mon, 17 May 2027 00:00:00 GMT
Link: <https://api.example.com/docs/versions/2026-09-01>; rel="deprecation"; type="text/html"
Operation-Location: /operations/01a1dc5e-c688-7d43-9846-be76e9816e1a

{"id":"01a1dc5e-c688-7d43-9846-be76e9816e1a","kind":"invoice-generation","status":"NotStarted","percentComplete":0,"createdAt":"2026-11-14T23:22:41.160705Z","lastUpdatedAt":"2026-11-14T23:22:41.160705Z"}

$ OP=$(tail -1 accepted.txt | jq -r .id); sleep 1

$ curl -s -i -H "Authorization: Bearer linus" http://127.0.0.1:52318/operations/$OP | grep -E '^(HTTP|Retry-After|\{)'
HTTP/1.1 200 OK
Retry-After: 1
{"id":"01a1dc5e-c688-7d43-9846-be76e9816e1a","kind":"invoice-generation","status":"Running","percentComplete":25,"createdAt":"2026-11-14T23:22:41.160705Z","lastUpdatedAt":"2026-11-14T23:22:41.913127Z"}

$ curl -s -X POST -H "Authorization: Bearer linus" http://127.0.0.1:52318/orders/01a1a612-9c80-7e46-8590-67e08b9d2434/invoices | jq -c '{status, type, operationLocation}'
{"status":409,"type":"https://api.example.com/problems/invoice-already-requested","operationLocation":"/operations/01a1dc5e-c688-7d43-9846-be76e9816e1a"}

$ sleep 3; curl -s -H "Authorization: Bearer linus" http://127.0.0.1:52318/operations/$OP | jq .
{
  "id": "01a1dc5e-c688-7d43-9846-be76e9816e1a",
  "kind": "invoice-generation",
  "status": "Succeeded",
  "percentComplete": 100,
  "createdAt": "2026-11-14T23:22:41.160705Z",
  "lastUpdatedAt": "2026-11-14T23:22:44.172468Z",
  "resourceLocation": "/orders/01a1a612-9c80-7e46-8590-67e08b9d2434/invoices/01a1dc5e-d249-7eba-be96-e00a01730945"
}

$ curl -s -D - -o invoice.pdf -H "Authorization: Bearer linus" "http://127.0.0.1:52318$(curl -s -H "Authorization: Bearer linus" http://127.0.0.1:52318/operations/$OP | jq -r .resourceLocation)" | head -3; file invoice.pdf
HTTP/1.1 200 OK
Content-Length: 650
Content-Type: application/pdf
invoice.pdf: PDF document, version 1.4, 1 pages

Tasarım Azure rehberinin status monitor'üne dayanıyor: 202, monitor'ün adresini taşıyan Operation-Location header'ı, bir sonraki sorgudan önce kaç saniye bekleneceğini söyleyen Retry-After ve NotStarted, Running, Succeeded, Failed, Canceled durumları. Neden Location değil de ayrı bir header? RFC 9110, Location'ın anlamını 201'de oluşturulan resource ve 3xx'te yönlendirilecek adres olarak tanımlar; 202 ile birlikte ne demek olduğu tanımsızdır. Monitor bitmiş işin sonucunu resourceLocation'da veriyor; işlem devam ederken her yanıtta Retry-After var, bitince yok. Aynı siparişe ikinci istek ikinci bir PDF üretmiyor; 409 ve çalışan operation'ın adresini alıyor. Bu, faturalama için doğal bir idempotency: "bir siparişin bir faturası olur" kuralı anahtarın işini görüyor.

Google'ın AIP'lerinden (API Improvement Proposals; Google'ın numaralı API tasarım kuralları) 151 numaralı olan aynı problemi başka bir şekille çözer: uzun süren method, bitince done alanı true olan, sonucu response'ta ya da hatayı error'da, ilerlemeyi metadata'da taşıyan ortak bir Operation resource'u döner ve bütün API'ler aynı Operations servisini kullanır. AIP-151'in kaba ölçüsü 10 saniyedir: bundan uzun sürebilecek işler LRO (long-running operation; uzun süren işlem) olmalıdır. İki yaklaşımın ortak fikri, işin kendisini değil işin durumunu bir resource yapmak. Bookstore bir noktada Azure'dan ayrılıyor: Azure, uzun süren bir oluşturma işleminin POST ile değil, istemcinin seçtiği kimlikle PUT ile başlatılmasını ister; bu sayede tekrar gönderilen istek idempotent olur. Biz faturanın kimliğini server'da ürettiğimiz için POST'u seçtik ve tekrar korumasını "sipariş başına bir fatura" kuralıyla sağladık. Bu sapmayı bir ADR (Architecture Decision Record; mimari karar kaydı) olarak ADR-0005'e yazdık.

Başarısızlık, İptal ve Saklama Süresi

Bir işlem 202'yi aldıktan sonra da başarısız olabilir ve bu artık bir HTTP status code'uyla bildirilemez; RFC 9110 da HTTP'de asenkron bir işlemin status code'unu sonradan göndermenin bir yolu olmadığını hatırlatır. Hata, monitor'ün içinde bir Problem Details olarak duruyor. Başlamadan önce bilinen hatalar ise (iptal edilmiş siparişe fatura gibi) 202 yerine doğrudan normal bir hata yanıtı alıyor; AIP-151 de işlemin başlamasını engelleyen hataların normal hata yanıtıyla dönmesini ister.

$ OP2=$(curl -s -X POST -H "Authorization: Bearer grace" http://127.0.0.1:52318/orders/01a18d45-6de0-707a-8dfe-2a2217fc695a/invoices | jq -r .id); sleep 3; curl -s -H "Authorization: Bearer grace" http://127.0.0.1:52318/operations/$OP2 | jq -c '{status, percentComplete, error}'
{"status":"Failed","percentComplete":25,"error":{"type":"https://api.example.com/problems/invoice-details-missing","title":"Invoice details missing","status":422,"detail":"Institution city-library has no tax number on file; ask your account manager to add it."}}

$ OP3=$(curl -s -X POST -H "Authorization: Bearer ada" http://127.0.0.1:52318/orders/01a06655-e3a0-7a3b-8199-c6b41c80317f/invoices | jq -r .id); sleep 1; curl -s -X POST -H "Authorization: Bearer ada" "http://127.0.0.1:52318/operations/${OP3}:cancel" | jq -c '{status, percentComplete}'
{"status":"Canceled","percentComplete":25}

$ curl -s -X POST -H "Authorization: Bearer ada" "http://127.0.0.1:52318/operations/${OP3}:cancel" | jq -c '{status, detail}'
{"status":409,"detail":"Operation 01a1dc5e-e26c-70e3-b95b-406f1f7f7779 has already ended with Canceled."}

$ curl -s -H "Authorization: Bearer ada" http://127.0.0.1:52318/operations/$OP | jq -c '{status, title}'
{"status":404,"title":"Not found"}

City Library'nin kayıtlı vergi numarası yok ve bunu iş, muhasebe sistemine gittiğinde öğreniyor. error içindeki problem türü katalogdaki diğerleriyle aynı biçimde; istemci 04. yazıdaki interceptor'ın kullandığı kodla bu hatayı da gösterebilir. İptal, ikinci yazıdaki custom method kuralıyla POST /operations/{operationId}:cancel: çalışan işi durduruyor, bitmiş bir işte 409 dönüyor. Son istekte Ada, Linus'un başlattığı operation'ı soruyor ve 404 alıyor: monitor yalnız işlemi başlatan kişiye görünüyor, çünkü içinde sipariş ve fatura bilgisi var.

Saklama süresi de contract'ın parçası (BS-64). Azure rehberi monitor'ün iş bittikten sonra en az 24 saat saklanmasını ve bu sürenin dokümante edilmesini ister; AIP-151 süresi dolmuş operation'ların silinmesine izin verir ve kaba bir ölçü olarak 30 gün önerir. Bookstore 24 saat sonra monitor'ü siliyor ve 404 dönüyor. Bu sürenin yazılı olması önemli: süresiz saklanan monitor'ler bir depolama sorununa, belgelenmemiş bir saklama süresi de "dün çalışıyordu" şikâyetlerine dönüşür. İstemci, sonuca ulaştıktan sonra monitor'ün adresini değil resourceLocation'ı saklamalı.

ASP.NET Core'da

Idempotency-Key, POST /orders'a eklenmiş bir endpoint filter: anahtarı ve bind edilmiş request'in hash'ini okuyor, ilk istekte handler'ı çalıştırıp dönen IResult'ı saklıyor, tekrarda aynı IResult'ı tekrar çalıştırıyor. Endpoint filter'lar model binding'den sonra çalıştığı için request body'yi tekrar okumak yerine context.Arguments içindeki bind edilmiş nesneyi kullanmak gerekiyor; endpoint filter'ların ayrıntısı ASP.NET Core Web API serisinin "Minimal API Derinlemesine 2: Route Group'lar, Endpoint Filter'lar, TypedResults Kataloğu ve OpenAPI Metadata'sı" yazısında. Uzun süren işler bir Channel<Operation>'a yazılıyor ve bir BackgroundService onları sırayla çalıştırıyor; iptal, işin CancellationToken'ına bağlı bir CancellationTokenSource ile yapılıyor. Örnekte hem anahtarlar hem operation'lar bellekte; uygulama yeniden başlarsa ya da birden çok instance çalışırsa kaybolurlar. Gerçek bir sistemde anahtarlar bütün instance'ların gördüğü bir veritabanında, işler de kalıcı bir queue'da durur.

Sık Düşülen Tuzaklar

Uzun işi senkron tutmak. "Genelde 3 saniye sürüyor" diye senkron bırakılan bir iş, yoğun bir günde 30 saniye sürer; istemci zaman aşımı alır, tekrar gönderir ve iş iki kez yapılır. Süresi değişken ve dışa bağımlı işler baştan 202 ile tasarlanmalı.

Monitor'ü sonsuza kadar saklamak ya da süresini yazmamak. Her işlem bir kayıt bırakır; süre dokümante edilmemişse istemciler monitor adresini kalıcı bir link gibi kullanır.

If-Match'i opsiyonel bırakmak. Concurrency kontrolü yalnız header gönderen istemcide çalışır, yani en dikkatli istemcide. Kontrol zorunlu değilse lost update'i yapan, header göndermeyen script'tir.

Idempotency anahtarını kullanıcıya değil yalnız değere bağlamak. Anahtar çağırana bağlı değilse, iki farklı kullanıcının aynı anahtarı göndermesi birinin diğerinin siparişini görmesine yol açabilir.

Tekrarı ham body'den tanımak. Aynı JSON'un farklı biçimlendirilmesi ya da property sırası, gerçek bir tekrarı 422'ye çevirir.

Küçük Bir Deneme

★☆☆ Retry-After'a uyan küçük bir polling döngüsü yaz: fatura işlemini başlatsın, monitor terminal bir duruma gelene kadar sorsun ve sonunda PDF'i indirsin. Bekleme süresini nereden okuyorsun?

★★☆ Aynı anahtarla ilk istek bir exception'la yarıda kalırsa ne olduğunu dene: CreateOrder'a geçici bir throw ekle, iki kez gönder. İkinci istek ne alıyor, neden?

★★★ Kurumsal toplu sipariş onayını (Linus'un 40 bekleyen siparişi tek seferde onaylaması) bir LRO olarak tasarla: hangi resource'a hangi method'la başlatılıyor, monitor'de hangi ilerleme bilgisi var, kısmen başarısız olursa error mı dönüyor yoksa başarıyla bitip hangi siparişlerin onaylanmadığını mı söylüyor?

Son alıştırmadaki soru, bir sonraki yazının konusu: tek istekte çok sayıda işlem. Okulların 500 satırlık listeyle toplu sipariş göndermesini, kısmi başarıyı satır bazlı bir raporla anlatmayı, sonucu polling ya da webhook ile bildirmeyi ve rate limit'i bir contract olarak yazmayı tasarlıyoruz.