Mert Özen Her Satırda Daha İleri
Yapay Zeka Angular ile AI Arayüzleri — #03

AI Chat State'i ve Hata Yönetimi: State Machine, Yarım Cevap, Timeout, 429 ve Yeniden Üretmek

Mert Özen 14 Kas 2026 14 dk 1 görüntülenme
AI Chat State'i ve Hata Yönetimi: State Machine, Yarım Cevap, Timeout, 429 ve Yeniden Üretmek

Bir AI chat'ini güvenilir yapan şey açık bir state machine ve hata türlerini ayırmak: 400 ProblemDetails, Retry-After'lı 429, ilk token timeout'u, akış ortasında error event'i ve kopan bağlantı. Bu yazıda yarım cevabı silmeden işaretliyor, abort'u hatadan ayırıyor, API süreci ölünce dev proxy'nin akışı 90 saniye açık tuttuğunu ve ping tabanlı sessizlik zamanlayıcısının bunu 15 saniyede yakaladığını gösteriyor, yeniden üretmeyi sunucudaki ConversationId geçmişiyle bağlıyoruz.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0, Node.js 24) ve .NET 10 ile derlenip denenmiştir; backend'de Microsoft.Extensions.AI 11.0.0 ve OllamaSharp 5.5.2, tarayıcı denemelerinde Playwright 1.65 ile headless Chromium 158, yerel modelde Ollama 0.34.4 (qwen3:4b) kullanılmıştır.

Önce Tahmin Et

Bookstore asistanı (sitedeki serilerin ortak örnek uygulaması Bookstore, bir online kitapçı) bir hikâyenin ortasındayken API süreci çöküyor. Tarayıcıdaki fetch ne yapar: hemen bir hata fırlatır mı, yoksa akış sessizce mi bekler? Ve ekranda yarım kalan cevap için doğru davranış hangisi: silmek mi, olduğu gibi bırakmak mı, "kesildi" diye işaretlemek mi?

Önceki yazıdaki store iyimserdi: tek bir busy boolean'ı, mesaj başına bir durum ve "bir şey ters giderse interrupted yaz" kuralı. Bu yazıda onu açık bir state machine'e çeviriyor, hata türlerini ayırıyor ve her biri için kullanıcıya ne gösterileceğine karar veriyoruz: bağlantı hiç kurulamadı, ilk token gelmedi, akış ortasında error event'i geldi, ağ koptu, sunucu 429 ya da ProblemDetails döndü. Sonra yeniden üretme, son mesajı düzenleme ve sayfa yenilenince sohbeti sunucudan geri yükleme geliyor. İlk sorunun cevabı ölçümde: beklediğin gibi değil.

Örtük Durumdan State Machine'e

2. yazıdaki store'un durumu üç yere dağılmıştı: busy signal'ı, son mesajın status alanı ve send'in içindeki finished değişkeni. Bu üçünün tutarsız olabileceği anlar vardı; örneğin busy false iken son mesaj hâlâ thinking'de kalabiliyordu. Daha önemlisi, "streaming sırasında ikinci bir mesaj gönderilebilir mi?" ya da "durdurulmuş bir cevabı yeniden üretmek sunucudaki geçmişi değiştirir mi?" gibi soruların cevabı kodun içine gömülüydü.

Chat'in durumunu tek bir signal'a ve izin verilen geçişleri bir tabloya taşıyoruz:

03-chat-state-tr.png

export type ChatStatus = 'idle' | 'submitted' | 'streaming' | 'done' | 'error' | 'aborted';

// İzin verilen geçişler; listede olmayan geçiş bir hatadır
const TRANSITIONS: Record<ChatStatus, readonly ChatStatus[]> = {
  idle: ['submitted'],
  submitted: ['streaming', 'error', 'aborted'],
  streaming: ['done', 'error', 'aborted'],
  done: ['submitted'],
  error: ['submitted'],
  aborted: ['submitted'],
};
private to(next: ChatStatus): boolean {
  const current = this.status();
  if (!TRANSITIONS[current].includes(next)) {
    console.warn(`Ignored chat transition ${current} -> ${next}`);
    return false;
  }
  this.status.set(next);
  return true;
}

submitted, isteğin gittiği ama modelden henüz hayat belirtisi gelmediği an; thinking ya da ilk delta ile streaming'e geçiyoruz. Ayrım önemli, çünkü iki aşamada farklı zaman aşımları geçerli ve kullanıcıya farklı şeyler gösteriyoruz ("Sending…" ve "Thinking…"). Arayüzün geri kalanı bu tek signal'dan türüyor:

