Mert Özen Her Satırda Daha İleri
Frontend RxJS — #09

RxJS'te Error Handling: catchError, retry ile Backoff, finalize ve Stream'i Öldürmeden Hata Yakalamak

Mert Özen 14 Kas 2026 22 dk 13 görüntülenme
RxJS'te Error Handling: catchError, retry ile Backoff, finalize ve Stream'i Öldürmeden Hata Yakalamak

catchError hatayı yakalayıp devam etmez, akışın geri kalanını döndürdüğün Observable ile değiştirir; uzun yaşayan bir akışı yaşatmak için onu switchMap'in içine koymak, retry'ı da backoff ve jitter ile yalnız idempotent isteklerde ve 5xx/ağ hatalarında kullanmak gerekir. Çift siparişi, finalize'ı, Angular'da HttpErrorResponse, ASP.NET Core ProblemDetails ve interceptor ile component arasındaki iş bölümünü gerçek çalıştırmalarla gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler RxJS 7.8.2, Angular 22.2, TypeScript 6.0 ve Node.js 24 ile denenmiştir (Angular projesi, Angular 22.2'nin desteklediği TypeScript 6.0'ı kullanıyor); ProblemDetails yanıtı ASP.NET Core 10'dan alınmıştır. Zamanlı örnekler RxJS'in TestScheduler'ı ile sanal zamanda, Angular örnekleri Playwright 1.65 ile headless Chromium'da çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) arama kutusu, arama API'si hata verdiğinde hata sayfası göstermemeli. Bu yüzden pipe'ın sonuna catchError ekleyip yerine boş bir liste gösteriyoruz. Kullanıcı birer saniye arayla "du", "dun" ve "dune" yazıyor; API bir kez, "dun" için hata veriyor:

import { catchError, concat, NEVER, of, switchMap } from "rxjs";
import { books } from "../../data/books.ts";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeHttp } from "./fake-http.ts";

// arama API'si bir kez, "dun" için başarısız oluyor
function searchBooks(term: string) {
  const status = term === "dun" ? 503 : 200;
  return fakeHttp(`GET /books?q=${term}`, 200, status, () =>
    books.filter((b) => b.title.toLowerCase().includes(term)).map((b) => b.title),
  );
}

runInVirtualTime("search box, catchError at the end", () => {
  const terms$ = concat(emitAt([0, "du"], [1000, "dun"], [2000, "dune"]), NEVER); // kutu açık kalıyor
  terms$
    .pipe(
      switchMap((term) => searchBooks(term)),
      catchError(() => of<string[]>([])),
    )
    .subscribe({
      next: (titles) => log(`screen: [${titles.join(", ")}]`),
      complete: () => log("search stream completed"),
    });
});

2000. ms'de "dune" yazıldıktan sonra ekranda ne görünür?

Önceki yazıda başarısız bir istek yanlışlıkla cache'lendi. Bu yazı hataları bilerek ele almakla ilgili: catchError'ın gerçekte neyi değiştirdiği, bir arama kutusunun başarısız bir istekten nasıl sağ çıktığı, exponential backoff ve jitter ile retry, tekrar denenen bir POST'un neden iki sipariş oluşturabildiği ve loading göstergeleri için finalize. İkinci yarıda bookstore-web'e geçiyoruz: Angular'ın HttpErrorResponse'u, ASP.NET Core'un gönderdiği ProblemDetails gövdesi ve bir functional interceptor ile bir component'in işi nasıl paylaştığı. Sanal zamanlı örnekler snapshot'taki fakeHttp ve flakyHttp'yi (src/09/fake-http.ts) kullanıyor: bir status koduyla cevap veren, 200 dışındaki her şeyde HttpError ile başarısız olan sahte bir çağrı.

Hata Stream'i Bitirir

Cevap:

--- search box, catchError at the end
    0 ms  -> GET /books?q=du
  200 ms  <- GET /books?q=du 200
  200 ms  screen: [Dune]
 1000 ms  -> GET /books?q=dun
 1200 ms  <- GET /books?q=dun 503
 1200 ms  screen: []
 1200 ms  search stream completed

Hiçbir şey. "dun" isteği 1200. ms'de başarısız oldu, catchError hatayı boş bir listeyle değiştirdi ve hemen ardından bütün arama akışı tamamlandı. 2000. ms'deki "dune" API'ye hiç ulaşmadı: kullanıcı istediği kadar yazabilir, arama kutusu sayfa yenilenene kadar ölü.

