Toplu işlem (batch, bulk operation) uç noktası (endpoint), istemcinin tek bir HTTP isteğiyle birden çok kaynağı oluşturmasını veya güncellemesini sağlar. Yaygın bir tasarım, koleksiyonun altına batch adlı bir alt kaynak açmaktır: POST /users/batch gövdesinde bir users dizisi alıp hepsini oluşturur, PATCH /users/batch ise updates dizisindeki her öğeyi kimliğine göre günceller. Asıl tasarım kararı URL değil, bir öğe başarısız olduğunda ne olacağıdır: ya isteğin tamamını reddedersiniz (ya hepsi ya hiçbiri) ya da her öğenin sonucunu ayrı ayrı raporlarsınız (kısmi başarı).

Bu yazıda iki gövde biçimini, atomik ve kısmi başarı modellerinin artı ve eksilerini, öğe sayısı sınırını, uzun süren toplu işlerde 202 Accepted kullanımını ve FastAPI ile çalışan bir uygulamayı ele alıyoruz. Tek kaynak üzerindeki uç noktalar için CRUD endpoint tasarımı, serinin tamamı için REST API tasarımı öğrenme rehberi sayfasına bakabilirsiniz.

Toplu işlem ne zaman gerekir

Toplu işlem ne zaman gerekir

Toplu uç nokta bir ihtiyaçtan doğar: bir CSV dosyasından yüzlerce kullanıcı içeri aktarmak, bir yönetim ekranında seçilen elli kaydın durumunu tek tıkla değiştirmek veya iki sistem arasında veri eşitlemek gibi. Bunları tek tek istekle yapmak, her öğe için ayrı bir bağlantı gidiş dönüşü, ayrı kimlik doğrulama ve ayrı hata işleme demektir. Toplu istek bu maliyeti tek isteğe indirir ve "ya hepsi ya hiçbiri" gibi bir bütünlük kuralını sunucunun uygulamasına izin verir; tek tek isteklerle bu kuralı istemcinin kurması çok daha zordur.

Öte yandan toplu uç nokta her API için gerekli değildir. Tekil uç noktalardan farklı bir hata modeli, ayrı sınırlar ve ayrı belgeler ister. İstemcilerin gerçekten çok sayıda öğeyi birlikte işlemesi gerekmiyorsa, tekil uç noktaları iyi tasarlamak daha az karmaşıklık getirir.

İstek gövdeleri

Toplu oluşturmada gövde, her biri tekil POST /users isteğinde göndereceğiniz nesneyle aynı yapıda olan öğelerden oluşan bir dizi taşır. Diziyi doğrudan kök olarak göndermek yerine users gibi adlı bir alanın içine koymak, ileride gövdeye seçenek eklemeyi (ör. bir mod bayrağı) kırıcı değişiklik yapmadan mümkün kılar:

http
POST /users/batch HTTP/1.1
Host: api.ornek.com
Content-Type: application/json

{"users": [{"email": "user1@example.com", "name": "Deniz"}, {"email": "user2@example.com", "name": "Ece"}]}

HTTP/1.1 201 Created
Content-Type: application/json

{"data": [{"id": 2, "email": "user1@example.com", "name": "Deniz", "status": "active"}, {"id": 3, "email": "user2@example.com", "name": "Ece", "status": "active"}]}

Toplu güncellemede her öğe hangi kaynağı değiştireceğini kendi içinde taşımak zorundadır, çünkü URL artık tek bir kaynağı göstermez. Bu yüzden her güncelleme nesnesinde id ve değişecek alanlar bulunur. Bu örnekte İngilizce alan adları kullanıldı; /kullanicilar/batch ve guncellemeler gibi Türkçe adlar da aynı şekilde çalışır.

http
PATCH /users/batch HTTP/1.1
Host: api.ornek.com
Content-Type: application/json

{"updates": [{"id": 1, "status": "active"}, {"id": 2, "status": "inactive"}]}

Burada metot seçimi tekil uç noktalarla aynı mantığı izler: oluşturma için POST, kısmi güncelleme için PATCH. PATCH'in anlamı RFC 5789'a göre hedef URI'deki kaynağa bir değişiklik kümesi uygulamaktır; /users/batch adresini "kullanıcı koleksiyonuna toplu değişiklik uygulayan kaynak" olarak düşündüğünüzde bu kullanım anlamlıdır. Gövde biçimi ise standart bir yama biçimi değil, API'ye özgü bir sözleşmedir; bu yüzden belgede açıkça tarif edilmesi gerekir. Standart yama biçimleri için PUT ve PATCH farkı yazısına bakın.