readonly busy = computed(() => this.status() === 'submitted' || this.status() === 'streaming');
readonly canSend = computed(() => !this.busy() && this.cooldown() === 0);
readonly canRegenerate = computed(
  () => this.canSend() && this.messages().at(-1)?.role === 'assistant',
);

Tablo, "streaming sırasında ikinci mesaj" sorusunu da cevaplıyor: streaming'den submitted'a geçiş yok. Butonu disable etmek arayüz seviyesinde bir önlem; Enter tuşuyla, bir klavye kısayoluyla ya da ileride bir başka component'ten gelen çağrı yine to('submitted')'dan geçmek zorunda. Bu store'u NgRx Signal Store ile de yazabilirsin; durum, computed değerler ve method'lar aynı kalır. İki yaklaşımın karşılaştırması bu turda yazılan Angular'da State Yönetimi serisinde.

Hataları Sınıflandırmak

Bir chat isteği beş farklı şekilde ters gidebilir ve her birinin kullanıcı için anlamı farklı:

DurumNasıl anlaşılırKullanıcıya ne gösterilir
İstek reddedildi400 ProblemDetailsSunucunun detail mesajı; yarım cevap yok
Kota doldu429 ve Retry-AfterGeri sayım; süre dolana kadar "Send" kapalı
İlk token gelmedi30 sn içinde thinking/delta yok"Zamanında cevap vermedi"; yeniden üret
Akış ortasında hataerror event'iYarım cevap korunur, "kesildi" işareti, banner
Bağlantı koptufetch hatası ya da 15 sn hiç event yok"Bağlantı koptu"; yarım cevap korunur

Kullanıcının durdurması bu tabloda yok, çünkü bir hata değil. Yarım cevabın altında "Stopped." yazıyor ve kırmızı bir banner çıkmıyor; kullanıcı neyi kendisinin kestiğini biliyor. Store bu ayrımı ChatError tipiyle taşıyor:

export type ErrorKind = 'network' | 'timeout' | 'rate-limit' | 'bad-request' | 'server' | 'stream';

export interface ChatError {
  kind: ErrorKind;
  message: string;
}

export interface ChatMessage {
  id: number;
  role: 'user' | 'assistant';
  text: string;
  // Asistan mesajının nasıl bittiği; kesilen cevap silinmez, işaretlenir
  ending: 'complete' | 'interrupted' | 'stopped' | null;
}

Sunucu Tarafı: ProblemDetails, 429 ve Retry-After

Tablonun ilk iki satırı akış başlamadan sunucudan geliyor. API'ye üç şey ekledim: mesaj doğrulaması, ProblemDetails ve ASP.NET Core'un rate limiter'ı.

builder.Services.AddSingleton<ConversationStore>();
builder.Services.AddProblemDetails();
builder.Services.AddRateLimiter(options =>
{
    // Demo: istemci IP'si başına dakikada 10 istek; production'da kullanıcı kimliğine göre bölümle
    options.AddPolicy("chat", http => RateLimitPartition.GetFixedWindowLimiter(
        http.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1) }));
    options.OnRejected = async (context, ct) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
        }
        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        await TypedResults.Problem(title: "Too many requests", statusCode: 429,
                detail: "You have sent too many messages. Please wait before sending another one.")
            .ExecuteAsync(context.HttpContext);
    };
});
app.MapPost("/chat/sse", IResult (ChatStreamRequest request, IChatClient chat, ConversationStore conversations,
    ILogger<Program> logger, CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(request.Message) || request.Message.Length > 2000)
    {
        return TypedResults.Problem(title: "Invalid message", statusCode: 400,
            detail: "The message must be between 1 and 2000 characters.");
    }
    // ...
}).RequireRateLimiting("chat");

Rate limiting'in ayrıntıları ve kullanıcı başına bölümleme .NET ile Yapay Zeka serisinin maliyet yazısında anlatıldı; burada arayüzün göreceği cevaba bakıyoruz:

$ curl -s -i -X POST localhost:19640/chat/sse -H 'Content-Type: application/json' -d '{"message":""}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid message","status":400,
 "detail":"The message must be between 1 and 2000 characters.","traceId":"00-d16773d9...-02"}

# 10 istekten sonra
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 60