Sebep, bu serinin ilk yazısındaki Observable contract'ı: error'dan sonra bir akış başka hiçbir şey göndermez. switchMap içteki isteğin hatasını dış akışa iletiyor; böylece başarısız tek bir istek, metin kutusuna olan subscription'ı da öldürüyor. catchError "yakala ve devam et" demek değil; başarısız akıştan unsubscribe olup onun yerine senin döndürdüğün Observable'a subscribe oluyor. Burada bu of([])'dı: bir kez yayıp tamamlanıyor, dolayısıyla sonuç da tamamlandı. Yazının geri kalanında bu resmi aklında tut: catchError akışın geri kalanını başka bir akışla değiştirir.

Üç Strateji: Yerine Koy, Yeniden Fırlat, EMPTY

catchError'dan ne döndürdüğün, subscriber'ın ne göreceğini belirliyor. Aynı başarısız stok isteği, üç şekilde:

import { catchError, EMPTY, of, throwError } from "rxjs";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeHttp } from "./fake-http.ts";

const stock = () => fakeHttp("GET /books/9780441172719/stock", 200, 503, () => 12);
const report = (label: string) => ({
  next: (v: unknown) => log(`${label} next: ${v}`),
  error: (err: Error) => log(`${label} error: ${err.message}${err.cause ? ` (cause: ${(err.cause as Error).message})` : ""}`),
  complete: () => log(`${label} complete`),
});

runInVirtualTime("1. replace: show the last known value", () => {
  stock().pipe(catchError(() => of("12 (cached)"))).subscribe(report("replace"));
});

runInVirtualTime("2. rethrow: add context, let the caller decide", () => {
  stock()
    .pipe(catchError((err: Error) => throwError(() => new Error("Could not load Dune's stock", { cause: err }))))
    .subscribe(report("rethrow"));
});

runInVirtualTime("3. EMPTY: give up silently", () => {
  stock().pipe(catchError(() => EMPTY)).subscribe(report("empty"));
});
--- 1. replace: show the last known value
    0 ms  -> GET /books/9780441172719/stock
  200 ms  <- GET /books/9780441172719/stock 503
  200 ms  replace next: 12 (cached)
  200 ms  replace complete
--- 2. rethrow: add context, let the caller decide
    0 ms  -> GET /books/9780441172719/stock
  200 ms  <- GET /books/9780441172719/stock 503
  200 ms  rethrow error: Could not load Dune's stock (cause: HTTP 503)
--- 3. EMPTY: give up silently
    0 ms  -> GET /books/9780441172719/stock
  200 ms  <- GET /books/9780441172719/stock 503
  200 ms  empty complete

Yedek bir değerle değiştirmek biraz eski olabilecek veriye uyuyor: bilinen son stok, cache'teki kategori listesi, varsayılan bir ayar. Subscriber bir değer ve normal bir tamamlanma alıyor; bu yüzden değerin taze olmadığını ayırt edebilmeli, örnek de bu yüzden onu işaretliyor. Yeniden fırlatmak, ne yapacağına karar veremeyen bir katmana uyuyor, tipik olarak bir service'e: alt seviyedeki hatayı neyin başarısız olduğunu söyleyen bir hatayla sarıyor, orijinali cause olarak saklıyor ve hatayı çağıran ele alıyor. EMPTY değer vermeden tamamlanıyor; yalnız "hiçbir şey" doğru bir sonuçsa kullan, örneğin isteğe bağlı bir öneri kutusunda. Önemli bir veri için kullanıldığında en sessiz hata bu: değer yok, hata yok, log yok.

catchError'ı switchMap'in İçine Koymak: Arama Kutusunu Yaşatmak

Dış akışı yaşatmak için hatanın ona ulaşmadan yakalanması gerekiyor, yani switchMap'in içinde, içteki istekte:

import { catchError, concat, map, NEVER, of, switchMap } from "rxjs";
import { books } from "../../data/books.ts";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeHttp } from "./fake-http.ts";

type SearchResult = { term: string; titles: string[] } | { term: string; error: string };

function searchBooks(term: string) {
  const status = term === "dun" ? 503 : 200;
  return fakeHttp(`GET /books?q=${term}`, 200, status, () =>
    books.filter((b) => b.title.toLowerCase().includes(term)).map((b) => b.title),
  );
}