Ya hepsi ya hiçbiri mi, kısmi başarı mı

Ya hepsi ya hiçbiri mi, kısmi başarı mı: Bir öğe başarısız olursa, HTTP durum kodu, İstemcinin işi, Veri tutarlılığı

Toplu işlemin en önemli tasarım kararı budur ve her iki modelin de yerinde kullanımı vardır.

ÖlçütYa hepsi ya hiçbiri (atomik)Kısmi başarı
Bir öğe başarısız olursaHiçbir değişiklik yapılmazDiğer öğeler işlenir
HTTP durum koduBaşarıda 201 veya 200, hatada 4xxGenellikle 200, öğe sonuçları gövdede
İstemcinin işiHatayı düzeltip tamamını yeniden gönderirBaşarısız öğeleri ayıklayıp yeniden gönderir
Veri tutarlılığıGüçlü, ara durum oluşmazİstemci sonuç listesini doğru okumalı
Uygun olduğu yerBirbirine bağlı kayıtlar, finansal işlemlerBağımsız öğeler, içe aktarma, eşitleme
Yeniden denemeAynı isteği tekrar göndermek anlaşılırBaşarılı öğeler tekrar gönderilmemeli

Atomik model HTTP anlamıyla daha iyi örtüşür: istek bir bütündür, durum kodu bütünün sonucunu söyler. RFC 5789'un PATCH için koyduğu atomiklik kuralı da aynı düşünceye dayanır; yama belgesinin tamamı uygulanamıyorsa hiçbir değişiklik uygulanmaz. Kısmi başarı modeli ise büyük ve birbirinden bağımsız öğe kümelerinde daha pratiktir, çünkü bin kayıtlık bir içe aktarmada tek bir hatalı satır yüzünden işin tamamını reddetmek istemciyi yorar.

Kısmi başarıda dikkat edilmesi gereken nokta, durum kodunun artık her şeyi anlatmamasıdır. Bazı öğeler başarılı, bazıları başarısız olduğunda yanıt genellikle 200 OK olur ve asıl bilgi gövdedeki öğe bazlı sonuç listesindedir. Problem Details belgesi (RFC 9457) bu konuda yol gösterici bir not düşer: birbirinden farklı türdeki birden çok sorunu tek yanıtta taşıyan genel "batch" problem tipleri tanımlamak mümkündür ama bunlar HTTP anlamıyla iyi örtüşmez; farklı türde sorunlar varsa en önemli veya en acil olanın yanıtta temsil edilmesi önerilir. Bu yüzden kısmi başarı modelini seçtiyseniz öğe sonuçlarını hata yanıtı olarak değil, başarılı bir işlemin raporu olarak tasarlamak daha tutarlıdır:

json
{
  "results": [
    {"index": 0, "id": 2, "http_status": 200},
    {"index": 1, "id": 99, "http_status": 404, "error": "Kullanıcı bulunamadı"}
  ],
  "summary": {"succeeded": 1, "failed": 1}
}

Her sonuç öğesinde istekteki sırayı gösteren index alanı bulunması, istemcinin hangi girdinin hangi sonuca karşılık geldiğini kesin olarak eşleştirmesini sağlar; kimlik henüz oluşmamış oluşturma isteklerinde tek güvenilir bağ budur. summary alanı ise istemcinin bütün listeyi taramadan durumu anlamasını sağlar.

FastAPI ile iki modelin uygulaması

Aşağıdaki örnekte toplu oluşturma atomik, toplu güncelleme ise kısmi başarı modeliyle kurulmuştur. Öğe sayısı sınırları Pydantic'in min_length ve max_length kısıtlarıyla şemaya yazılmıştır. Depo olarak bellek içi bir sözlük kullanılıyor; üretimde atomik model veritabanı işlemiyle (transaction) uygulanır, böylece yazma sırasında oluşan bir hata da tüm değişiklikleri geri alır.

python
from typing import Literal
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

MAKS = 100
app = FastAPI()
# Bellek içi örnek depo; üretimde veritabanı ve işlem (transaction) kullanılır.
db = {1: {"id": 1, "email": "ayse@example.com", "name": "Ayşe", "status": "active"}}
son_id = [1]

class YeniKullanici(BaseModel):
    email: str
    name: str

class TopluOlustur(BaseModel):
    users: list[YeniKullanici] = Field(min_length=1, max_length=MAKS)

class Guncelleme(BaseModel):
    id: int
    status: Literal["active", "inactive"]