{"title":"Too many requests","status":429,"detail":"You have sent too many messages. Please wait before sending another one.","traceId":"00-bdd7e120...-02"}

Bir ayrıntı: 429'u üç saniye arayla üç kez aldığımda üçünde de Retry-After: 60 geldi. .NET 10'daki fixed window limiter bu değeri pencerenin kalan süresi olarak değil tamamı olarak veriyor; dotnet/runtime deposunun ana dalındaki kod kalan süreyi hesaplıyor. Arayüz için sonuç değişmiyor: header ne diyorsa o kadar bekliyoruz, belki gerektiğinden biraz fazla.

ChatApi: Akış Başlamadan Dönen Hatalar

2. yazıda response.ok değilse genel bir Error fırlatıyorduk. Şimdi ProblemDetails gövdesini ve Retry-After'ı taşıyan bir hata tipi var:

// Akış hiç başlamadan dönen HTTP hatası; gövde ProblemDetails (RFC 9457)
export class ChatHttpError extends Error {
  constructor(
    readonly status: number,
    readonly detail: string,
    readonly retryAfterSeconds: number | null,
  ) {
    super(`HTTP ${status}: ${detail}`);
  }
}
if (!response.ok || !response.body) {
  const problem = await response.json().catch(() => null);
  const retryAfter = Number(response.headers.get('Retry-After'));
  throw new ChatHttpError(
    response.status,
    problem?.detail ?? response.statusText,
    Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null,
  );
}

response.json()'ı catch ile sarmamın sebebi, her hata cevabının JSON olmaması: önündeki reverse proxy 502 ya da 504'ü bir HTML sayfasıyla döndürebilir. Retry-After bir saniye sayısı ya da bir HTTP tarihi olabilir; bizim API saniye döndürüyor, tarih gelirse Number NaN verir ve store varsayılan 30 saniyeyi kullanır.

Zaman Aşımları ve Sessizlik

Store'un run method'u iki zamanlayıcı kuruyor. İlki ilk token için: 30 saniye içinde thinking ya da delta gelmezse istek iptal ediliyor. İkincisi sessizlik için: sunucu model sessizken 5 saniyede bir ping gönderiyor, dolayısıyla 15 saniye boyunca hiçbir event gelmemesi bağlantının öldüğü anlamına geliyor.

// İlk token gelmezse isteği "timeout" nedeniyle iptal et
const timer = setTimeout(
  () => controller.abort(new DOMException('No answer within 30 seconds.', 'TimeoutError')),
  FIRST_TOKEN_TIMEOUT_MS,
);
let silence = 0;
const resetSilence = () => {
  clearTimeout(silence);
  silence = setTimeout(
    () => controller.abort(new DOMException('No event within 15 seconds.', 'NetworkError')),
    SILENCE_TIMEOUT_MS,
  );
};
resetSilence();
let ended = false;
try {
  const request = { message: text, conversationId: this.conversationId(), replaceLastTurn };
  for await (const event of this.api.stream(request, controller.signal)) {
    resetSilence();
    switch (event.type) {
      // ...
    }
  }
  if (!ended) {
    this.fail(id, { kind: 'network', message: 'The connection closed before the answer was complete.' });
  }
} catch (err) {
  this.handleError(id, err);
}

AbortController.abort()'a bir sebep verdiğimizde fetch ve stream okuması o sebeple reddediliyor. Böylece tek bir AbortController üç farklı iptali taşıyabiliyor ve handleError hangisi olduğunu name'den ayırt ediyor:

private handleError(id: number, err: unknown): void {
  if (err instanceof DOMException && err.name === 'AbortError') {
    // Kullanıcı durdurdu: bu bir hata değil
    this.finish(id, 'stopped', 'aborted');
  } else if (err instanceof DOMException && err.name === 'TimeoutError') {
    this.fail(id, { kind: 'timeout', message: 'The assistant did not start answering in time.' });
  } else if (err instanceof ChatHttpError && err.status === 429) {
    this.fail(id, { kind: 'rate-limit', message: err.detail });
    this.startCooldown(err.retryAfterSeconds ?? 30);
  } else if (err instanceof ChatHttpError && err.status < 500) {
    this.fail(id, { kind: 'bad-request', message: err.detail });
  } else if (err instanceof ChatHttpError) {
    this.fail(id, { kind: 'server', message: 'The assistant is unavailable right now.' });
  } else {
    // fetch TypeError ya da sessizlik (NetworkError): bağlantı kurulamadı veya akış ortasında koptu
    this.fail(id, { kind: 'network', message: 'Connection lost. Check your network and try again.' });
  }
}