runInVirtualTime("search box, catchError inside switchMap", () => {
  const terms$ = concat(emitAt([0, "du"], [1000, "dun"], [2000, "dune"]), NEVER);
  terms$
    .pipe(
      switchMap((term) =>
        searchBooks(term).pipe(
          map((titles): SearchResult => ({ term, titles })),
          // hata yalnız bu içteki isteği bitirir; dış akış dinlemeye devam eder
          catchError((err: Error) => of<SearchResult>({ term, error: err.message })),
        ),
      ),
    )
    .subscribe((r) =>
      log("error" in r ? `screen: search for "${r.term}" failed (${r.error}), try again` : `screen: [${r.titles.join(", ")}]`),
    );
});
--- search box, catchError inside switchMap
    0 ms  -> GET /books?q=du
  200 ms  <- GET /books?q=du 200
  200 ms  screen: [Dune]
 1000 ms  -> GET /books?q=dun
 1200 ms  <- GET /books?q=dun 503
 1200 ms  screen: search for "dun" failed (HTTP 503), try again
 2000 ms  -> GET /books?q=dune
 2200 ms  <- GET /books?q=dune 200
 2200 ms  screen: [Dune]

Bu kez hata yalnız "dun" isteğini bitirdi. catchError onu bir hata mesajı taşıyan sonuca çevirdi, ekran "tekrar dene" dedi ve 2000. ms'deki "dune" normal çalıştı. Sonuç tipi bir union (titles ya da error); template ikisini de ele almak zorunda ve TypeScript bunu kontrol ediyor. Aynı kural mergeMap, concatMap ve exhaustMap için, birleştirme yazısının alıştırmasındaki forkJoin kaynakları için de geçerli: hatası yerel kalması gereken içteki Observable'a catchError koy.

retry ile Exponential Backoff ve Jitter

Bazı hatalar geçici: deploy sırasındaki bir 503, kısa bir ağ kesintisi. Bunları hemen ve tekrar tekrar denemek en kötü seçim, çünkü zorlanan bir sunucu aynı anda her istemciden daha da fazla istek alıyor. Exponential backoff her denemeden sonra bekleme süresini ikiye katlıyor, jitter ise istemcileri bu süre içinde rastgele dağıtıyor, böylece hepsi aynı anda tekrar denemiyor. RxJS 7'nin retry'ı bunun için bir config object alıyor: count deneme sayısını sınırlıyor; delay, hatayı ve deneme numarasını alıp bir Observable döndüren bir fonksiyon (o yayınca tekrar dener, hata verirse vazgeçer); resetOnSuccess ise başarılı bir değerden sonra sayacı sıfırlıyor, bu da uzun yaşayan akışlarda önemli. Yardımcılar:

// Jitter'lı exponential backoff: 500, 1000, 2000… ms (sınırlı), her biri %50 ile %100 arasında rastgele.
import { throwError, timer, type Observable } from "rxjs";
import { HttpError } from "./fake-http.ts";

export function backoffDelay(retryCount: number, random: () => number = Math.random, baseMs = 500, maxMs = 8000): number {
  const ceiling = Math.min(maxMs, baseMs * 2 ** (retryCount - 1));
  return Math.round(ceiling / 2 + (random() * ceiling) / 2);
}

// ağ hataları (status 0) ve 5xx bir sonraki denemede başarılı olabilir; 4xx olmaz
export function isRetryable(err: unknown): boolean {
  return err instanceof HttpError && (err.status === 0 || err.status >= 500);
}

// retry({ delay }) için: tekrar denenebilir hatada bekle, gerisini hemen yeniden fırlat
export function retryDelay(random?: () => number) {
  return (err: unknown, retryCount: number): Observable<unknown> =>
    isRetryable(err) ? timer(backoffDelay(retryCount, random)) : throwError(() => err);
}

