Web ve Mobil Uygulamalarda Uzun Süren API İsteklerini Yönetmek
Tek bir backend üzerinde çalışan web SPA ve mobil uygulama için uzun süreli işlere dair tek bir varsayılan desen ve onu geçersiz kılmanız gereken durumlar.
Senkron İstek/Yanıt Nerede Tıkanır
Çoğu ürün API’si düz senkron istek/yanıt ile başlar. Login için, bir kullanıcı kaydını okumak için, basit bir arama için çalışır. Sonra bir uç nokta gerçek iş yapmak zorunda kalır.
Altı ila on saniye süren bir ödeme yetkilendirmesini düşünün. Bu kadar uzun bir spinner checkout dönüşümünü düşürür; kullanıcılar butona tekrar basar ve altta bir idempotency katmanı olmadığında backend ikinci bir ücretlendirmeyi kabul eder.
Ya da iki dakikalık bir transcode tetikleyen mobil bir yüklemeyi düşünün. Kullanıcı uygulamayı arka plana atar; hücresel soket, operatör NAT’ı ya da işletim sistemi suspend’i yüzünden ölür. İş dakikalar sonra başarıyla tamamlanır, ama istemci bunu hiç öğrenmediği için arayüz hâlâ “yükleniyor” spinner’ını gösterir. Webhook güdümlü sonuçlar (mobil oturum kapandıktan sonra backend’e düşen bir ödeme sağlayıcı geri bildirimi) aynı biçimdedir: backend bilir; istemci bilmez.
Bu, güvenilmez mobil bağlantılar üzerinden uzun süreli işlerin normal hata biçimidir ve daha fazla polling bunu onarmaz. Önce başvurulacak varsayılan, gönder ve abone ol desenidir: 202 ile bir iş kimliği dönün, ilerlemeyi SSE üzerinden akıtın ve istemci geri döndüğünde tek bir GET /jobs/{id} ile uzlaştırın. Üçünün de altında bir idempotency katmanı durur.
Varsayılan
Aynı backend üzerinde bir web uygulaması ve bir mobil uygulama çalıştıran çoğu ürün ekibi için, uzun süreli operasyonların uzun kuyruğunu aşağıdaki üç adım kapsar. Yalnızca belirli bir kısıt başka bir şey gerektirdiğinde geçersiz kılın.
- İstemci
POST /resource’u birIdempotency-Keybaşlığı ile gönderir. Sunucu202 Accepted, birLocation: /jobs/{id}başlığı ve gövdede birjobIddöner. - İstemci SSE üzerinden
GET /jobs/{id}/events’e abone olur. Bağlantı koptuğunda tarayıcı (veya RN kütüphanesi)Last-Event-IDile otomatik olarak yeniden bağlanır ve sunucu kaçırılan olayları tekrar oynatır. - Uzun bir arka planın ardından uygulama yeniden açıldığında, istemci yeniden abone olmadan önce terminal durumu okumak için bir kez
GET /jobs/{id}çağırır.
Tek bir sunucu API’si üç istemci yolunu kapsar: sekmesini izleyen web kullanıcısı, ön planda kalan mobil kullanıcı ve uygulamayı arka plana atıp saatler sonra geri dönen mobil kullanıcı. Aşağıdaki sıra, varsayılanın baştan sona çizilmiş hâlidir.
Çalıştırılabilir Örnek
Aşağıda gönderim uç noktasının ve SSE akışının küçük bir Node taslağı var. Yer kazanmak için hata yönetimi kırpıldı. Kopyalamaya değer olan şekil, üç dallı idempotency cache’i: eşleşme cache’lenmiş 202’yi döner, uyumsuzluk 422 döner, yeni istek işi yaratır.
import express from "express";
import { randomUUID } from "crypto";
import { EventEmitter } from "events";
const app = express();
app.use(express.json());
const jobs = new Map<string, Job>();
const events = new EventEmitter();
interface Job {
id: string;
status: "queued" | "processing" | "completed" | "failed";
progress: number;
result?: string;
}
// Idempotency-Key cache: key -> { bodyHash, response }
const idem = new Map<string, { bodyHash: string; jobId: string }>();
app.post("/transcodes", (req, res) => {
const key = req.header("Idempotency-Key");
const bodyHash = hash(JSON.stringify(req.body));
if (key) {
const cached = idem.get(key);
if (cached) {
if (cached.bodyHash === bodyHash) {
return res.status(202).json({ jobId: cached.jobId });
}
return res
.status(422)
.json({ error: "idempotency_key_reused_with_different_body" });
}
}
const jobId = randomUUID();
const job: Job = { id: jobId, status: "queued", progress: 0 };
jobs.set(jobId, job);
if (key) idem.set(key, { bodyHash, jobId });
enqueue(jobId);
res.status(202).location(`/jobs/${jobId}`).json({ jobId });
});
app.get("/jobs/:id/events", (req, res) => {
const { id } = req.params;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
// Replay current state on connect, then stream updates.
const job = jobs.get(id);
if (job) {
res.write(`event: status\ndata: ${JSON.stringify(job)}\n\n`);
}
const listener = (update: Job) => {
if (update.id !== id) return;
res.write(`id: ${Date.now()}\n`);
res.write(`event: ${update.status}\n`);
res.write(`data: ${JSON.stringify(update)}\n\n`);
};
events.on("job", listener);
req.on("close", () => events.off("job", listener));
});
app.get("/jobs/:id", (req, res) => {
const job = jobs.get(req.params.id);
if (!job) return res.sendStatus(404);
res.json(job);
});
Burada iki şey yerini hak ediyor. Idempotency-Key cache’i, kısa bir ağ kesintisinden sonraki retry’ın yeni bir iş oluşturmak yerine aynı iş kimliğini yeniden kullanmasını sağlar. Ayrı GET /jobs/{id} uç noktası uzlaştırma yoludur. Terminal olayını kaçıran bir mobil istemcinin hâlâ son durumu öğrenmek için temiz bir yolu vardır.
Tarayıcı İstemcisi
Tarayıcı tarafı kısadır çünkü EventSource zor kısımları yapar.
async function transcode(file: File) {
const key = crypto.randomUUID();
const res = await fetch("/transcodes", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({ fileId: file.name }),
});
const { jobId } = await res.json();
const events = new EventSource(`/jobs/${jobId}/events`);
events.addEventListener("progress", (e) =>
console.log("progress", JSON.parse(e.data)),
);
events.addEventListener("completed", (e) => {
console.log("done", JSON.parse(e.data));
events.close();
});
events.onerror = () => {
// EventSource otomatik olarak yeniden bağlanır. Sadece vazgeçmeye karar verirsek kapatın.
};
}
React Native Notu
React Native EventSource ile gelmez. Yaygın iki yol react-native-sse kütüphanesi veya bir ReadableStream polyfill ile fetch’ten akışı kendiniz parse etmektir. Parse etmek zor değildir; her kayıt event: artı data: artı bir boş satırdır. Kütüphane çoğu ekip için yeterlidir.
Yeniden açılışta yalnızca akışa güvenmeyin. GET /jobs/{id} çağırın ve dönen terminal durumu ne ise onu render edin. Yalnızca SSE akışına güvenirseniz, işletim sistemi uygulamayı arka planda kapatıp son olayı düşürdüğünde arayüzünüz spinner’da takılı kalır.
Varsayılanı Ayakta Tutan Hata Senaryoları
Idempotency Key’ler: Varsayılanın İlk Sözleşmesi
Mobil istemcinin çağırdığı her mutating uç noktanın bir Idempotency-Key’e ihtiyacı vardır. İstemci her mantıksal operasyon için bir UUID üretir ve retry’da yeniden kullanır. Sunucu istek parmak izini ve yanıtı yaklaşık 24 saat cache’ler. Idempotency-Key başlığı için IETF taslağı birkaç revizyondan geçti ve Stripe bu deseni production’da uzun süredir çalıştırıyor. Farklı bir anahtar adı icat etmek için bir neden yok.
Sunucu key başına üç şey saklar: gövde parmak izi, yanıt gövdesi ve bir durum. Aynı key ve eşleşen parmak izi ile bir retry gelirse cache’lenmiş yanıtı döndürün. Parmak izi farklıysa bu bir istemci hatasıdır. Stripe 400 ile birlikte idempotency_error kodu döner; 409 Conflict ve 422 Unprocessable Entity da makul seçimlerdir. Birini seçin ve belgeleyin.
Jitter ile Retry
İstemci retry’ları jitter ile exponential backoff’a ihtiyaç duyar. Çok sayıda istemci tam olarak 1s, 2s, 4s, 8s’de retry ederse senkronize olurlar. Sunucu toparlanacak sessiz bir an bulamaz. Timeout ve backoff üzerine AWS Builders’ Library yazısı çoğu ekibin atıf yaptığı referanstır. Mevcut backoff penceresi üzerinde full jitter iyi bir varsayılandır.
Reconnect Sırasında Kaçırılan Olayların Uzlaştırılması
SSE bağlantısı düşüp geri geldiğinde istemcinin ilk sinyali Last-Event-ID’dir. Sunucu olayları o noktadan itibaren tekrar oynatır. Uzun bir arka planın ardından uygulama yeniden açıldığında ise tek bir GET /jobs/{id} yetkili cevaptır. İş bittiyse terminal durumu, hâlâ çalışıyorsa mevcut ilerlemeyi döner. Ancak ondan sonra istemci yeniden abone olmayı dener.
Uzlaştırmayı transport katmanında inşa etmeyin. İş katmanında, alanınızın zaten sahip olduğu bir correlation ID’ye göre inşa edin. Uygulama altı saat boyunca kapalı kalsa bile “Sipariş 9f3c tamamlandı” düzgün şekilde uzlaşır. “Socket 0x42 payload 17” uzlaşmaz.
SSE Buffer’lama ve Proxy Tuzakları
SSE neredeyse her proxy’den geçer, ama şaşırtıcı sayıda proxy yanıtı varsayılan olarak buffer’lar. Olaylar ya topluca gelir ya da hiç gelmez. Nginx, yanıtta X-Accel-Buffering: no’ya ihtiyaç duyar. Diğer proxy’lerde de benzer ayarlar var. SSE uç noktanızın önünde CDN varsa, text/event-stream için yanıt sıkıştırmasını kapatın ve bağlantının tüm gövde gelene kadar tutulmadığını doğrulayın. Sadece ofisinizden değil, kullanıcılarınızın gerçekten bağlandığı ağlardan test edin.
Geçersiz Kılma Koşulları
Beş durum geçersiz kılmayı hak eder.
Zayıf bir mobil ağda p99’da yaklaşık bir saniyeden kısa süren işlemler. Düz senkron istek/yanıt. İş kimliği yok, SSE yok, koordinasyon yok. Sınır, laptopunuzda gördüğünüz şey değildir; yüzde 30 paket kaybı olan LTE’deki kullanıcının gördüğü şeydir. Burada gereğinden fazla kurmak size pil ve kod harcatır, karşılığında bir şey getirmez.
Çift yönlü, yüksek frekanslı istemci yazmaları. WebSocket. Sohbet, işbirlikçi imleçler, çok oyunculu girdi. Radyo uyanık kalır, Wi-Fi’dan LTE’ye geçiş bağlantıyı koparır ve mobil arka plan politikaları soketleri hızla öldürür. Heartbeat’ler, jitter’lı reconnect ve sunucu tarafında bir oturum resume token’ı inşa edeceksiniz. Bunu ilk günden hesaba katın ve SSE’nin zaten kapsadığı bir problemde “gelecek esnekliği” için WebSocket’e uzanmayın.
Kurumsal bir proxy SSE’yi engelliyor. Long polling yedektir. İstemci, sunucunun N saniyeye kadar tutabileceği bir GET gönderir; sunucu veri geldiğinde veya timeout’ta flush eder; istemci hemen yeniden bağlanır. Her olay için bir roundtrip’e mal olur, multiplexing yoktur, ama her proxy bunu geçirir.
Saatlerce süren, her adımda retry ve timeout gerektiren çok adımlı iş akışı. Varsayılan API’nin arkasındaki düz kuyruğun yerini Temporal gibi bir workflow motoru alır. Retry’ları, telafileri ve adımdan adıma değişen timeout’ları bir mesaj kuyruğunun üzerinde yeniden kurmaya çalışırsanız iş akışı orkestrasyonunu kötü şekilde icat edersiniz. Varsayılanın POST ve GET /jobs/{id} uç noktaları aynı kalır; yalnızca worker’ın şekli değişir.
İstemci bekleme olmaksızın saf backend decoupling. Varsayılan API’nin arkasında, görünmez şekilde SQS veya BullMQ gibi bir mesaj kuyruğu. İstemci yine POST eder ve bir iş kimliği ile 202 alır; ancak SSE akışı tek bir completed olayından fazlasını hiç taşımayabilir ve bazı durumlarda istemci hiç abone olmaz.
Webhook’lar için burada tek bir not gerekiyor. Yalnızca sunucudan sunucuya çalışırlar. Bir webhook, web veya mobil istemci için asla tek başına sonuç kanalı değildir, çünkü istemci bir sunucu değildir. Bir sağlayıcı webhook uç noktanızı çağırdığında imzayı doğrulayın, olayı kalıcı hale getirin, ACK edin ve sonra sonucu kullanıcının SSE akışına köprüleyin veya bir sonraki GET /jobs/{id}’nin terminal durumu döndürmesi için iş deposunu işaretleyin.
Karar Çerçevesi
Aşağıdaki akış şeması varsayılandan başlar ve geçersiz kılmalara dallanır.
Geçersiz kılmalardan önce bir güvence: sunucu yaklaşık 15 saniyeden uzun sürerse, hücresel bağlantıdaki mobil istemciler handler’ınız dönmeden bağlantıyı yitirme eğilimindedir. İşletim sistemi sabit bir istek tavanı uygulamaz; ama operatör timeout’ları ve uygulama suspend’i, uzun tutulan istekleri rutin olarak kırar. Ön planda mobil istemcinin açık tuttuğu her şey için yaklaşık 15 saniyeyi pratik bir tavan olarak kabul edin. Bunun ötesinde, isteseniz de istemeseniz de varsayılana geçmiş olursunuz.
Yaygın Tuzaklar
Aşağıdaki hatalar, async’i sync’in üzerine sonradan ekleyen hemen her projede karşımıza çıkıyor.
- 30 saniyelik bir senkron uç noktayı “muhtemelen iyi” olarak kabul etmek. Değildir. Mobil LTE timeout’ları laptop Wi-Fi toleransınızdan daha kısadır.
- Idempotency key olmadan POST’u retry etmek. Duplicate ödemeler ve duplicate siparişler bir hafta içinde gelir.
- WebSocket reconnect’in “kendi kendine çalışacağını” varsaymak. Ağ geçişleri arasında çalışmaz.
- ACK etmeden önce işi senkron yapan webhook handler’ları. Sağlayıcı retry eder. İşi iki kere yaparsınız.
- Yanıt buffering’i açık bir reverse proxy arkasında SSE. Olaylar ya topluca gelir ya da hiç gelmez. Nginx’in
X-Accel-Buffering: no’ya ihtiyacı vardır; diğer proxy’lerin benzer düğmeleri vardır. - Happy path için ayarlanmış polling aralığı, outage yolu için değil. Saniyede bir polling, çok sayıda kullanıcı ve on dakikalık bir outage ile çarpıldığında kendi kendine yaratılmış bir hizmet reddi saldırısına döner.
- Dead-letter kuyruğu yok. Bir zehirli mesaj tüm worker havuzunu tüketir.
- Correlation ID iş katmanı yerine transport katmanında icat edilmiş. Kullanıcı uygulamayı saatler sonra yeniden açtığında uzlaştırmak imkânsızdır.
- Webhook imza doğrulamasını atlamak. Her büyük sağlayıcı sunar; kullanmak tek satırlık bir değişikliktir.
Sahada Uzlaşılamayan İki Konu
İki nokta deneyimli uygulayıcıları ikiye bölüyor. İkisini de adıyla anmakta fayda var; çünkü her biri, kendi iş yükünüzde ölçebileceğiniz bir gerçeğe dayanıyor.
Birincisi mobil pilde WebSocket’e karşı SSE. Bazı kaynaklar, yeniden bağlanmanın uyanma maliyetinden kaçındıkları için WebSocket’lerin bağlandıktan sonra daha verimli olduğunu iddia ediyor. Diğerleri, radyonun daha yüksek güç durumunda kalması nedeniyle WebSocket’lerin daha fazla pil tükettiğini bildiriyor. Dürüst cevap mesaj sıklığına bağlıdır. Seyrek trafik keepalive’lı SSE’yi destekler. Yoğun trafik WebSocket’i destekler. Kullanıcılarınızın kullandığı cihazlarda ve ağlarda ölçün.
İkincisi Node’da uzun süreli işler için kuyruk seçimi. BullMQ savunucuları, Node ekipleri için doğru varsayılan olduğunu iddia ediyor. Temporal savunucuları, işin retry’ları, timeout’ları ve birden fazla adımı varsa bir kuyruk kütüphanesinin yanlış soyutlama olduğunu ve üzerine iş akışı orkestrasyonunu kötü şekilde yeniden kuracağınızı iddia ediyor. Her iki görüş de farklı iş şekilleri için doğrudur. Belirleyici olan işinizin şeklidir.
Kaynaklar
- MDN: Using Server-Sent Events - EventSource ve Last-Event-ID semantiği ile SSE için temel tarayıcı API’si.
- MDN: Idempotency-Key header - İstemci tarafından üretilen key deseni için kanonik referans.
- IETF draft-ietf-httpapi-idempotency-key-header - Idempotency-Key HTTP başlığı için güncel Internet-Draft.
- Stripe: Idempotent requests - İstek parmak izi artı 24 saatlik cache için production planı.
- Stripe: Webhooks - İmzalama, retry’lar ve telafi edilemez olayların yönetimi.
- RFC 6455: The WebSocket Protocol - Temel WebSocket spesifikasyonu.
- WebSocket.org: WebSocket vs HTTP, SSE, MQTT, WebRTC, gRPC - Transport seçimleri için yan yana karşılaştırma matrisi.
- AWS Builders’ Library: Timeouts, retries and backoff with jitter - Retry fırtınası önlemesi için standart referans.
- AWS SQS: Visibility timeout - SQS’te en az bir kez semantiği ve yeniden teslim davranışı.
- AWS SQS: Dead-letter queues - DLQ yapılandırması ile zehirli mesajları işleme.
- Chrome Developers: Streaming requests with the fetch API - Tarayıcı destek uyarılarıyla ReadableStream yükleme ve indirme desenleri.
- React Native Networking docs - Mobilde polyfill tuzakları dâhil WebSocket ve fetch semantiği.
- Temporal: In lieu of a queuing solution - Temporal ekibinden dayanıklı iş akışı ve mesaj kuyruğu çerçevelemesi.
- RxDB: WebSockets vs SSE vs Long-Polling vs WebRTC vs WebTransport - Pratik trade-off’larla protokol karşılaştırması.
- Apple: URLSession background configuration - Uzun süreli transferler için iOS arka plan yükleme ve indirme semantiği.
İlgili yazılar
API, ödeme ve mesaj tüketicisi geliştirenler için idempotency'ye pratik giriş: HTTP metot semantiği, idempotency key'leri, upsert ve yaygın tuzaklar.
Mobil binary geri alınamaz ve eski sürümler kalıcıdır; güvenlik ve hız sunucuya kayar: BFF, tüketici güdümlü sözleşmeler ve geriye dönük uyumlu sürümleme.
IoT lojistik için MQTT, AMQP, ZeroMQ, CoAP ve DDS'nin teknik karşılaştırması: filo takibi, soğuk zincir izleme ve gerçek zamanlı cihaz iletişimi.
Headless CMS çözümlerinin (Strapi, Contentful, Kontent, Storyblok) pratik karşılaştırması: Cloudinary ile görsel yönetimi ve framework entegrasyonu.
WebSocket, push bildirim, email, SMS ve webhook kanalları için üretimde test edilmiş gerçek zamanlı bildirim teslimat stratejileri