class TopluGuncelle(BaseModel):
    updates: list[Guncelleme] = Field(min_length=1, max_length=MAKS)

@app.post("/users/batch")
def toplu_olustur(govde: TopluOlustur):
    # Ya hepsi ya hiçbiri: tek bir sorun varsa hiçbir kayıt eklenmez.
    kayitli = {k["email"] for k in db.values()}
    gorulen, sorunlar = set(), []
    for i, k in enumerate(govde.users):
        if k.email in kayitli or k.email in gorulen:
            sorunlar.append({"index": i, "email": k.email, "error": "E-posta zaten kullanılıyor"})
        gorulen.add(k.email)
    if sorunlar:
        return JSONResponse(status_code=409, content={
            "error": "Toplu oluşturma reddedildi, hiçbir kayıt eklenmedi",
            "code": "BATCH_CONFLICT", "items": sorunlar})
    olusan = []
    for k in govde.users:
        son_id[0] += 1
        db[son_id[0]] = {"id": son_id[0], **k.model_dump(), "status": "active"}
        olusan.append(db[son_id[0]])
    return JSONResponse(status_code=201, content={"data": olusan})

@app.patch("/users/batch")
def toplu_guncelle(govde: TopluGuncelle):
    # Kısmi başarı: her öğenin sonucu ayrı raporlanır.
    sonuclar = []
    for i, g in enumerate(govde.updates):
        if g.id not in db:
            sonuclar.append({"index": i, "id": g.id, "http_status": 404, "error": "Kullanıcı bulunamadı"})
            continue
        db[g.id]["status"] = g.status
        sonuclar.append({"index": i, "id": g.id, "http_status": 200})
    basarili = sum(1 for s in sonuclar if s["http_status"] == 200)
    return {"results": sonuclar, "summary": {"succeeded": basarili, "failed": len(sonuclar) - basarili}}

c = TestClient(app)
r = c.post("/users/batch", json={"users": [{"email": "deniz@example.com", "name": "Deniz"},
                                           {"email": "ece@example.com", "name": "Ece"}]})
print(r.status_code, [k["id"] for k in r.json()["data"]])
# çıktı: 201 [2, 3]

r = c.post("/users/batch", json={"users": [{"email": "can@example.com", "name": "Can"},
                                           {"email": "ayse@example.com", "name": "Ayşe K."},
                                           {"email": "can@example.com", "name": "Can B."}]})
print(r.status_code, r.json()["items"], len(db))
# çıktı: 409 [{'index': 1, 'email': 'ayse@example.com', 'error': 'E-posta zaten kullanılıyor'}, {'index': 2, 'email': 'can@example.com', 'error': 'E-posta zaten kullanılıyor'}] 3

r = c.post("/users/batch", json={"users": [{"email": f"u{i}@example.com", "name": "x"} for i in range(101)]})
print(r.status_code, r.json()["detail"][0]["type"], r.json()["detail"][0]["loc"])
# çıktı: 422 too_long ['body', 'users']

r = c.post("/users/batch", json={"users": []})
print(r.status_code, r.json()["detail"][0]["type"])
# çıktı: 422 too_short

r = c.patch("/users/batch", json={"updates": [{"id": 2, "status": "inactive"}, {"id": 99, "status": "active"}]})
print(r.status_code, r.json())
# çıktı: 200 {'results': [{'index': 0, 'id': 2, 'http_status': 200}, {'index': 1, 'id': 99, 'http_status': 404, 'error': 'Kullanıcı bulunamadı'}], 'summary': {'succeeded': 1, 'failed': 1}}

r = c.patch("/users/batch", json={"updates": [{"id": 3, "status": "inactive"}, {"id": 1, "status": "deleted"}]})
print(r.status_code, r.json()["detail"][0]["loc"], db[3]["status"])
# çıktı: 422 ['body', 'updates', 1, 'status'] active

İlk istekte iki yeni kullanıcı oluştu ve 201 döndü. İkinci istekte iki ayrı sorun var: ayse@example.com zaten kayıtlı, can@example.com ise aynı istek içinde iki kez geçiyor. Atomik model gereği hiçbir kayıt eklenmedi; depodaki kayıt sayısı 3 olarak kaldı ve yanıt, sorunlu öğelerin sırasını ve nedenini listeleyen bir 409 Conflict oldu. RFC 9110, 409 kodunu istemcinin çakışmayı çözüp isteği yeniden gönderebileceği durumlar için tanımlar ve yanıtın çakışmanın kaynağını anlatacak kadar bilgi içermesini ister; öğe listesi tam olarak bunu yapar.