// seed'li küçük bir rastgele sayı üreteci (mulberry32); örnekler her koşuda aynı beklemeleri bassın diye
export function seededRandom(seed: number): () => number {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

retryDelay yalnız bir sonraki denemede başarılı olabilecek hataları tekrar deniyor: status 0 (hiç yanıt yok) ve 5xx. 404 ya da 400 kaç kez sorarsan sor aynı şekilde başarısız olur, bu yüzden hemen yeniden fırlatılıyor. Seed'li rastgele sayı üreteci yalnız örneklerin her koşuda aynı sayıları basması için; gerçek kod Math.random kullanır.

import { retry } from "rxjs";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { retryDelay, seededRandom } from "./backoff.ts";
import { flakyHttp } from "./fake-http.ts";

const report = (label: string) => ({
  next: (v: unknown) => log(`${label}: ${v}`),
  error: (err: Error) => log(`${label} failed: ${err.message}`),
});

runInVirtualTime("503, 503, 503, then 200", () => {
  flakyHttp("GET /books/9780441172719/stock", 200, [503, 503, 503, 200], () => 12)
    .pipe(retry({ count: 4, delay: retryDelay(seededRandom(42)) }))
    .subscribe(report("stock"));
});

runInVirtualTime("404 is not retried", () => {
  flakyHttp("GET /books/9780000000000", 200, [404], () => "book")
    .pipe(retry({ count: 4, delay: retryDelay(seededRandom(42)) }))
    .subscribe(report("book"));
});

runInVirtualTime("503 every time: give up after 4 retries", () => {
  flakyHttp("GET /categories", 200, [503], () => [])
    .pipe(retry({ count: 4, delay: retryDelay(seededRandom(42)) }))
    .subscribe(report("categories"));
});
--- 503, 503, 503, then 200
    0 ms  -> GET /books/9780441172719/stock
  200 ms  <- GET /books/9780441172719/stock 503
  600 ms  -> GET /books/9780441172719/stock
  800 ms  <- GET /books/9780441172719/stock 503
 1524 ms  -> GET /books/9780441172719/stock
 1724 ms  <- GET /books/9780441172719/stock 503
 3576 ms  -> GET /books/9780441172719/stock
 3776 ms  <- GET /books/9780441172719/stock 200
 3776 ms  stock: 12
--- 404 is not retried
    0 ms  -> GET /books/9780000000000
  200 ms  <- GET /books/9780000000000 404
  200 ms  book failed: HTTP 404
--- 503 every time: give up after 4 retries
    0 ms  -> GET /categories
  200 ms  <- GET /categories 503
  600 ms  -> GET /categories
  800 ms  <- GET /categories 503
 1524 ms  -> GET /categories
 1724 ms  <- GET /categories 503
 3576 ms  -> GET /categories
 3776 ms  <- GET /categories 503
 7115 ms  -> GET /categories
 7315 ms  <- GET /categories 503
 7315 ms  categories failed: HTTP 503

İlk senaryoda denemeler arasındaki beklemeler 400, 724 ve 1852 ms oldu: her biri kendi tavanının (500, 1000, 2000 ms) yarısı ile tamamı arasında; dördüncü deneme başarılı oldu. Kullanıcı bir hata görmek yerine 3,8 saniye bekledi. 404 tek denemede, olması gerektiği gibi başarısız oldu. Üçüncü senaryoda sunucu hiç düzelmedi; dört tekrar ve 7,3 saniye sonra hata subscriber'a ulaştı. count'u ve tavanları kullanıcıyı düşünerek seç: bir spinner'ın arkasında bir dakika boyunca tekrar denemek, birkaç saniye sonra gelen dürüst bir hata mesajından daha kötü.

retry ve POST: Çift Sipariş

Tekrar denemek yalnız tekrarlandığında aynı sonucu veren işlemler için güvenli. GET böyle; sipariş vermek değil. Sonraki örnekte sunucu siparişi istek gelir gelmez oluşturuyor ama yavaş cevap veriyor; istemci bir saniye sonra vazgeçip tekrar deniyor:

import { retry, timeout, timer } from "rxjs";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "../04/fake-api.ts";

// sunucu siparişi istek gelir gelmez oluşturuyor ama yavaş cevap veriyor (1500 ms)
function bookstoreServer() {
  const orders = new Map<string, number>(); // idempotency key -> sipariş id
  let nextId = 1043;
  return {
    placeOrder(idempotencyKey?: string) {
      return fakeRequest(`POST /orders${idempotencyKey ? ` (Idempotency-Key: ${idempotencyKey})` : ""}`, 1500, () => "201", () => {
        if (idempotencyKey && orders.has(idempotencyKey)) {
          log(`   server: key seen before, returns order ${orders.get(idempotencyKey)}`);
          return;
        }
        const id = nextId++;
        if (idempotencyKey) orders.set(idempotencyKey, id);
        log(`   server: order ${id} created`);
      });
    },
  };
}

runInVirtualTime("retry on a POST: every attempt is a new order", () => {
  const server = bookstoreServer();
  server
    .placeOrder()
    .pipe(timeout(1000), retry({ count: 2, delay: () => timer(500) }))
    .subscribe({ next: (r) => log(`client: ${r}`), error: (err: Error) => log(`client: ${err.name}`) });
});

runInVirtualTime("the same retry with an idempotency key", () => {
  const server = bookstoreServer();
  const key = "c1f0e6a2"; // tıklama başına bir kez üretilir, her denemede gönderilir
  server
    .placeOrder(key)
    .pipe(timeout(1000), retry({ count: 2, delay: () => timer(500) }))
    .subscribe({ next: (r) => log(`client: ${r}`), error: (err: Error) => log(`client: ${err.name}`) });
});
--- retry on a POST: every attempt is a new order
    0 ms  -> POST /orders
    0 ms     server: order 1043 created
 1000 ms  x  POST /orders cancelled
 1500 ms  -> POST /orders
 1500 ms     server: order 1044 created
 2500 ms  x  POST /orders cancelled
 3000 ms  -> POST /orders
 3000 ms     server: order 1045 created
 4000 ms  x  POST /orders cancelled
 4000 ms  client: TimeoutError
--- the same retry with an idempotency key
    0 ms  -> POST /orders (Idempotency-Key: c1f0e6a2)
    0 ms     server: order 1043 created
 1000 ms  x  POST /orders (Idempotency-Key: c1f0e6a2) cancelled
 1500 ms  -> POST /orders (Idempotency-Key: c1f0e6a2)
 1500 ms     server: key seen before, returns order 1043
 2500 ms  x  POST /orders (Idempotency-Key: c1f0e6a2) cancelled
 3000 ms  -> POST /orders (Idempotency-Key: c1f0e6a2)
 3000 ms     server: key seen before, returns order 1043
 4000 ms  x  POST /orders (Idempotency-Key: c1f0e6a2) cancelled
 4000 ms  client: TimeoutError

Üç deneme, üç sipariş: 1043, 1044 ve 1045; istemci yine de hata bildiriyor. Müşteri için olabilecek en kötü sonuç bu: ekranda bir hata ve kartta üç ödeme. x satırları, istemcide isteği iptal etmenin sunucuda hiçbir şeyi geri almadığını hatırlatıyor. İkinci senaryoda her deneme, tıklama başına bir kez üretilen aynı idempotency key'i taşıdı; sunucu key'i tanıdı ve var olan 1043 numaralı siparişi döndürdü. Sunucunun bu key'leri nasıl sakladığı ve kontrol ettiği Çözüm Desenleri serisinin "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" yazısında. İstemci tarafında kural kısa: idempotency key olmadan POST'a retry yok ve key tekrar denemeler boyunca aynı kalmalı.

finalize ile Loading Göstergesi

Bir loading göstergesi her durumda kaybolmalı: başarıdan sonra, hatadan sonra ve istek iptal edildiğinde. Onu next ve error'da kapatmak üçüncü durumu kaçırıyor. finalize, subscription hangi sebeple biterse bitsin çalışıyor:

import { concat, finalize, NEVER, switchMap } from "rxjs";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeHttp } from "./fake-http.ts";

