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
catchErrorkoymak. Akışın geri kalanını değiştirir; arama kutusu ya da polling kalıcı olarak durur.switchMap'in içinde yakala. - Hataları
EMPTYya 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
nextveerror'da kapatmak. İptal edilen bir istek ikisini de çağırmaz;finalizekullan. - 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.