Skip to content

Web ve Mobil için Asenkron API Desenleri: Görüşlü Bir Varsayılan

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.

Somut Problem

Ç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 nedeniyle ölür, iş dakikalar sonra başarıyla tamamlanır ve istemci bunu asla öğrenmediği için arayüz hâlâ "yükleniyor" spinner'ı 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.

Bunlar uç durumlar değildir. Güvenilmez mobil bağlantılar üzerinden uzun süreli işlerin normal hata biçimidir. Çözüm daha fazla polling değildir. Çözüm, her operasyon için doğru koordinasyon desenini seçmek ve altına bir idempotency katmanı koymaktır.

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 tek bir desen kapsar. Önce bunu deneyin; yalnızca belirli bir kısıt başka bir şey gerektirdiğinde geçersiz kılın.

  1. İstemci POST /resource'u bir Idempotency-Key başlığı ile gönderir. Sunucu 202 Accepted, bir Location: /jobs/{id} başlığı ve gövdede bir jobId döner.
  2. İstemci SSE üzerinden GET /jobs/{id}/events'e abone olur. Bağlantı koptuğunda tarayıcı (veya RN kütüphanesi) Last-Event-ID ile otomatik olarak yeniden bağlanır ve sunucu kaçırılan olayları tekrar oynatır.
  3. 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önder uç noktası artı SSE akışının küçük bir Node taslağı vardır. Yer için hata işleme kırpılmıştır. Kopyalamaya değen şekil üç dallı idempotency cache'idir: eşleşme cache'lenmiş 202'yi döner, uyumsuzluk 422 döner, yeni istek işi yaratır.

typescript
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 ekmeğini kazanıyor. Idempotency-Key cache'i, bir ağ hıçkırığından 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.

typescript
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, son olayı düşüren bir arka plan öldürmesi arayüzünüzü takılı bırakır.

Varsayılanı Çalıştıran Hata Modları

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 toparlanmak için hiç sessiz 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. "Sipariş 9f3c tamamlandı" altı saatlik bir uygulama öldürmesi arasında düzgünce 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 bir yumak halinde ya da hiç gelmez. Nginx, yanıtta X-Accel-Buffering: no'ya ihtiyaç duyar. Diğer proxy'lerin benzer düğmeleri vardır. 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. Kullanıcılarınızın gerçekten oturduğu ağlardan test edin, sadece ofisinizden değil.

Varsayılanı Ne Zaman Geçersiz Kılmalı

Varsayılan, uzun süreli operasyonların çoğunu kapsar. Beş durum bir geçersiz kılmayı hak eder.

Bozuk 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 değildir; yüzde 30 paket kaybı olan LTE'deki kullanıcının gördüğüdür. 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 bütçeleyin ve SSE'nin bugün kapsadığı bir problemde "gelecek esnekliği" için WebSocket'e uzanmayın.

Kurumsal bir proxy SSE'yi soyuyor. 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 tek bir not hak eder, bir bölüm değil. Yalnızca sunucudan sunucuyadır. 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. Ondan ötesinde isteseniz de istemeseniz de varsayılan topraklarındasınız.

Yaygın Tuzaklar

Aşağıdaki hatalar, async'i sync'in üzerine cıvatalayan hemen her projede karşımıza çıkar.

  • 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 bir yumak halinde 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.

Sahadaki İki Dürüst Anlaşmazlık

İki nokta deneyimli uygulayıcıları böler. Birini kazanan ilan etmek yerine adlarını söylemekte fayda var.

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. Soru işinizin şeklidir, kütüphanenin popülerliği değil.

Kaynaklar

İlgili Yazılar