let loading = false;

function load(label: string, status: number) {
  loading = true;
  log(`${label}: loading = true`);
  return fakeHttp(`GET /books (${label})`, 300, status, () => 6).pipe(
    // complete'te, error'da ve unsubscribe'da çalışır
    finalize(() => {
      loading = false;
      log(`${label}: finalize, loading = false`);
    }),
  );
}

runInVirtualTime("success, error, cancel", () => {
  load("success", 200).subscribe({ next: (n) => log(`success: ${n} books`), complete: () => log("success: complete") });
  emitAt([1000, "x"]).subscribe(() => load("error", 503).subscribe({ error: (err: Error) => log(`error: ${err.message}`) }));
  // daha yeni bir istek bunu switchMap üzerinden iptal ediyor
  concat(emitAt([2000, "first"], [2100, "second"]), NEVER)
    .pipe(switchMap((label) => load(label, 200)))
    .subscribe((n) => log(`switchMap: ${n} books, loading = ${loading}`));
});
--- success, error, cancel
    0 ms  success: loading = true
    0 ms  -> GET /books (success)
  300 ms  <- GET /books (success) 200
  300 ms  success: 6 books
  300 ms  success: complete
  300 ms  success: finalize, loading = false
 1000 ms  error: loading = true
 1000 ms  -> GET /books (error)
 1300 ms  <- GET /books (error) 503
 1300 ms  error: HTTP 503
 1300 ms  error: finalize, loading = false
 2000 ms  first: loading = true
 2000 ms  -> GET /books (first)
 2100 ms  first: finalize, loading = false
 2100 ms  second: loading = true
 2100 ms  -> GET /books (second)
 2400 ms  <- GET /books (second) 200
 2400 ms  switchMap: 6 books, loading = true
 2400 ms  second: finalize, loading = false

Flag başarıdan sonra, 503'ten sonra ve switchMap 2100. ms'de "first" isteğini iptal ettiğinde false'a döndü; son durumda ne next ne de error çalıştı. Çıktıda iki ayrıntı görünüyor. finalize, subscriber'ın kendi callback'lerinden sonra çalışıyor ("success: complete"ten, "error: HTTP 503"ten sonra); yani next içinde flag hâlâ true, "switchMap: 6 books, loading = true" satırının gösterdiği gibi. Ayrıca tek bir ortak flag switchMap ile yalnız aynı anda hiçbir zaman birden fazla istek olmadığı için çalışıyor; mergeMap'te ilk biten istek, diğerleri sürerken göstergeyi kapatır. O durumda boolean yerine süren istekleri say.

Angular: HttpErrorResponse, ProblemDetails ve Interceptor