Bu sessizlik zamanlayıcısını ilk tasarımda koymamıştım. Önce Tahmin Et'teki senaryoyu denediğimde neden gerektiğini gördüm. Angular dev server'ın proxy'si üzerinden bir hikâye isterken, ilk delta'lar geldikten sonra API sürecini kill -9 ile öldürdüm. curl çıktısı:

 15962 ms  event: delta
 15962 ms  data: {"text":" eyes"}
 15985 ms  event: delta
 15985 ms  data: {"text":" wide"}

# 90 saniye sonra
$ pgrep -f "localhost:4260/chat/sse"
14002

Son delta 16. saniyede geldi ve sonra hiçbir şey olmadı: ne bir hata ne bağlantının kapanması. Upstream ölmüş olsa da proxy istemciye giden cevabı açık tuttu ve curl 90 saniye sonra hâlâ bekliyordu. Tarayıcıda da aynısı olur: fetch hata fırlatmaz, for await sonsuza kadar bekler, ekranda yarım bir cevap ve sonsuza kadar dönen bir "Stop" butonu kalır. Production'daki reverse proxy'n farklı davranabilir ama buna güvenmek yerine heartbeat'i istemci tarafında da kullanmak daha sağlam. Aynı denemeyi Playwright'ta store'un son hâliyle tekrarladım:

$ node run03.mjs drop
  8284 ms killed API process (kill -9)
 23650 ms status: error | bubbles: ["Write a 300-word story about a dog who guards a library. | Edit",
          "I cannot write creative stories as I am Bookstore's assistant, | The answer was cut off."]
          | banner: Connection lost. Check your network and try again.

API öldükten 15,4 saniye sonra sessizlik zamanlayıcısı isteği iptal etti, yarım cevap ekranda kaldı ve altına "The answer was cut off." yazıldı. Cevabın kendisi de not edilmeye değer: system prompt "yalnız kitaplar, siparişler ve mağaza hakkında cevap ver" dediği için model hikâye yazmayı reddetmeye başlamıştı.

İlk token zaman aşımını da X-Chaos: stall-40 header'ıyla, yani modelden önce 40 saniye bekleyerek denedim:

$ node run03.mjs timeout
 30102 ms status: error | banner: The assistant did not start answering in time.

# sunucu log'u
Stream closed: deltas=0, firstDelta=(null) ms, elapsed=30000 ms, aborted=True

Bu arada sunucu 5 saniyede bir ping gönderdi; sessizlik zamanlayıcısı her seferinde sıfırlandı ama ilk token zamanlayıcısı sıfırlanmadı. 30. saniyede istek iptal edildi ve sunucu aborted=True ile kapandı: timeout yalnız ekrandaki bir mesaj değil, sunucudaki işi de durduruyor.

Yarım Cevap: Akış Ortasında Hata

Akış ortasında hata üretmek için gerçek modelin önüne yalnız Development ortamında eklenen bir DelegatingChatClient koydum. X-Chaos header'ına göre modelin akışını belli bir parçadan sonra kesiyor ya da başlamadan önce bekletiyor:

// Yalnız Development: X-Chaos header'ı ile gerçek modelin önüne hata enjekte eder
// "fail-after-5": 5 metin parçasından sonra exception, "stall-40": ilk parçadan önce 40 sn bekle
public sealed class ChaosChatClient(IChatClient inner, IHttpContextAccessor http) : DelegatingChatClient(inner)
{
    public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        string chaos = http.HttpContext?.Request.Headers["X-Chaos"].ToString() ?? "";
        int failAfter = chaos.StartsWith("fail-after-") ? int.Parse(chaos["fail-after-".Length..]) : -1;
        if (chaos.StartsWith("stall-"))
        {
            await Task.Delay(TimeSpan.FromSeconds(int.Parse(chaos["stall-".Length..])), cancellationToken);
        }

        int texts = 0;
        await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
        {
            if (failAfter >= 0 && update.Contents.OfType<TextContent>().Any(t => t.Text.Length > 0) && texts++ == failAfter)
            {
                throw new HttpRequestException("Chaos: model connection reset");
            }
            yield return update;
        }
    }
}