Üçüncü ve dördüncü istekler sınırları gösterir: 101 öğe too_long, boş dizi too_short hatasıyla 422 aldı. Beşinci istek kısmi başarı modelini çalıştırdı: 2 numaralı kullanıcı güncellendi, var olmayan 99 numara için öğe bazlı 404 raporlandı ve yanıtın kendisi 200 oldu.

Son istek önemli bir ayrıntıyı ortaya koyuyor: kısmi başarı modelinde bile şema doğrulaması isteğin bütününe uygulanır. İkinci öğedeki geçersiz deleted değeri yüzünden FastAPI isteği uç nokta fonksiyonuna hiç ulaştırmadan reddetti; hata konumu ["body", "updates", 1, "status"] olarak bildirildi ve geçerli olan ilk öğe de uygulanmadı, 3 numaralı kullanıcının durumu active kaldı. Bu genellikle istenen bir davranıştır: biçimsel hatalar istemcinin kodundaki bir soruna işaret eder, iş kuralı hataları (bulunamayan kayıt gibi) ise öğe bazında raporlanır. Bu ayrımı belgede yazmak, istemcinin iki hata türünü doğru ele almasını sağlar.

Öğe sayısına sınır koymak

Sınırsız bir toplu uç nokta, tek bir istekle sunucuyu dakikalarca meşgul edebilir, belleği doldurabilir ve zaman aşımına uğrayan isteklerin yarım kalmış bir durum bırakmasına yol açabilir. Bu yüzden her toplu uç nokta için öğe sayısı üst sınırı belirleyin, belgeye yazın ve şemada uygulayın. Örnekte bu sınır 100 olarak seçildi; doğru sayı, öğe başına işin maliyetine ve kabul edilebilir yanıt süresine bağlıdır.

Sınır aşıldığında hangi kodun döneceği bir tasarım tercihidir. FastAPI şema kısıtıyla 422 döndürür. Gövdenin boyutuna dayalı bir sınır kullanıyorsanız RFC 9110'daki 413 Content Too Large kodu daha açıklayıcıdır: sunucunun, istek içeriği işlemeye istekli veya muktedir olduğundan büyük olduğu için isteği reddettiğini söyler. Durum geçiciyse sunucunun Retry-After başlığı göndermesi beklenir. Hangi kodu seçerseniz seçin, yanıtta sınırın kendisini de bildirmek istemcinin isteği bölmesini kolaylaştırır:

http
HTTP/1.1 413 Content Too Large
Content-Type: application/problem+json

{"type": "https://api.ornek.com/problems/batch-too-large", "title": "Toplu istek çok büyük", "status": 413, "detail": "Bir istekte en fazla 100 öğe gönderebilirsiniz, 250 öğe gönderildi.", "max_items": 100}

Sınır, istek hızı sınırlamasıyla (rate limiting) da ilişkilidir. 100 öğelik bir toplu istek, sunucu için 100 tekil istek kadar iş üretebilir. RFC 6585'teki 429 Too Many Requests tanımı, sunucunun istekleri nasıl saydığını tanımlamaz; toplu istekleri tek istek mi yoksa öğe sayısı kadar istek mi sayacağınızı siz belirlersiniz. Bu kararı ve sınırlama başlıklarını API rate limiting stratejileri yazısıyla birlikte düşünün.

Uzun süren toplu işler: 202 Accepted

Binlerce öğelik bir içe aktarma, bir HTTP isteğinin makul süresi içinde bitmeyebilir. Bu durumda sunucu isteği kabul edip işi arka planda yürütebilir. RFC 9110'a göre 202 Accepted, isteğin işlenmek üzere kabul edildiğini ama işlemin tamamlanmadığını bildirir; bilinçli olarak bağlayıcı olmayan bir yanıttır ve iş daha sonra yapılırken reddedilebilir. Yanıt temsilinin isteğin güncel durumunu anlatması ve istemcinin işin ne zaman biteceğini öğrenebileceği bir durum izleyicisine işaret etmesi beklenir:

http
POST /users/batch HTTP/1.1
Host: api.ornek.com
Content-Type: application/json

{"users": [{"email": "user1@example.com", "name": "Deniz"}]}

HTTP/1.1 202 Accepted
Content-Type: application/json

{"job_id": "b7f3c2", "status": "queued", "total": 1, "monitor": "/batch-jobs/b7f3c2"}