Angular'da başarısız her HttpClient çağrısı bir HttpErrorResponse ile bitiyor. status'u HTTP status'u ya da hiç yanıt gelmediğinde (ağ hatası, CORS (Cross-Origin Resource Sharing; tarayıcının başka bir origin'e yapılan isteğe izin verip vermediğini belirleyen kural) reddi, çalışmayan sunucu) 0; error property'si ise parse edilmiş yanıt gövdesi. ASP.NET Core hataları application/problem+json ile ProblemDetails olarak gönderiyor; sunucu tarafını ASP.NET Core Web API serisinin "ASP.NET Core'da Global Hata Yönetimi: Exception Middleware ve ProblemDetails" yazısı anlatıyor. Bir ASP.NET Core 10 endpoint'inin 409 status'lu TypedResults.Problem için döndürdüğü yanıt aşağıda; snapshot'taki sahte API, POST /api/orders'a bir adet stoku aştığında aynı şekilde, geçersiz bir adette ise ValidationProblem biçiminde bir 400 ile cevap veriyor:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{"type":"https://bookstore.example.com/problems/out-of-stock","title":"Not enough stock","status":409,"detail":"Only 3 copies of Middlemarch are in stock.","traceId":"00-f39f136fc215c755185bd077da552e91-f0fe67a39aa75f85-00"}

bookstore-web, bu tür her hatayı tek bir yerde kullanıcı için bir cümleye çeviriyor:

import { HttpErrorResponse } from '@angular/common/http';

// ASP.NET Core'un application/problem+json ile gönderdiği gövde (RFC 9457)
export interface ProblemDetails {
  type?: string;
  title?: string;
  status?: number;
  detail?: string;
  traceId?: string;
  errors?: Record<string, string[]>;
}

function isProblemDetails(body: unknown): body is ProblemDetails {
  return typeof body === 'object' && body !== null && ('title' in body || 'detail' in body);
}

// HTTP çağrısı neyle başarısız olduysa, kullanıcının okuyabileceği bir mesaj
export function errorMessage(err: unknown): string {
  if (!(err instanceof HttpErrorResponse)) return 'Something went wrong.';
  if (err.status === 0) return 'Cannot reach the Bookstore service. Check your connection.';
  if (isProblemDetails(err.error)) {
    const validation = Object.values(err.error.errors ?? {}).flat();
    return validation.length ? validation.join(' ') : (err.error.detail ?? err.error.title ?? err.message);
  }
  return err.status >= 500 ? 'The Bookstore service is not responding. Please try again.' : err.message;
}

Daha önemli soru, hangi hatayı kimin ele aldığı. İşin içinde iki yer var ve birbirinin işini yapmamalılar. Functional interceptor her isteği görüyor; her yerde aynı olan işler için doğru yer o: idempotent GET'leri tekrar denemek, loglamak ve bütün servisin çalışmadığını kullanıcıya söylemek. Component ise bir hatanın kendi bağlamında ne anlama geldiğini biliyor: sepet sayfasında 409'un "adedi azalt" demek olduğunu ya da kataloğun boş bir liste gösterebileceğini. Interceptor:

import { HttpErrorResponse, HttpResponse, type HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, retry, tap, throwError, timer } from 'rxjs';
import { ServerStatus } from './server-status';

// ağ hataları (status 0) ve 5xx bir sonraki denemede başarılı olabilir; 4xx olmaz
const isRetryable = (err: unknown) =>
  err instanceof HttpErrorResponse && (err.status === 0 || err.status >= 500);

// 500, 1000, 2000 ms tavanları, %50 ile %100 arasında rastgele
const backoff = (retryCount: number) => {
  const ceiling = 500 * 2 ** (retryCount - 1);
  return ceiling / 2 + (Math.random() * ceiling) / 2;
};

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const serverStatus = inject(ServerStatus);
  // yalnız GET'i tekrarlamak güvenli; bir POST ikinci bir sipariş oluşturabilir
  const retries = req.method === 'GET' ? 3 : 0;

  return next(req).pipe(
    retry({
      count: retries,
      delay: (err, retryCount) => {
        if (!isRetryable(err)) return throwError(() => err);
        console.warn(`[http] ${req.method} ${req.url} failed (${err.status}), retry ${retryCount}`);
        return timer(backoff(retryCount));
      },
    }),
    tap((event) => {
      if (event instanceof HttpResponse) serverStatus.report(false);
    }),
    catchError((err: unknown) => {
      // merkezi kısım: kaydet ve global banner'ı göster; hata yine çağırana gider
      if (isRetryable(err)) serverStatus.report(true);
      console.error(`[http] ${req.method} ${req.url} failed:`, err instanceof HttpErrorResponse ? err.status : err);
      return throwError(() => err);
    }),
  );
};