Playwright'ta page.route ile isteğe header ekleyip bir polisiye önerisi istedim, sonra header'ı kaldırıp "Regenerate"a bastım:

$ node run03.mjs fail
  6912 ms status: error | bubbles: ["Recommend one mystery novel in two sentences. | Edit",
          "\"The Murder of Roger Ack | The answer was cut off."] | banner: The model stopped responding.
 14813 ms status: done | bubbles: ["Recommend one mystery novel in two sentences. | Edit",
          "For a gripping modern mystery, try *Gone Girl* by Gillian Flynn. This psychological thriller ..."] | banner: (no banner)

# sunucu log'u
Model stream failed after 5 deltas
Stream closed: deltas=5, firstDelta=6392 ms, elapsed=6511 ms, aborted=False
Stream closed: deltas=38, firstDelta=6603 ms, elapsed=7451 ms, aborted=False

Yarım cevap ("The Murder of Roger Ack") silinmedi; kullanıcı neyin geldiğini ve nerede kesildiğini görüyor. Sunucunun error event'indeki mesaj banner'a yazıldı, partial: true olduğu için cevabın altında "kesildi" işareti var. "Regenerate" yarım cevabı kaldırıp aynı soruyu yeniden gönderdi ve bu sefer başka bir kitap geldi: Agatha Christie yerine Gillian Flynn. 1. yazıda gördüğümüz belirsizliğin arayüzdeki karşılığı bu: yeniden üretmek aynı cevabı tamamlamak değil, yeni bir cevap istemek.

Yeniden Üret, Düzenle ve ConversationId

Yeniden üretmenin sunucu tarafı var. Asistan bir önceki konuşmayı hatırlasın diye API artık sohbetleri saklıyor: istemci conversationId gönderiyor, sunucu akışın başında bir conversation event'iyle kimliği bildiriyor ve yalnız done ile biten turu geçmişe yazıyor. Kalıcı saklama ve "bu sohbet bu kullanıcının mı?" kontrolü .NET ile Yapay Zeka serisinin sohbet geçmişi yazısında; buradaki ConversationStore bellekte tutan bir demo. İstekteki yeni alan:

// ReplaceLastTurn: sunucudaki son kullanıcı + asistan çiftini sil (yeniden üret / düzenle)
public sealed record ChatStreamRequest(string Message, Guid? ConversationId = null, bool ReplaceLastTurn = false);

Hangi durumda replaceLastTurn gönderileceğine store karar veriyor. Tamamlanmış bir cevabı yeniden üretiyorsan o tur sunucuda saklı ve değiştirilmesi gerekiyor. Kesilmiş ya da durdurulmuş bir cevap ise hiç saklanmadı; aynı soruyu yeni bir tur olarak göndermek yeterli:

// Son cevabı yeniden üret: tur sunucuda saklandıysa onu değiştir
regenerate(): Promise<void> {
  const list = this.messages();
  const answer = list.at(-1);
  const question = list.at(-2);
  if (!this.canRegenerate() || answer?.role !== 'assistant' || question?.role !== 'user') {
    return Promise.resolve();
  }
  this.messages.set(list.slice(0, -2));
  return this.run(question.text, answer.ending === 'complete');
}

editLast aynı mantıkla son soruyu yeni metinle gönderiyor. Sayfa yenilendiğinde store, sessionStorage'daki kimlikle geçmişi istiyor; sunucu yeniden başladıysa 404 dönüyor ve store yeni bir sohbet başlatıyor:

// Sayfa yenilenince sunucudaki geçmişi yükle
async restore(): Promise<void> {
  const id = this.conversationId();
  if (id === null || this.messages().length > 0) {
    return;
  }
  const history = await this.api.history(id);
  if (history === null) {
    this.newConversation();
    return;
  }
  this.messages.set(
    history.map((h) => ({ id: this.nextId++, role: h.role, text: h.text, ending: h.role === 'assistant' ? 'complete' : null })),
  );
}

Gerçek modelle iki mesajlık bir sohbet ve sonrasında sayfa yenileme:

$ node run03.mjs history
before reload: [
 "My name is Ada and I like science fiction. Recommend one book in one sentence.",
 "I recommend Frank Herbert's *Dune* for its epic blend of political intrigue, ecological themes, and visionary science fiction.",
 "What is my name, and which genre do I like? One sentence. | Edit",
 "Your name is Ada and you like science fiction."
]
# yenilemeden sonra: aynı dört mesaj, aynı sırayla
conversationId: 4c8e1ecf-c9e8-4ccb-b3e2-678edf049cbc

