Kapatılmayan akış yanıtı yüz saniye asılı kalıyor
Token'lar geliyor, cevap tamamlanıyor, istek açık kalıyor ve proxy onu kesiyor. Akışı doğru kapatmak ve gerçek alan adı üzerinden ölçmek.
Bir özellik cevabını token token akıtıyor. Metin ekrana geliyor, son cümle de düşüyor ve sonra hiçbir şey olmuyor. Durdur düğmesi ekranda kalıyor, istek ağ panelinde beklemede duruyor ve yaklaşık yüz saniye sonra arayüz bağlantı hatasına geçiyor. Oysa cevap eksiksiz ve doğru. Aynı kod dizüstünde sorunsuz görünüyor. Sorun modelde veya tarayıcıda değil: yanıtı kimse kapatmamış.
Gerçekte ne oluyor
Akış yanıtı, content type'ı text/event-stream olan ve parça parça gönderilen sıradan bir HTTP yanıtıdır. İçerik uzunluğu olmadığı için istemci ne kadar veri geleceğini bilmez. Cevabın bittiğine dair tek işaret, sunucunun akışı kapatmasıdır. Gövdenin içindeki data: [DONE] satırı bir anlaşmadır ve HTTP açısından hiçbir şey ifade etmez: o sadece metindir, anlamını yalnızca o metni okuyan kod bilir.
Herkesin ilk yazdığı handler şuna benzer:
const upstream = await fetch(PROVIDER_URL, { method: 'POST', body });
return new Response(
new ReadableStream({
async start(controller) {
for await (const chunk of upstream.body) controller.enqueue(chunk);
controller.close();
},
}),
{ headers: { 'content-type': 'text/event-stream' } },
);Okunuşu doğrudur. Döngü, upstream gövdesi dosya sonuna geldiğinde biter ve akış kapanır. İşin püf noktası şu: cevap bittiğinde upstream gövdesi dosya sonuna gelmez. Bağlantı tekrar kullanımını destekleyen bir sağlayıcı son olayı yazar ve soketi açık tutar, aynı bağlantıdan yeni bir istek gönderecek misiniz diye bekler. Kendi idle timeout'u altmış saniye de olabilir beş dakika da. O ana kadar bizim döngümüz dönmeyecek bir okumada park etmiş durumdadır, istemci de onunla birlikte bekler.
Dizüstünde bu görünmez. Yerel test sahtesi fixture'ı yazar yazmaz soketi kapatır, ki gerçek sağlayıcının yapmadığı tek davranış budur. Elle denerken metin zaten ekrandadır, sekme kapatılır ve soket onunla ölür. Kimse hata bildirmez.
Reverse proxy arkasında aynı sıra farklı biter:
- Handler son olayı yazar. Tarayıcı boyar. Okuyan biri için özellik bitmiştir.
- Handler döngüde kalır, upstream soketini, bir worker'ı ve bir bağlantı slotunu tutmaya devam eder.
- Proxy origin'i bekler. Read timeout'u aldığı son bayttan itibaren sayar, nginx'te varsayılan altmış saniyedir. Baktığım bir platformda bu değer yüz saniyeye çekilmişti.
- Süre dolunca proxy origin'den umudu keser ve istemci bağlantısını sert biçimde kapatır. fetch promise'i reject olur, reader hata fırlatır ve arayüz, tamamlanmış bir cevabın üstüne hata akışını çalıştırır.
Asıl fatura bu dört maddenin arasında saklıdır. Cevap ekranda göründükten sonra geçen yüz saniye boyunca istek hâlâ canlıdır: origin tarafında bir worker, proxy tarafında bir bağlantı, sağlayıcı tarafında açık bir akış tutulur. Tek kullanıcıyla bu fark edilmez. Aynı anda yüz kişi sohbet ettiğinde, hepsi cevabını almış olmasına rağmen yüz bağlantı boşuna açık durur ve sistem, gerçekte yapmadığı bir yükün kapasite sınırına dayanır. Grafiklerde bu, model yavaşlamış gibi görünür.
Bunu birinciyle karıştırmamak için proxy'nin ikinci davranışını ayırmak gerekir. Buffer açıkken, ki varsayılan budur, nginx yanıtı kendi buffer'ına toplar ve biri dolunca boşaltır. Cevap öbekler hâlinde ya da sonunda toptan gelir, akış hissi kaybolur, üstelik akış düzgün kapansa bile. Biri buffer sorunudur, öteki kapanma sorunu, ve farklı satırlarla çözülürler.
Nasıl görülür
İlk bayta kadar geçen süre modelin başladığını söyler. Son bayta kadar geçen süre yanıtın bitip bitmediğini söyler. İkisini birden isteyin:
curl -N -sS -o /dev/null \
-H 'accept: text/event-stream' \
-H 'content-type: application/json' \
-d '{"q":"merhaba"}' \
-w 'ilk bayt %{time_starttransfer}s, son bayt %{time_total}s\n' \
https://app.ornek.com/api/chat
# ilk bayt 0.41s, son bayt 100.08sİlk baytın yarım saniyenin altında, son baytın tam yüzde olması teşhisin tamamıdır. Sayı denemeler arasında oynamaz, çünkü bu yavaş bir cevap değil bir timeout'tur. Aynı komutu proxy'yi atlayıp doğrudan origin portuna çalıştırın: bu kez başka bir sabit alırsınız, genelde sağlayıcının idle timeout'u, ki bu da bağlantıyı origin'in kendi başına açık tuttuğunu gösterir.
Sürenin akışın içinde nereye gittiğini görmek için her satırı damgalayın:
curl -N -sS -H 'accept: text/event-stream' -d '{"q":"merhaba"}' \
https://app.ornek.com/api/chat \
| while IFS= read -r line; do printf '%s %s\n' "$(date +%T.%2N)" "$line"; done
# 14:22:07.11 data: {"delta":"Merhaba"}
# 14:22:09.64 data: [DONE]
# (doksan saniye boyunca hiçbir şey, sonra kabuk geri döner)Bütün satırlar aynı damgayla geliyorsa buffer açıktır ve elinizde öteki sorun vardır. Proxy log'u da durumu adıyla söyler:
tail -2 /var/log/nginx/error.log
# upstream timed out (110: Connection timed out) while reading upstream, request: "POST /api/chat"Bu satır teşhisin yarısıdır: nginx, origin'den veri beklerken pes etmiş demektir. İstemci tarafındaki karşılığı, gövdenin tamamı geldikten sonra reject olan bir fetch promise'idir. İkisini yan yana koyduğunuzda sorunun modelde değil, cevabın bittiğini kimsenin bildirmemesinde olduğu görünür.
Çözüm
Üç değişiklik: cevap bitince okumayı bırakmak, bunu yanıt başlıklarında söylemek ve proxy'ye akışın doğasına uygun bir timeout vermek.
Handler'da bayt kopyalamak yerine olayları ayrıştırın, done işaretinde döngüyü kırın ve temizliği finally içine koyun ki her yol oradan geçsin:
export async function POST(req) {
const upstream = await fetch(PROVIDER_URL, { method: 'POST', body: req.body, signal: req.signal });
const reader = upstream.body.getReader();
const dec = new TextDecoder();
const enc = new TextEncoder();
let buf = '';
const stream = new ReadableStream({
async start(controller) {
const beat = setInterval(() => controller.enqueue(enc.encode(': keep-alive\n\n')), 15000);
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) !== -1) {
const event = buf.slice(0, i + 2);
buf = buf.slice(i + 2);
if (event.includes('[DONE]')) {
controller.enqueue(enc.encode('event: done\ndata: {"ok":true}\n\n'));
return;
}
controller.enqueue(enc.encode(event));
}
}
} finally {
clearInterval(beat);
reader.cancel().catch(() => {});
try { controller.close(); } catch {}
}
},
cancel() { reader.cancel().catch(() => {}); },
});
return new Response(stream, {
headers: {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
'connection': 'keep-alive',
'x-accel-buffering': 'no',
},
});
}Değerin çoğunu iki ayrıntı taşır. reader.cancel() upstream bağlantısını yarım okunmuş hâlde bırakmak yerine serbest bırakır, bu da sağlayıcı açık akış sayısını hesabınıza yazıyorsa doğrudan paraya dokunur. Açıkça gönderilen event: done ise istemciye biten cevapla kesilen cevabı ayırt etme imkânı verir, çünkü kapanan bir bağlantı tek başına hangisi olduğunu söyleyemez.
Proxy tarafında o route için buffer'ı kapatın ve boşluk toleransını bilerek seçin:
location /api/chat {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
chunked_transfer_encoding on;
}proxy_read_timeout, proxy'nin iki okuma arasında tahammül ettiği sessizliktir, yanıtın toplam uzunluğu değil. On beş saniyelik heartbeat'e karşı iki dakika hem rahat bir paydır hem de takılmış origin'i yine de keser. Buffer'ı kapatmanın bedeli küçük yazmaların tek tek iletilmesi ve o route'ta sıkıştırma olmamasıdır: akış için doğru, sayfa için yanlış bir takas. Bu başlığı uygulama yerine nginx'te veriyorsanız yanıtın üzerinde kontrol edin, çünkü sunucu seviyesinde tanımlı bir başlık, bir location kendi başlığını tanımladığı anda kaybolur; bu da güvenlik başlıklarını sessizce düşüren kuralın ta kendisidir.
Bunun çözmediği bir şey var: akışın ortasında yaşanan hata. Sağlayıcı kırkıncı token'da düşerse istemcinin elinde yarım bir cevap ve düzgün kapanmış bir bağlantı kalır, yani biten cevapla kesilen cevap aynı görünür. Kod bu yüzden bağlantının kapanmasına güvenmek yerine kendi event: done olayını gönderir. Hata yolunda da durum taşıyan bir event: error gönderip akışı yine kapatın, istemci de bu iki olaydan birini almadan cevabı tamamlanmış saymasın.
Çalıştığını nasıl doğrularsınız
Aynı komut, değişen tek sayı:
curl -N -sS -o /dev/null -H 'accept: text/event-stream' \
-d '{"q":"merhaba"}' -w 'son bayt %{time_total}s\n' \
https://app.ornek.com/api/chat
# son bayt 2.71sSonra bunu saklayın. İçeriğe bakan bir test bozuk akışta da geçer, o yüzden son parça ile kapanış arasındaki boşluğu ölçün:
const res = await fetch(`${BASE}/api/chat`, { method: 'POST', body, headers });
const reader = res.body.getReader();
let last = Date.now();
for (;;) {
const { done } = await reader.read();
if (done) break;
last = Date.now();
}
const gap = Date.now() - last;
if (gap > 2000) throw new Error(`akış son parçadan ${gap}ms sonra hâlâ açıktı`);BASE yayındaki alan adını göstersin. localhost'a çalıştırılan bir test proxy'yi atlar, oysa test etmek istediğiniz davranışın yarısı proxy'dedir.
Testi yalnızca yeni düzelttiğiniz uca değil, akış yapan bütün uçlara koşturun. Bu hata tek bir handler'ın hatası değil, aynı kalıbı kopyalayan her ucun hatasıdır ve birincisini düzelttikten sonra ikincisi genelde aylarca fark edilmeden durur.
Nelere dikkat etmeli
- Temizlemeyi unuttuğunuz bir heartbeat, aynı hatanın kibar hâlidir. Interval kuyruğa yazmaya devam eder, akış açık kalır, belirti birebir aynıdır. Timer'ı controller'ı kapattığınız
finallyiçinde temizleyin. - İsteğin abort sinyalini sağlayıcı çağrısına geçirmezseniz, sekmeyi kapatan bir kullanıcı sizi kimsenin okumayacağı token'ları okuyup öderken bırakır. Bu da yapay zekâ özelliğini makul maliyette tutmanın sessiz kalemlerinden biridir.
- Yoldaki her durağın kendi idle timeout'u vardır, sadece sizin ayarladığınızın değil. nginx'in önündeki load balancer, tünel veya CDN kendi kararını verir; on saniye boşta kalınca ölen tüneller tam olarak bu şekilde bozulur.
- Read timeout'u bir saate çıkarmak sadece hata mesajını susturur. İstek hâlâ açıktır, hâlâ bağlantı tutar, üstelik artık bir saat boyunca.
- Akış yapan bir route'u önbelleğe alan herhangi bir katman, ilk cevabı başka kullanıcılara tekrar oynatır.
proxy_cache offsatırını dacache-control: no-cachebaşlığını da bırakın; biri diğerinin yedeği değildir, ikisi farklı katmanlara konuşur.
Akış yapan bir ucun iki sonu vardır: gövdeye yazılan son ve kablodaki son. Okuyucular birincisini görür, sizinle onlar arasındaki bütün makineler yalnızca ikincisini görür. Bittiğine inandığınız isteğin gerçekten bitip bitmediğini kontrol etmeye değer ve bunun en ucuz yolu ekran görüntüsü değil bir sayıdır: gerçek alan adı üzerinden ölçülen son bayt süresi ile son kelimenin ekrana düştüğü an. Bu iki sayı örtüşüyorsa özellik tamamdır. Aralarındaki fark yuvarlak bir sayıysa birinin timeout'unu bulmuşsunuz demektir.
Sorular ve cevaplar
- Akış uçlarım localde çalışıp nginx arkasında neden asılı kalıyor?
- Localde tarayıcı ile süreç arasında hiçbir katman yoktur, açık kalan bir akışla biten akış ekranda aynı görünür: metin iki durumda da yerindedir. Reverse proxy ise son bayttan sonraki sessizliği sayar ve read timeout dolunca bağlantıyı kapatır, bu da istemciye ağ hatası olarak yansır. Asılı kalma iki durumda da vardır, proxy sadece onu görünür kılan katmandır.
- X-Accel-Buffering: no tam olarak ne yapıyor?
- nginx'e o tek yanıt için buffer'lamayı kapatmasını söyler, böylece süreçten çıkan her yazma buffer dolmayı beklemeden istemciye iletilir. Bu başlık olmadan akış çoğu zaman öbekler hâlinde veya sonunda tek seferde gelir, bu da proxy ayarı değil yavaş model gibi görünür. Başlık yanıt bazında çalışır, bu da buffer'ı tüm sunucuda kapatmaktan güvenlidir.
- Akış yapan bir route'ta proxy_read_timeout kaç olmalı?
- Bu değer yanıtın toplam süresini değil, origin'den gelen iki okuma arasındaki boşluğu ölçer. Dolayısıyla beklediğiniz en uzun meşru sessizlikten biraz uzun olmalı. On beş saniyede bir heartbeat gönderiyorsanız iki dakikalık bir boşluk toleransı fazlasıyla yeterlidir ve gerçekten takılmış isteği yine de keser. Bir saate çıkarmak akışı düzeltmez, sadece bozuk isteği bir saat boyunca tutar.
- Server sent events ucunda heartbeat şart mı?
- Model veya sorgu, yoldaki en kısa idle timeout'tan uzun süre sessiz kalabiliyorsa şarttır, ve o yolda sizin ayarlamadığınız load balancer'lar, tüneller de vardır. İki nokta ile başlayan bir yorum satırı istemci tarafında yok sayılır ama aradaki bütün sayaçları sıfırlar. Timer'ı akışı kapattığınız yerde temizleyin, yoksa heartbeat yanıtın hiç bitmemesinin sebebi olur.