Yalnız GET'leri, yalnız 0 ve 5xx için, öncekiyle aynı backoff fikriyle tekrar deniyor; son başarısızlıkta, bir signal'ı olan küçük bir service olan ServerStatus üzerinden global bir banner'ı açıyor. Sonra hatayı yeniden fırlatıyor, çünkü verinin yerine ne gösterileceğini yalnız çağıran biliyor. Interceptor app.config.ts'te provideHttpClient(withInterceptors([errorInterceptor])) ile kaydediliyor. Katalog sayfasında component'in payı tek satır, boş listeye düşmek:

  protected readonly books = toSignal(
    inject(HttpClient)
      .get<Book[]>(`${apiUrl}/books`)
      // interceptor zaten tekrar denedi ve banner'ı gösterdi; burada sayfa yalnızca kullanılabilir kalıyor
      .pipe(catchError(() => of<Book[]>([]))),
    { initialValue: [] },
  );

Sahte API'yi FAIL=/api/books:2 ile başlattık, yani ilk iki kitap isteği 503 dönüyor, ve kataloğu headless Chromium'da açtık:

 0.3 s GET /api/books 503
 0.3 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 0.3 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 1
 0.3 s GET /api/categories 200
 0.9 s GET /api/books 503
 0.9 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 0.9 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 2
 2.0 s GET /api/books 200
 4.1 s books on screen: 6 | banner: (none)

İki hata, yaklaşık 0,6 ve 1,1 saniye sonra iki tekrar deneme ve katalog altı kitabın hepsiyle açıldı; kullanıcı bir hata değil, kısa bir gecikme gördü. FAIL=/api/books:9 ile servis hiç düzelmedi:

 0.3 s GET /api/books 503
 0.3 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 0.3 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 1
 0.3 s GET /api/categories 200
 1.0 s GET /api/books 503
 1.0 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 1.0 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 2
 1.8 s GET /api/books 503
 1.8 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 1.8 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 3
 3.6 s GET /api/books 503
 3.6 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 3.6 s [console.error] [http] GET http://localhost:5180/api/books failed: 503
 6.1 s books on screen: 0 | banner: The Bookstore service is not responding. Please try again later.

Dört deneme, sonra banner; sayfa boş bir listeyle kullanılabilir kaldı. Component'teki catchError olmadan, bu senaryonun ilk koşusunda Angular konsola ERROR HttpErrorResponse yazdı: toSignal ile oluşturulan bir signal, Observable'ın hatasını her okunduğunda, burada template'te, yeniden fırlatıyor. toSignal'a bu serinin Angular yazısında daha yakından bakacağız.

Siparişi sepet sayfası veriyor. Kendi loading durumunu finalize ile yönetiyor ve hatayı kendisi ele alıyor, çünkü 409'un anlamı bu sayfaya ait:

  protected placeOrder(): void {
    this.placing.set(true);
    this.message.set(null);
    this.#orders
      .placeOrder(this.cart.items())
      // ne olursa olsun buton geri gelir: başarı da hata da
      .pipe(finalize(() => this.placing.set(false)))
      .subscribe({
        next: (order) => this.message.set(`Order ${order.id} placed.`),
        // component seviyesi: 409'un kullanıcı için ne demek olduğunu bu sayfa biliyor
        error: (err: unknown) => this.message.set(errorMessage(err)),
      });
  }

Middlemarch'ı dört kez ekledik (stoku 3), sepete gittik ve "Place order"a tıkladık:

 0.3 s GET /api/books 200
 0.3 s GET /api/categories 200
 0.6 s button while waiting: Placing order... | disabled: true
 0.8 s POST /api/orders 409
 0.8 s [console.error] Failed to load resource: the server responded with a status of 409 (Conflict)
 0.8 s [console.error] [http] POST http://localhost:5180/api/orders failed: 409
 0.9 s message: Only 3 copies of Middlemarch are in stock. | button: Place order | disabled: false
[api] listening on http://localhost:5180
[api] #1 GET /api/books
[api] #2 GET /api/categories
[api] #1 200
[api] #2 200
[api] #3 OPTIONS /api/orders
[api] #3 204 preflight
[api] #4 POST /api/orders
[api] #4 409 problem+json

İstek sürerken buton "Placing order..." diyordu ve devre dışıydı; ikinci bir tıklama ikinci bir POST gönderemezdi. 409 tekrar denenmedi (POST ve zaten 4xx), interceptor onu logladı ve sayfa ProblemDetails gövdesindeki detail'i gösterdi. finalize butonu yeniden etkinleştirdi. Sunucu logundaki OPTIONS isteği tarayıcının CORS preflight isteği, çünkü sayfa ile API farklı port'larda çalışıyor.