Model ikinci soruda adı ve türü hatırladı, çünkü sunucu ilk turu geçmişe yazmıştı. Yenilemeden sonra dört mesaj sunucudan geri geldi. Kimliği sessionStorage'da tutmam bilinçli: sekme kapanınca sohbet de kapanıyor. Kullanıcının sohbetlerini cihazlar arasında taşımak istiyorsan kimlik sunucuda, kullanıcı hesabına bağlı saklanır.

Üretimde Dikkat

Hata mesajlarını kullanıcının dilinde ve aksiyonla yaz. "HTTP 503" değil, "Asistan şu an cevap veremiyor, birazdan tekrar dene" ve yanında "Regenerate". Teknik ayrıntıyı log'a ve hata izleme aracına gönder; traceId alanı sunucu log'uyla eşleştirmek için ideal.

Zaman aşımı değerlerini modelin gerçek dağılımına göre seç. Bu yazıdaki 30 saniye, qwen3:4b'nin düşünme süresine göre ayarlandı; düşünmeyen hızlı bir model için 10 saniye yeterli olabilir, uzun düşünen bir reasoning modelinde 30 saniye yetmeyebilir. 1. yazıdaki gibi TTFT'yi (Time To First Token; ilk metin parçasının gelme süresi) ölç ve yüzdelik dilimlere bak.

Yeniden denemeyi otomatik yapma. Ağ hatasında bir kez sessizce yeniden denemek cazip gelir ama LLM isteği hem pahalı hem de idempotent değil: aynı soru farklı bir cevap ve yeni bir maliyet üretir. Kararı kullanıcıya bırakmak, en azından akış başladıktan sonra, daha doğru.

Geri sayımı sunucuyla tutarlı tut. Retry-After gelmezse kendi tahminini kullanıyorsun; çok kısa tutarsan kullanıcı tekrar 429 alır ve süre uzadıkça uzar.

Sık Düşülen Tuzaklar

Birincisi, hata olunca yarım cevabı silmek. Kullanıcı okuduğu metnin kaybolduğunu görür ve ne olduğunu anlamaz. Yarım cevap kalır, "kesildi" diye işaretlenir.

İkincisi, abort'u hata gibi göstermek. Kullanıcının kendi bastığı "Stop" için kırmızı bir banner çıkarmak ona bir şeyi yanlış yaptığını söyler.

Üçüncüsü, fetch'in bir hata fırlatacağına güvenmek. Ölçümde proxy upstream'in ölümünü istemciye hiç iletmedi; heartbeat olmadan bu durumu ayırt etmenin yolu yok.

Dördüncüsü, iptal sebebini exception tipinden çıkarmaya çalışmak. Kullanıcı iptali, timeout ve sessizlik aynı AbortController'dan geliyor; ayrımı abort()'a verilen sebebin name'i yapıyor.

Beşincisi, yeniden üretirken sunucudaki geçmişi unutmak. Tamamlanmış bir turu sunucuda bırakıp aynı soruyu yeniden gönderirsen model, kendi önceki cevabını da görerek cevap verir ve geçmişte aynı soru iki kez durur.

Küçük Bir Deneme

★☆☆ API'yi kill -9 yerine kill ile durdur ve aynı denemeyi yap. ASP.NET Core'un graceful shutdown'ı açık akışa ne yapıyor? Ekranda ne görüyorsun?

★★☆ 429 geri sayımı sırasında kullanıcı sayfayı yenilerse geri sayım kaybolur. Bitiş zamanını sessionStorage'a yazıp yenilemeden sonra kaldığı yerden devam ettir.

★★★ ChatStore'u NgRx Signal Store ile (signalStore, withState, withComputed, withMethods) yeniden yaz. TRANSITIONS tablosunu nereye koyarsın? Geçersiz bir geçişi bir unit test ile göster.

Artık hatalar ayrışıyor ve yarım cevap kaybolmuyor. Ama ekrandaki metin hâlâ düz metin: *Gone Girl*'deki yıldızlar olduğu gibi duruyor. Bir sonraki yazıda model çıktısını markdown olarak render ediyoruz ve bunu güvenle yapmanın neden zor olduğunu görüyoruz: model çıktısı güvenilmeyen bir girdi, ve streaming'de markdown her an yarımdır.