İlk production LLM çağrın: Streaming, retry ve timeout
429, takılan socket ve yarıda kesilen stream üreten sahte provider kur; client davranışını ölç. Full jitter: 226’ya karşı 2,2 sn.
Bu sayfada
Chapter 13, dokunabildiğin bir modelin üzerinde çalışan bir kronometreyle bitmişti. Ağırlıklar belleğindeydi, KV cache’i açıp kapatmak senin elindeydi ve ortaya çıkan sayı — time to first token — donanımının bir özelliğiydi.
Şimdi o modeli bir portun arkasına koy; her ürünün yaptığı şey bu. Aynı sayıyı yeniden oku. Hâlâ time to first token, ama artık kontrol ettiğin herhangi bir şeyin özelliği değil. Artık bir TLS handshake’i, provider tarafındaki bir kuyruğu, bir rate limiter’ı ve hiçbir token’ın hiç gelmeme ihtimalini içeriyor.
Bu son cümlecik bölümün kendisi. Yazmak üzere olduğun kod hiçbir şey hesaplamıyor. Bir bağlantı açıyor, bekliyor, geleni parse ediyor, hiçbir şey gelmediğinde ne yapacağına karar veriyor, gelen şey bir hata olduğunda yeniden karar veriyor ve kullanıcı fikrini değiştirdiğinde kendini iptal ediyor. Bunların her biri zaman içinde state hakkında bir karar ve her birinin ship edilip para kaybettiren yanlış bir cevabı var.
Problemin şekli burada; ölçülmüş hâliyle, tamamı bu bölümde:
| ne oldu | dikkatsiz bir client ne yapar | maliyeti |
|---|---|---|
| server socket’i kabul etti ve hiç cevap vermedi | bekler | Node kendi kendine vazgeçene kadar 300,8 sn |
| key yanlıştı (401) | beş kez retry yapar | 6.325 ms gecikme, sonra aynı 401 |
| yüz client aynı anda rate limit’e çarptı | hepsi aynı programla retry yapar | boşalması 226 sn, 2,2 sn’ye karşı |
| request timeout’a düştü ve yeniden gönderildi | yeniden gönderir | provider cevabı iki kez üretir — ve faturalandırır |
| bağlantı cevap ortasında koptu | kısmi metni gösterir | doğru ama kısa bir cevaptan ayırt edilemez |
Bunların hiçbiri bir modelleme problemi değil. Hepsi, bugüne kadar yazılmış her LLM ürününün ilk yüz satırında.
Bu bölüm neden dil değiştiriyor
Bölüme bağlantı: Bu bölüm neden dil değiştiriyorO tabloyu yeniden oku ve nasıl bir programı tarif ettiğini sor. Kırk saniye boyunca bir bağlantıyı açık tutuyor. Bir düğmeden iptal edilebilir olmalı. Gösterilmesi geçerli, kaydedilmesi geçersiz olan kısmi bir cevabı biriktiriyor. Ve cevabı render eden şeyin yanında, bir socket tutarak bir server process’te ya da edge worker’da çalışıyor.
Bu bir notebook değil. Python bunu yapamaz demek değil — yapabilir ve insanlar yapıyor — ama önceki on üç bölümün inşa ettiği her şey başka türdendi. Chapter 1’den 13’e kadar kod ağırlıklar, gradient’ler, logit’ler ve tokenizer byte’ları tutuyordu. Buradan itibaren kod bir bağlantı, bir retry, bir cancellation, birikmiş state ve daha sonra bir izin prompt’u tutuyor. Kurs, nesnenin değiştiği tam dikiş noktasında dil değiştiriyor.
Kuralı bir kez yazalım:
Kodun elinde ağırlıklar, gradient’ler, logit’ler veya tokenizer byte’ları varsa Python’dır. Bir bağlantı tutuyor, retry yapıyor, iptal ediyor, state biriktiriyor ve izin istiyorsa TypeScript’tir.
Dikiş noktası tek ve burada, Chapter 13 ile Chapter 14 arasında. Üç bağımsız ölçüt onu buraya koyuyor.
Bir: ecosystem, sayılarla. Bu kursun sol yarısında atıf yapılan her şey Python ve bu müfredat için incelenen on iki kursun hiçbirinde backpropagation’ın başka bir dilde öğretildiğine dair tek bir emsal yok: micrograd (17,4K yıldız), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Chapter 5’i TypeScript’te yazmak bu kaynaklarla bağı koparırdı; o bağlar, sıralanmak için değil referans verilmek için var olan bir bölümün değerinin yarısı. Bu tarafta aritmetik tersine dönüyor: Vercel’in ai paketi ayda 89,4M indirmede ve şeyin kendisini ship ediyor — ToolLoopAgent olarak export edilen bir tool-calling agent döngüsü — yani bu kursun Chapter 23’te ulaştığı kavramın referans implementasyonu TypeScript’te; üstelik o bölümün ölçtüğü gibi, kimse adına karar vermiş olmasa da. Mastra 27,7K yıldızda; Anthropic’in tek bir spesifikasyondan üretilen SDK’ları ise Python’daki 201’e karşı TypeScript’te 202 endpoint bildiriyor — nezaketen yapılmış bir port değil, parite.
İki: MCP’nin normatif kaynağı. Model Context Protocol spesifikasyonunun şeması bir schema.ts dosyası. Chapter 26’nın protokolünü başka bir dilde öğretmek, kurucu belgesinin çevirisini öğretmek demek.
Üç: search talebi, bariz tahmine düzeltmeyle. machine learning python internetteki en doygun ifade; ai agent typescript de kendi sağlıklı kuyruğuna sahip. Ama “MCP ecosystem çoğunlukla TypeScript” ancak nasıl saydığına bağlı olarak doğru: resmî registry, npm’de 8.275 server listeliyor; PyPI’da 3.603. İndirmelerde ise Python kazanıyor — mcp için ayda 287M artı fastmcp için 72M; @modelcontextprotocol/sdk için 195M’ye karşı. MCP burada gerçekten iki dilli tek bölge; bu yüzden Chapter 27 aynı server’ı rol yapmadan iki kez yazıyor.
Ayrıntıları göster
Kuralın slogan değil kural olması için ilan edilen beş istisna.
Chapter 17, 20 ve 29 bir Python’da ikinci panel taşıyor: top-p sampling’i implement etmek probability vector’ı elinde tutmayı gerektirir ve bir HTTP API bunu asla vermez; bir fine-tune’un fiyatını dürüstçe çıkarmak onu çalıştırmak demektir ve bir LoRA adapter nn.Module içinde bir düzine satırdır; ayrıca lm-eval-harness, HELM, SWE-bench ve τ-bench Python’dır, bu yüzden TypeScript’te bir evaluation harness, backpropagation hatasının aynadaki yansıması olurdu. Chapter 27, yukarıdaki ölçülmüş nedenle iki dillidir. Chapter 28 Markdown’dır, çünkü bir agent skill zaten bir SKILL.md dosyasıdır ve ona bir programlama dili vermek formatı anlamamış olmak olurdu.
On üç Python bölümü çöpe atılmıyor. Portun diğer tarafında olan şey onların inşa ettiği şey ve buradaki son bölüm bir client’ı ona bağlıyor.
Kırabileceğin bir provider
Bölüme bağlantı: Kırabileceğin bir providerBunların hiçbirini gerçek bir provider’a karşı öğrenemezsin. Ondan seçtiğin bir anda 429 vermesini, bağlantını kabul edip asla cevaplamayan bir socket’i ya da bir kelimenin ortasında duran bir stream’i isteyemezsin — ayrıca her deney için para ödersin; oysa ilginç deneyler yüz kez çalıştırdıklarındır.
Bu yüzden kursun bu yarısındaki ilk program bir client değil. Bir düşmanca server: chat completions endpoint’iyle aynı wire protocol’ü konuşan ve istendiğinde kötü davranan kırk satır sade Node. Bu bölümdeki her sayı ondan çıktı.
import { createServer } from "node:http";
const WORDS = "A tide gauge is a device that measures sea level over time .".split(" ");
const CAPACITY = 3; // how many requests it will serve at once
let inflight = 0;
const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
createServer(async (req, res) => {
const url = new URL(req.url, "http://x");
if (url.pathname === "/hang") return;
if (url.pathname === "/401") { res.writeHead(401); return res.end("{}"); }
if (inflight >= CAPACITY) {
res.writeHead(429, { "retry-after": "1" });
return res.end(JSON.stringify({ error: { type: "rate_limit_error" } }));
}
inflight++;
const cut = Number(url.searchParams.get("cut") ?? -1); // abandon after N chunks
const how = url.searchParams.get("how"); // "close" = orderly, else reset
const max = Number(url.searchParams.get("max_tokens") ?? 999);
const delay = Number(url.searchParams.get("delay") ?? 60); // ms per token
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
for (let i = 0; i < Math.min(WORDS.length, max); i++) {
if (i === cut) {
how === "close" ? res.end() : res.destroy();
inflight--; return;
}
await new Promise((r) => setTimeout(r, delay));
sse(res, { choices: [{ delta: { content: (i ? " " : "") + WORDS[i] }, finish_reason: null }] });
}
sse(res, { choices: [{ delta: {}, finish_reason: max < WORDS.length ? "length" : "stop" }] });
res.write("data: [DONE]\n\n");
inflight--;
res.end();
}).listen(8787);Dört düşmanca davranış, her biri bir satır: /hang socket’i kabul eder ve ona hiç yazmaz; /401 key’i reddeder; capacity check, hâlihazırda üç request uçuşta olduğunda gerçek bir Retry-After header’ıyla gerçek bir 429 üretir; ?cut=N ise ya socket’i reset ederek ya da — &how=close ile — düzenli biçimde kapatarak cevabı yarıda bırakır; bunun çok önemli olduğu ortaya çıkıyor. Geri kalanı gerçek bir Server-Sent Events stream’i: her data: satırında bir JSON object, event’ler arasında boş satır, sonda [DONE] string’i.1
Çalıştır; bölümün kalanı ölçüm.
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"length"}]}
data: [DONE]Request body ve server’dan asla ayrılmayan key
Bölüme bağlantı: Request body ve server’dan asla ayrılmayan keyBir chat request, her biri role taşıyan bir messages listesidir. Bu liste modelin tüm state’idir: çağrılar arasında memory yoktur ve modelin bilmesini istediğin her şey bu sefer gönderdiğin array’in içinde olmak zorundadır. Chapter 15 içine ne koyacağını, Chapter 16 bunun maliyetini anlatıyor; burada sadece şekil var.
const body = {
model: "gpt-4.1-mini",
messages: [
{ role: "system", content: "You explain instruments in one sentence." },
{ role: "user", content: "What is a tide gauge?" },
],
stream: true,
max_tokens: 200,
};Bu role’ler süs değildir. Model tek bir token görmeden önce Chapter 11’deki chat template’e render edilirler; yanlış role göndermenin hata fırlatmak yerine cevabı sessizce bozmasının nedeni bu.
İstisnasız tek kural: API key asla client’a gitmez. Browser için prefix’lenmiş bir environment variable’da değil, build-time constant’ta değil, “geçici olarak” hiç değil. Bundle içindeki bir key, birkaç gün içinde başkasının faturasında çalışan bir key’dir. Browser senin server’ınla konuşur; server’ın key’i tutar ve provider’la konuşur — server’ın ortada olduğu için, her kullanıcının ne harcadığını ölçebilen tek yer de orasıdır; Chapter 16’daki muhasebe orada yaşamak zorunda.
Aynı soru, üç kez
Bölüme bağlantı: Aynı soru, üç kezŞimdi bölümün üzerine kurulduğu deney. Tek soru, 60 ms’de bir on üç token üreten tek mock provider, sormanın üç yolu.
İlki, streaming olmadan. Client request’i gönderir ve tüm JSON body’yi bekler.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopİki sayı aynı ve bütün problem bu. 791 ms boyunca kullanıcıda bir spinner var ve tek bir kelime bile daha önce kullanılabilir değildi — server cevaba byte byte sahipti ve hiçbir şey söylememeyi seçti.
İkincisi, streaming ile. Aynı server, aynı cevap, aynı toplam iş. Fark bir parser.
export async function* readSSE(res: Response) {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of event.split("\n")) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
yield JSON.parse(payload);
}
}
}
}Oradaki üç ayrıntı yük taşıyor ve ilk denemelerin çoğu üçünü de atlıyor. buffer var çünkü bir network chunk’ın bir event ile ilişkisi yoktur: bir read() yarım event ya da iki buçuk event döndürebilir. { stream: true } flag’i var çünkü çok byte’lı bir UTF-8 karakter iki chunk’a bölünebilir; o olmadan aksanlı bir harf rastgele replacement character’a dönüşür. Ve event’ler newline ile değil boş satırla ayrılır; bu yüzden döngü \n\n arar.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopİlk kelimeye on iki kat daha hızlı, son kelimeye iki milisaniye daha yavaş. Streaming hiçbir şeyi hızlandırmaz. Aynı 790 ms boyunca kullanıcının ne yaptığını değiştirir: beklemek yerine okumak. Bütün fayda budur, muazzamdır ve her chat ürününün stream etmesinin nedeni budur.
Üçüncüsü, aynı anda yirmi client ile. Mock provider aynı anda üç request’e hizmet verir. Yirmisini ateşle:
jitter=true clients=20 server capacity=3
HTTP requests made: 74 429s received: 54 200s: 20
wall clock: 7,100 ms
retries per client: 0 0 0 1 1 1 2 2 2 3 4 3 3 5 4 5 4 5 4 5
every answer identical: trueYirmi cevap, yetmiş dört request, elli dört ret. Kimse bir şey kaybetmedi, her client aynı metni aldı ve görünen tek maliyet zamandı. Bu, çalışan bir retry policy. Bölümün kalanı bunun başarısız olabileceği üç yolla ilgili.
finish_reason ve aynı görünen iki son
Bölüme bağlantı: finish_reason ve aynı görünen iki sonHatalardan önce, hemen herkesin ilk geçişte görmezden geldiği field. Her stream finish_reason taşıyan bir event ile biter. stop modelin bittiğine karar verdiği anlamına gelir. length token tavanına çarptığı anlamına gelir; yani cevap cümlenin ortasında kesilmiştir ve bu modelin suçu değildir. Sonraki bölümler tool_calls (Chapter 18) ve content filter’lar ekler.
Şimdi naive bir client’ın ayırt edemediği iki sona bak. Aynı server, aynı gecikme; biri max_tokens ile truncation, diğeri bağlantının beş token’dan sonra temizce kapatılması:
max_tokens=5 loop ended NORMALLY chunks=5 finish_reason=length text="A tide gauge is a"
socket closed cleanly loop ended NORMALLY chunks=5 finish_reason=null text="A tide gauge is a"
socket destroyed threw TypeError: terminated (UND_ERR_SOCKET)
chunks=4 finish_reason=null text="A tide gauge is"İlk iki satırı dikkatle oku. Aynı metin. Aynı chunk sayısı. İkisinde de exception yok. for await döngüsü iki durumda da normal bitti, çünkü reader’ın bakış açısından body bitti ve bir body’nin yapabileceği tek şey bu. Tüm gözlemdeki tek fark, birinin finish_reason: "length" taşıması ve diğerinin hiçbir şey taşımaması.
Yani kural “streaming sırasında hataları yakala” değil. Şu:
finish_reasonolmadan biten bir stream bitmemiştir. Durmuştur.
Eksik bir finish_reason’i her zaman failure kabul et ve o metni tamamlanmış bir cevap olarak asla persist etme. Üçüncü satır daha kolay durumu gösteriyor — destroy edilmiş socket gerçekten throw eder ve uçuşta olan chunk’ı da kaybeder; bu yüzden metin yukarıdaki ikisinden bir kelime daha kısa.
Beş status code, beş farklı problem
Bölüme bağlantı: Beş status code, beş farklı problemYeni bir ürünün en pahalı alışkanlığı, provider’ın döndürdüğü her şey için tek bir catch bloğuna sahip olmaktır. Bu code’lar “başarısız oldu”nun varyasyonları değildir. Beş talimattır ve dördü birbirine ters düşer.
| status | anlamı | ne yapmalı | bekle? |
|---|---|---|---|
| 400 | request’in hatalı biçimlenmiş — bozuk JSON, bilinmeyen field, context çok uzun | kodu düzelt | asla |
| 401 | key yanlış, eksik ya da revoke edilmiş | deployment’ı düzelt | asla |
| 429 | rate limit: dakikada çok fazla request ya da çok fazla token | retry yap | Retry-After, sonra backoff |
| 500 | provider bozuldu | retry yap | backoff |
| 503 | provider overload altında — ayakta ama dolu | retry yap | backoff ve load shed et |
Önemli çizgi 4xx ile geri kalanın arasından geçer. 400 ya da 401’i bin kez gönderirsen tam olarak aynı cevabı döndürür, çünkü denemeler arasında iki uçta da hiçbir şey değişmez. Onu retry etmek tedbir değil, ekstra adımlı gecikmedir. Ölçüm: exponential backoff ile beş retry olmak üzere altı attempt yapan bir client ve önce code’u okuyan bir client.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401Dört milisaniyede mevcut olan cevaba ulaşmak için altı saniye spinner. Ve bu hafif versiyon: ürünlerde retry genellikle iç içedir — retry yapan bir HTTP client, retry yapan bir job runner’ın içinde, kendi redelivery’si olan bir queue’nun içinde — böylece altı saniye, kalıcı olarak bozuk bir deployment’ın yavaş görünerek altı dakikaya uzamasına dönüşür.
Triage dokuz satırdır ve tek bir yerde olmalıdır:
export type Verdict = "retry" | "retry-after" | "fatal";
export function classify(status: number): Verdict {
if (status === 429) return "retry-after";
if (status === 408 || status >= 500) return "retry";
return "fatal"; // 400, 401, 403, 404, 422 — nothing changes by waiting
}Listene iki tane daha: bazı provider’ların “credit’in bitti” için kullandığı 402; retry yerine daha fazla satın alma linki olan bir ekran ister. Bir de 529 veya vendor-specific eşdeğerleri; bunlar 503 gibi davranır.
Backoff ve jitter’ın gerçekte ne kazandırdığı
Bölüme bağlantı: Backoff ve jitter’ın gerçekte ne kazandırdığıRetry etmek kolay. Ne zaman retry edileceği, ölçülebilir doğru cevabı olan kısımdır.
Exponential backoff standarttır: bir base delay bekle, her failure’dan sonra ikiye katla, bir tavanda dur. Var olma nedeni şu: overload altındaki bir server, az önce failed olan client’lar hemen geri gelirse daha kötüleşir.
Problem, herkesin aynı başlangıç noktasından ikiye katlamasıdır. Yüz client aynı anda bir limite çarparsa — ve çarparlar, çünkü traffic spike budur — yüzünün tamamı 200 ms bekler, yüzünün tamamı birlikte retry eder, yüzünün tamamı birlikte fail olur ve yüzünün tamamı 400 ms bekler. Retry schedule onları senkronize etmiştir. Bu bir thundering herd ve çözüm randomness’tır.2
Bu tek değişiklik — aralığın üst ucunu almak yerine aralıktan uniform seçmek — full jitter diye adlandırılır. Math.random()’a tek bir çağrıdır ve inanmak yerine ölçmeye değer:
export const backoffNaive = (n: number, base = 200, cap = 20_000) =>
Math.min(cap, base * 2 ** n);
export const backoffFull = (n: number, base = 200, cap = 20_000) =>
Math.random() * Math.min(cap, base * 2 ** n); Yüz client, aynı anda üçüne hizmet veren bir server, diğer her şey aynı, her biri üç run:
| HTTP requests | rejections | en kötü client | en yoğun 50 ms penceresi | wall clock | |
|---|---|---|---|---|---|
| jitter yok, run 1 | 491 | 391 | 10 deneme | 46 geliş | 65,6 sn |
| jitter yok, run 2 | 780 | 680 | 19 deneme | 72 geliş | 245,7 sn |
| jitter yok, run 3 | 770 | 670 | 18 deneme | 97 geliş | 225,6 sn |
| full jitter, run 1 | 324 | 224 | 5 deneme | 32 geliş | 2,2 sn |
| full jitter, run 2 | 313 | 213 | 6 deneme | 31 geliş | 2,3 sn |
| full jitter, run 3 | 318 | 218 | 6 deneme | 25 geliş | 1,8 sn |
O tabloda iki şey var ve önemlisi ikincisi.
İlki median: 226 saniyeye karşı 2,2, yaklaşık yüz kat, request sayısının yarısından azıyla. En yoğun retry penceresi nedenini söylüyor. Jitter olmadan yüz client’ın 97’sine kadarı aynı 50 milisaniyelik slot içine geldi; server’da üç yer vardı, yani 94’ü reddedildi ve hâlâ senkronize şekilde, daha uzun bir beklemeyle aynı şeyi tekrar yapmak üzere uyudu. Jitter ile aynı yüz client aynı pencerelere yaklaşık otuzluk gruplar hâlinde yayıldı ve neredeyse hemen boşaldı.
İkincisi variance. Jitter olmadan: 65,6 sn, 245,7 sn, 225,6 sn. Onunla: 2,2, 2,3, 1,8. Jitter’sız bir sistem yalnızca kötü performans göstermez; öngörülemez performans gösterir, çünkü sonucu, senkronize olmuş yüz client’tan hangi üçünün önce geldiğini seçen mikroskobik scheduling kazaları belirler. Production’da bu bug’ın imzası budur: iyi, iyi, iyi olan ve sonra dört dakika süren bir endpoint; senin yaptığın hiçbir değişiklik bunu açıklamaz.
En ucuz retry ise hiç gerçekleşmeyendir. Provider’ın önüne bir concurrency gate koy — uçuşta N’den fazla request’e asla izin vermeyen bir counter — ve 74 request ile 7,1 saniyeye ihtiyaç duyan aynı yirmi client şöyle davranır:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msYirmi cevap için yirmi request, sıfır ret, sekiz kat daha hızlı. Retry özürdür; gate özre ihtiyaç duymamaktır.
Retry-After bir tabandır, öneri değil
Bölüme bağlantı: Retry-After bir tabandır, öneri değilBir provider 429 döndürdüğünde genellikle Retry-After header’ında ne kadar beklemen gerektiğini söyler.3 Bu sayı tavsiye değildir: exchange içinde penceresinin ne zaman resetleneceğini bilen tek taraf provider’dır.
Bu yüzden bekleme ikisinin büyüğüdür: asla Retry-After’den az değil ve kendi backoff’undan da az değil; çünkü header limiter’ın seni ne zaman affedeceğini söyler, server’da ne zaman yer olacağını değil.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); Yirmi client’lık run’daki en şanssız client’ın trace’i header’ın işini yaptığını gösteriyor. İlk dört backoff çekilişinin hepsi bir saniyenin altındaydı ve dördü de override edildi:
t+ 26ms attempt 0 HTTP 429 -> sleep 1000 ms
t+ 1032ms attempt 1 HTTP 429 -> sleep 1000 ms
t+ 2034ms attempt 2 HTTP 429 -> sleep 1000 ms
t+ 3046ms attempt 3 HTTP 429 -> sleep 1000 ms
t+ 4047ms attempt 4 HTTP 429 -> sleep 2782 ms
t+ 6852ms attempt 5 HTTP 200 -> sleep 0 msİki pratik not. Retry-After saniye sayısı yerine bir HTTP date olabilir; ikisini de parse et. Ayrıca provider’lar aynı anda iki eksende rate-limit uygular — dakikadaki request ve dakikadaki token — bu yüzden uzun prompt’lar dokümante edilmiş request limitinin çok altında reddedilir. Header iki durumda da aynı görünür; çözüm aynı değildir.
Kimsenin seçmediği timeout
Bölüme bağlantı: Kimsenin seçmediği timeoutMock provider’dan /hang iste. Bağlantıyı kabul eder ve sonra hiçbir şey yapmaz: header yok, body yok, close yok. Bu egzotik değil — arkasındaki process socket’lerini kapatmadan öldüğünde bir load balancer’ın yaptığı şey budur.
İki client, tek fark:
AbortSignal.timeout(5s) gave up after 5.0 s (TimeoutError: The operation was aborted due to timeout)
no timeout gave up after 300.8 s (TypeError: fetch failed)
cause: HeadersTimeoutError UND_ERR_HEADERS_TIMEOUTÜç yüz saniye. Açık tutulan bir socket, işgal edilmiş bir request slot’u ve spinner’a bakan bir kullanıcıyla beş dakika; ne olduğunu anlatmayan generic bir TypeError ile bitiyor. Bu sayı bug değil: Node’un default headers timeout’u; generic bir HTTP client için makul, user-facing bir request için felaket. Her runtime’ın böyle bir default’u vardır, çoğu insan hiç bakmaz ve kendininkini bulmanın tek yolu az önce yaptığımız gibi bir socket’i bilerek asılı bırakmaktır.
Yani: her outgoing request, senin seçtiğin açık bir deadline alır.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});Bir streaming call için tek deadline yetmez, çünkü iki farklı failure vardır. İlki stream hiç açılmaz: hiçbir event gelmez ve on ile otuz saniye arası doğrudur. İkincisi stream açılır ve sonra stall olur: token’lar akar ve sonra socket hâlâ sağlıklı hâlde sonsuza dek durur. Total-duration timeout, durmuş bir stream’i uzun ama doğru bir cevaptan ayıramaz; bu yüzden istediğin şey bir idle timeout’tur — her event ile resetlenen, yalnızca örneğin on beş saniye boyunca hiçbir şey gelmediğinde tetiklenen bir timer.
Cancellation, aynı mekanizmanın bir insana çevrilmiş hâlidir. AbortSignal.timeout ve Stop’a basan bir kullanıcı ikisi de AbortError olarak gelir; bu yüzden onları birleştir ve hangisinin tetiklendiğini kaydet:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort etmek düzenlilikten daha fazlası için önemlidir: sen dinlemiyorken token’lar üretiliyor ve faturalandırılıyor. Chapter 16 buna fiyat koyuyor.
Neyi retry etmek güvenli
Bölüme bağlantı: Neyi retry etmek güvenliŞimdi zaman yerine para kaybettiren failure. Bir request client tarafında timeout’a düşer ve bariz hamle onu yeniden göndermektir — ama timeout, server’ın onu alıp almadığı hakkında hiçbir şey söylemez. Çok sık almıştır ve hâlâ çalışıyordur.
Ölçüldü. Mock provider cevap için 780 ms’ye ihtiyaç duyuyor. Client 300 ms’de vazgeçiyor ve retry yapıyor. Server gerçekte kaç cevap ürettiğini sayıyor; faturalandıracağı şey budur:
idempotency-key: no attempt 0: TimeoutError after 300 ms | attempt 1: TimeoutError after 300 ms
answers generated (and billed): 2
idempotency-key: yes attempt 0: TimeoutError after 300 ms | attempt 1: HTTP 200 (replay) id=cmpl_1
answers generated (and billed): 1Key olmadan: iki tam generation, iki kez ücret, client ise hiçbirini almadı. Key ile: server ikinci request’i aynı request olarak tanıdı ve zaten üretmiş olduğu cevapla anında döndü; böylece retry hem çift ücretten kaçındı hem de sonunda başarılı olan attempt oldu.
Bir idempotency key, logical operation başına — attempt başına değil — ürettiğin ve onun her retry’ında değiştirmeden gönderdiğin unique string’dir. Server sonucu key’e karşı saklar ve replay eder. Payment API’lerinin aynı nedenle kullandığı mekanizma budur.4
async function send(url: string, payload: unknown) {
const key = crypto.randomUUID(); // once per turn, not per attempt
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(payload),
headers: { "content-type": "application/json", "idempotency-key": key },
signal: AbortSignal.timeout(20_000),
});
if (res.ok) return res;
if (classify(res.status) === "fatal") throw new Error(`HTTP ${res.status}`);
await sleep(backoffFull(attempt));
}
throw new Error("out of attempts");
}İki dürüst sınır. Her provider completions üzerinde idempotency key desteklemez ve endpoint idempotent değilse, zaten çalışmış olabilecek bir POST için doğru retry sayısı sıfırdır. Ayrıca yarıda failed olan bir stream genel durumda replay edilemez: ya yeniden başlatır ve tekrar ödersin ya da kısmi metni tutar ve incomplete olarak işaretlersin. Ürününün bunlardan hangisini yaptığı bir networking kararı değil, ürün kararıdır ve bilerek vermeye değer.
Dikişi kapatmak
Bölüme bağlantı: Dikişi kapatmakBu bölümde yazılan client portun arkasında ne olduğuna dair hiçbir fikre sahip değil. Base URL’ini commercial bir provider’a yönelt; trilyon parameter’lı bir modelden token stream eder. Chapter 13’ün aritmetiği üzerine kurulu bir server’a yönelt — Chapter 10’da pretrained ettiğin modeli, KV cache’i ve quantized ağırlıklarıyla serving eden bir server — aynı kod, değişmeden, senin inşa ettiğin bir modelden token stream eder.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Bu tek satır kursun dikiş noktasıdır. Bir tarafında ilk on üç bölümün inşa ettiği şey; diğer tarafında sonraki on altı bölümün inşa edeceği şey var. Sınır temiz çünkü contract HTTP ve SSE; iki taraf da diğeri hakkında başka hiçbir şey bilmiyor.
Geçerken ne kaybettiğini fark etmeye değer. Commercial bir endpoint’in arkasında ne ağırlıkları, ne sampling implementasyonunu, ne konuştuğun version’ı, ne de bu sabah değişip değişmediğini kontrol edersin. Kontrol ettiğin şey contract’tır: gönderdiğin messages, set ettiğin deadline, ayırt ettiğin code’lar ve hiçbir şey geri gelmediğinde yaptığın şey. Bu, Chapter 5’te sahip olduğundan daha küçük bir yüzey ve kalan her bölüm onu iyi kullanmakla ilgili.
Bundan sonra nereye gidiyor
Bölüme bağlantı: Bundan sonra nereye gidiyorArtık stream eden, zamanında vazgeçen, doğru şeyleri retry eden ve yanlış olanları asla retry etmeyen bir client’ın var. Gönderdiği şey hâlâ senin yazdığın neyse o.
Chapter 15 bu içerikle ilgili ve bir disiplinle geliyor. İnternet prompting tavsiyeleriyle dolu — modele bahşiş teklif et, tehdit et, derin nefes almasını söyle — ve neredeyse hiçbiri ölçümle gelmiyor. Bu tekniklerin bazıları output’u çok oynatıyor, bazıları hiç oynatmıyor ve en az biri bir classification task’ı daha fazla token’a mal ederken daha kötü yapıyor. Hangisinin hangisi olduğu okuyarak belli olmaz ve tartışmayla çözülmez.
Bu yüzden sonraki bölüm bir bench kuruyor: bilinen cevaplara sahip altmış case, aynı prompt’un dört variant’ı, az önce yazdığın client üzerinden paralel çalıştırılmış, Chapter 4’ten gelen confidence interval’larıyla tabloya dökülmüş — çünkü yirmi case üzerindeki dört variant hiçbir şeyi ayırt etmez. Tüm bölümü yöneten tek cümle: prompt tartışılmaz, ölçülür.
Kaynaklar ve yöntem
Bölüme bağlantı: Kaynaklar ve yöntemYukarıdaki her sayı mock provider’dan, Node 22 üzerinde loopback interface ile geldi; bu yüzden latency’ler herhangi bir gerçek network’ün vereceğinden daha temiz. Bu bilinçli: ölçülen failure’ların hiçbiri network’ten kaynaklanmıyor ve yeniden başlatabildiğin düşmanca bir server, para ödemek zorunda olduğun ve kıramadığın gerçek bir server’dan daha iyi öğretir.
Referanslar
Bölüme bağlantı: Referanslar-
Server-Sent Events, WHATWG HTML Living Standard, bölüm 9.2. Wire format —
data:field’ları, boş satırla ayrılmış event’ler,id:veretry:— orada,EventSourceinterface’iyle birlikte tanımlanır.EventSourcerequest body veya custom header gönderemez; bu yüzden her LLM client formatı onu kullanmak yerinefetchüzerinden elle parse eder. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Yukarıda kullanılan “full jitter” formülasyonunun kaynağı; naive versiyonun client’ları neden senkronize ettiğini gösteren simulation’larla birlikte. Load’u queue’ya almak yerine shed etme yönündeki eşlik eden argüman, Beyer, Jones, Petoff ve Murphy (eds.), Site Reliability Engineering (O’Reilly, 2016) içindeki Handling Overload bölümüdür. ↩
-
Fielding, R., Nottingham, M. ve Reschke, J. (eds.), HTTP Semantics, RFC 9110, bölüm 15, status code sınıflarını tanımlar; Nottingham, M. ve Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), bölüm 4, 429 Too Many Requests’i tanımlar.
Retry-AfterRFC 9110 bölüm 10.2.3’tür ve saniye sayısı ya da HTTP date kabul eder. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, 7 Eylül 2026’da okundu — contract’ın en açık ifadesi: logical operation başına bir key, stored result’ların replay edilmesi, ilk attempt hâlâ uçuşta iken conflict döndürülmesi — ve pattern provider-independent’tır. Burada kullanılan request ve event shape’leri için normatif referanslar streaming, error code’lar ve rate limit’ler içindevelopers.openai.com/api/reference/resources/chat, Messages API içinplatform.claude.com/docs/en/api/messages;ai-sdk.dev/docsaynı kaygıların bir library içinde sarılmış en iyi worked example’ıdır. Hepsi aynı gün okundu. ↩