İstemci bu durumda monitor adresini belirli aralıklarla sorgular ve iş bittiğinde öğe bazlı sonuç raporunu oradan alır. Sorgulamak yerine iş bitince istemciye haber vermek isterseniz bir webhook tasarlayabilirsiniz; ayrıntısı webhook tasarımı yazısında. RFC 9110, HTTP'de eşzamansız bir işlemin durum kodunu sonradan yeniden göndermenin bir yolu olmadığını da hatırlatır; sonucu iletmek tamamen sizin tasarladığınız durum kaynağının işidir.

Yeniden deneme ve idempotentlik

POST /users/batch idempotent değildir. Ağ kesintisi nedeniyle yanıtı alamayan bir istemci isteği tekrar gönderirse, atomik modelde ikinci istek e-posta çakışmasıyla 409 alır ve istemci ilk isteğin başarılı olup olmadığını bu yanıttan anlayamaz. Benzersizlik kısıtı olmayan kaynaklarda ise kayıtlar sessizce çoğalır. Toplu oluşturma uç noktalarında Idempotency-Key başlığını desteklemek bu sorunu çözer; aynı anahtarla gelen tekrar isteğe ilk yanıt aynen döndürülür. Uygulaması için Idempotency-Key kullanımı yazısına bakın.

Sık yapılan hatalar

Sık yapılan hatalar: Hata modelini belgelememek, Kısmi başarıda yalnızca durum koduna bakmak, Sonuçları istekteki sıraya bağlamamak, Sınırsız dizi kabul etmek
  • Hata modelini belgelememek: İstemci bir öğe başarısız olduğunda diğerlerinin uygulanıp uygulanmadığını yanıttan ve belgeden kesin olarak anlayabilmelidir.
  • Kısmi başarıda yalnızca durum koduna bakmak: 200 yanıtı içinde başarısız öğeler olabilir; istemci kodunun öğe sonuçlarını okuması gerekir.
  • Sonuçları istekteki sıraya bağlamamak: index alanı olmadan, kimliği olmayan yeni öğelerin hangisinin başarısız olduğu anlaşılmaz.
  • Sınırsız dizi kabul etmek: Öğe sayısı ve gövde boyutu sınırı yoksa tek istek sunucuyu kilitleyebilir.
  • Atomik modeli işlemsiz uygulamak: Denetimden sonra yazma sırasında oluşan bir hata, veritabanı işlemi kullanılmıyorsa yarım kalmış bir toplu yazma bırakır.
  • İstek içi tekrarları denetlememek: Aynı e-posta bir istekte iki kez geçebilir; yalnızca mevcut kayıtlarla karşılaştırmak yetmez.
  • Toplu uç noktayı tekil olanlardan farklı kurallarla doğrulamak: Aynı alan iki uç noktada farklı doğrulanırsa istemci tutarsız sonuçlarla karşılaşır.

Sık Sorulan Sorular

Toplu oluşturma yanıtında Location başlığı gönderilmeli mi?

RFC 9110, POST ile kaynak oluşturulduğunda 201 Created yanıtında birincil kaynağı gösteren bir Location başlığı gönderilmesini önerir. Toplu oluşturmada tek bir birincil kaynak yoktur; oluşan her kaynağın kimliğini ve adresini yanıt gövdesinde listelemek daha kullanışlıdır.

Toplu silme nasıl tasarlanır?

Aynı ilkeler geçerlidir: silinecek kimlikleri bir dizi olarak alan ayrı bir toplu uç nokta, öğe sınırı ve açıkça seçilmiş bir hata modeli. Kimlik listesini çok uzun bir query parametresiyle göndermek URI uzunluk sınırlarına takılabilir; RFC 9110 bu durum için 414 URI Too Long tanımlar.

Kısmi başarıda yanıt kodu ne olmalı?

İsteğin kendisi işlendiği için genellikle 200 OK kullanılır ve öğe sonuçları gövdede raporlanır. Tüm öğeler başarısız olduysa bile bunu öğe sonuçlarıyla bildirmek ya da tek bir 4xx yanıtına dönmek arasında bir seçim yapıp belgelemeniz gerekir.

Toplu uç nokta tekil uç noktaların yerine geçer mi?

Hayır. Tekil uç noktalar basit ve tek kayıtlık işler için kalmalıdır; toplu uç nokta yalnızca çok sayıda öğenin birlikte işlenmesi gerektiğinde ek bir kolaylık olarak sunulur.

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 · Idempotency-Key kullanımı · PUT ve PATCH farkı · CRUD endpoint tasarımı · Hata mesajı formatı · Webhook tasarımı · Rate limiting stratejileri