Üretimde Dikkat

Yalnız tarayıcı konsoluna ulaşan bir hata ekip için yok hükmünde. Önemli olanları interceptor'dan ya da Angular'ın ErrorHandler'ından bir hata izleme servisine (Sentry, Application Insights ya da benzeri) gönder ve ProblemDetails gövdesindeki traceId'yi ekle: onunla backend ekibi tam o isteği kendi loglarında bulabilir. Kullanıcıya kısa bir referans göstermek ("hata referansı: f39f136f") destek taleplerini de işe yarar hâle getiriyor.

Retry trafiğini izle. Her tekrar deneme gerçek bir istek; bir kesinti sırasında istemci başına üç tekrar, zaten zorlanan bir servise normal yükün dört katına kadar yük demek. Jitter bu yükü dağıtıyor, kısa bir count onu sınırlıyor. Sunucu 503 ya da 429 ile bir Retry-After header'ı gönderiyorsa, kendi backoff'un yerine delay fonksiyonunda ona uy. Ve ölç: isteklerin ne kadarının ancak bir tekrar denemeden sonra başarılı olduğunu gösteren bir dashboard, kararsızlığı kullanıcılar şikâyet etmeden çok önce gösterir.

Son olarak yedek değerlerle dikkatli ol. Hatadan sonra gelen boş bir liste "kitap bulunamadı" ile tamamen aynı görünüyor. Kullanıcılar "arama bozuk" yerine "arama Dune'u bulmuyor" diye bildiriyor ve asıl sorunu bulmak uzuyor. Önemli olduğu yerde, yukarıdaki arama kutusunun yaptığı gibi verinin bir hata yüzünden eksik olduğunu göster.

Sık Düşülen Tuzaklar

  • Uzun yaşayan bir akışın sonuna catchError koymak. Akışın geri kalanını değiştirir; arama kutusu ya da polling kalıcı olarak durur. switchMap'in içinde yakala.
  • Hataları EMPTY ya da boş bir yedek değerle yutup hiçbir şey göstermemek. Kullanıcı boş bir sayfa görür, hatayı kimse görmez.
  • Idempotency key olmadan bir POST'u tekrar denemek. Bir timeout ve ardından gelen retry iki sipariş oluşturabilir.
  • 4xx hatalarını tekrar denemek. 400, 404 ya da 409 her seferinde aynı şekilde başarısız olur; yalnız 0 ve 5xx'i tekrar dene.
  • Hemen ve sınırsız tekrar denemek. Backoff ve jitter ile retry({ count, delay }) kullan.
  • Loading göstergesini next ve error'da kapatmak. İptal edilen bir istek ikisini de çağırmaz; finalize kullan.
  • Sayfanın ne göstereceği dahil her hatayı interceptor'da ele almak. Interceptor her yerde aynı olanı yapar; hatanın anlamı component'e aittir.

Küçük Bir Deneme

★☆☆ 04-retry-backoff.ts'te seed'i 42'den 7'ye çevir ve hangi beklemelerin değişeceğini, hangi sınırlar içinde kalacaklarını tahmin et. Sonra ilk senaryoda count: 2 yap; subscriber ne alır?

★★☆ Sepet sayfasının bir idempotency key göndermesini sağla: tıklama başına bir kez crypto.randomUUID() ile bir key üret, onu Idempotency-Key header'ı olarak gönder ve interceptor bir POST'u yalnız bu header varsa tekrar denesin. Sahte API'yi key'leri hatırlayacak şekilde genişlet ve tekrar denenen bir POST'un yalnız bir sipariş oluşturduğunu sunucu logundan kontrol et.

★★★ Interceptor'a Retry-After desteği ekle: bir 503 ya da 429 yanıtında bu header varsa (saniye cinsinden), backoff yerine o kadar bekle. Sahte API'nin 503 cevaplarıyla Retry-After: 2 göndermesini sağlayarak test et ve aralıkları Playwright logunda ölç.

Bu yazıda bookstore-web, toSignal'ı, async'i ve bir interceptor'ı pek sorgulamadan kullandı. Bir sonraki yazıda Angular 22'deki RxJS'e bir bütün olarak bakacağız: hangi Angular API'lerinin Observable döndürdüğü ve signal karşılıklarının ne olduğu; toSignal, toObservable, rxResource ve outputFromObservable; zoneless bir OnPush uygulamada bir Observable'ın ekranı neden kendiliğinden güncellemediği; üç şekilde yazılmış arama kutusu ve subscription'ları sızıntı olmadan yönetmek.