Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #18

Node.js'te HTTP Client: fetch, undici, AbortSignal.timeout, Keep-Alive, Streaming Body ve Retry

Mert Özen 15 Kas 2026 15 dk 6 görüntülenme
Node.js'te HTTP Client: fetch, undici, AbortSignal.timeout, Keep-Alive, Streaming Body ve Retry

Node.js'te dış servis çağrısı global fetch ile yapılır, ama response.ok kontrolü, AbortSignal.timeout, okunan ya da iptal edilen body ve idempotency'ye dikkat eden bir retry olmadan güvenli değildir. Gerçek çıktılarla 404'te çalışmayan catch, 301 saniye bekleyen istek, okunmayan body yüzünden tükenen connection, 329 MB yerine 131 MB ile stream edilen dosya, jitter'sız retry dalgaları ve MockAgent ile test edilen kargo takip client'ı.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS), undici 8.12.4 ve TypeScript 7.0.2 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) artık kargo firmasının API'sini de çağıracak. İlk deneme şöyle; kargo firması bilmediği bir takip numarası için 404 dönüyor:

import http from "node:http";

// Kargo firması bilmediği bir takip numarası için 404 dönüyor
const carrier = http.createServer((req, res) => {
    res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown tracking number" }));
});
carrier.listen(52180, "127.0.0.1");

try {
    const response = await fetch("http://127.0.0.1:52180/v1/tracking/BK9999TR");
    const tracking = await response.json();
    console.log("tracking:", tracking);
} catch (err) {
    console.log("caught:", (err as Error).message);
} finally {
    carrier.close();
}

Ekrana tracking: mı yazılır, caught: mı?

tracking: { error: 'unknown tracking number' }

catch çalışmadı. fetch yalnız ağ seviyesindeki hatalarda (bağlantı kurulamadı, DNS çözülemedi, istek iptal edildi) reject ediyor; 404 ya da 503 de bir cevaptır ve promise başarıyla tamamlanır. Kod, kargo firmasının hata mesajını takip bilgisi sanıp yoluna devam etti. .NET'te HttpClient.GetAsync da aynı şekilde davranır, ama orada GetFromJsonAsync ya da EnsureSuccessStatusCode() alışkanlığı vardır. fetch'te bu kontrolü her seferinde kendin yazarsın.

Bu yazıda Node'un yerleşik HTTP client'ını, yani global fetch'i ve altındaki undici'yi, bir dış servisi güvenle çağıracak hâle getiriyoruz: timeout ve iptal, connection pool, body'yi stream olarak okumak, retry, exponential backoff, jitter ve idempotency. Sonunda Bookstore API'sine kargo takibi endpoint'i ekliyor ve client'ı gerçek ağa çıkmadan test ediyoruz.

fetch, undici ve Timeout

Node'daki global fetch 18'den beri bayraksız, 21'den beri stable. Tarayıcıdaki API ile aynı, ama altında Node ekibinin sıfırdan yazdığı HTTP/1.1 client'ı undici var; hangi sürümün geldiğini process.versions.undici söylüyor (Node 26.13.1'de 8.12.4). 10'da sunucu tarafını gördüğümüz node:http'nin client tarafı (http.request) hâlâ duruyor, ama yeni kodda fetch kullanmak hem daha kısa hem de tarayıcıyla aynı API.

İlk eksik timeout. fetch'in kendi timeout seçeneği yok; undici'nin varsayılanları ise şaşırtıcı derecede uzun. Bağlantıyı kabul edip hiç cevap vermeyen bir sunucuya istek atalım:

// Bağlantıyı kabul eden ama hiç cevap vermeyen bir kargo firması
const carrier = http.createServer(() => {});
carrier.listen(52187, "127.0.0.1");

const started = performance.now();
try {
    await fetch("http://127.0.0.1:52187/v1/tracking/BK1001TR");
} catch (err) {
    const cause = (err as Error).cause as { name: string; code: string };
    console.log(`${(err as Error).message} after ${((performance.now() - started) / 1000).toFixed(1)} s: ${cause.name} ${cause.code}`);
} finally {
    carrier.closeAllConnections();
fetch failed after 301.4 s: HeadersTimeoutError UND_ERR_HEADERS_TIMEOUT

Beş dakika. undici'nin dokümanına göre header'ları bekleme süresi (headersTimeout) ve body parçaları arasındaki bekleme süresi (bodyTimeout) varsayılan olarak 300 saniye. Kargo firması takıldığında, kitap siparişi veren kullanıcının request'i de beş dakika bekler; o sırada gelen her yeni istek bir connection ve bellek tutar. Bu yüzden her dış çağrıya bir süre ver. Tarayıcıdan bildiğin AbortSignal.timeout() burada da çalışıyor; 07'de gördüğümüz AbortSignal.any() ile de kendi süreni, API'ni çağıran client'ın bağlantıyı kapatmasıyla birleştirebiliyorsun:

async function attempt(label: string, signal: AbortSignal): Promise<void> {
    const started = performance.now();
    try {
        await fetch(url, { signal });
        console.log(`${label}: answered`);
    } catch (err) {
        const e = err as Error;
        console.log(`${label}: ${e.name} after ${Math.round(performance.now() - started)} ms (${e.message})`);
    }
}

// 1) Kendi süre sınırımız: deneme başına 500 ms
await attempt("timeout", AbortSignal.timeout(500));

// 2) API'mizi çağıran client 100 ms sonra ayrıldı; süre sınırı hâlâ 500 ms
const requestAborted = new AbortController();
setTimeout(() => requestAborted.abort(), 100);
await attempt("any", AbortSignal.any([requestAborted.signal, AbortSignal.timeout(500)]));
timeout: TimeoutError after 505 ms (The operation was aborted due to timeout)
any: AbortError after 102 ms (This operation was aborted)

İki farklı hata adı var ve ayrımı önemli. TimeoutError senin koyduğun sürenin dolduğunu söylüyor; bu durumda işlemi tekrar denemek mantıklı olabilir. AbortError ise çağıranın vazgeçtiğini söylüyor; Bookstore'da bu, kullanıcının sayfayı kapatması demek ve tekrar denemenin anlamı yok. C#'taki karşılığı, CancellationTokenSource.CreateLinkedTokenSource ile HttpContext.RequestAborted'ı bir timeout token'ıyla birleştirmek.

Connection'lar, Keep-Alive ve undici Agent

Her fetch yeni bir TCP bağlantısı açmıyor. undici her origin (şema + host + port) için bir connection pool tutuyor ve keep-alive ile bağlantıları yeniden kullanıyor. Pool'un ayarlarını değiştirmek istediğinde undici paketini kurup kendi Agent'ını oluşturuyor ve fetch'e dispatcher seçeneğiyle veriyorsun. Bu seçenek standart fetch'te yok, Node'a özgü bir ek; TypeScript'in RequestInit tipinde de olmadığı için kodda bir cast görüyorsun. npm'den kurduğun undici'nin major sürümü, Node'un içindekiyle aynı olsun (burada ikisi de 8.12.4).

// Global dispatcher üzerinden 20 sıralı çağrı: kaç TCP bağlantısı?
for (let i = 0; i < 20; i++) {
    const response = await fetch(url);
    await response.arrayBuffer();
}
console.log(`sequential, body read: 20 requests, ${sockets} connection(s)`);

// 20 paralel çağrı, bu origin'e en fazla 5 bağlantı
sockets = 0;
const limited = new Agent({ connections: 5 });
await Promise.all(
    Array.from({ length: 20 }, async () => {
        const response = await fetch(url, { dispatcher: limited } as RequestInit);
        await response.arrayBuffer();
    }),
);
console.log(`parallel, connections: 5: 20 requests, ${sockets} connection(s)`);

// Tek bağlantı ve ilk cevabın body'si hiç okunmuyor
const single = new Agent({ connections: 1 });
const first = await fetch(url, { dispatcher: single } as RequestInit);
console.log("first:", first.status, "(body not read)");
const started = performance.now();
try {
    const second = await fetch(url, { dispatcher: single, signal: AbortSignal.timeout(1_000) } as RequestInit);
    console.log("second:", second.status);
} catch (err) {
    console.log(`second: ${(err as Error).name} after ${Math.round(performance.now() - started)} ms`);
}

// İlk body'yi iptal etmek bağlantıyı geri veriyor
await first.body?.cancel();
const third = await fetch(url, { dispatcher: single, signal: AbortSignal.timeout(1_000) } as RequestInit);
console.log("after cancel():", third.status);
await third.body?.cancel();

await Promise.all([limited.close(), single.destroy()]);
sequential, body read: 20 requests, 2 connection(s)
parallel, connections: 5: 20 requests, 5 connection(s)
first: 200 (body not read)
second: TimeoutError after 1002 ms
after cancel(): 200

Sıralı 20 istek iki bağlantı üzerinden gitti (undici bu çalıştırmada iki bağlantıyı sırayla kullandı); paralel 20 istek connections: 5 sınırında beş bağlantıya yayıldı. Asıl dikkat edilecek kısım sonu: body'si okunmayan bir cevap, bağlantıyı meşgul tutuyor. Tek bağlantılı pool'da ikinci istek bir saniye boyunca sıra bekleyip timeout'a düştü. undici'nin README'si bunu açıkça yazıyor: tarayıcıda okunmayan body'yi garbage collector bir süre sonra temizler, Node'da bu ne zaman olacağı belli olmadığı için bağlantılar tükenebilir. Kural basit: body'yi ya oku (json(), text(), arrayBuffer()) ya da response.body?.cancel() ile bırak. Hata cevaplarında body'yi okumadan throw etmek, bu tuzağın en yaygın hâli.

.NET tarafında bu işi SocketsHttpHandler'ın connection pool'u ve onu yöneten IHttpClientFactory yapıyor (External Service Integration ve Resilience serisinin 02. yazısı). Node'daki karşılığı, process başına bir kez oluşturulan ve her çağrıda tekrar kullanılan bir Agent. Bir de proxy notu: kurumsal ağlarda dışarı çıkmak için proxy gerekiyorsa undici'nin ProxyAgent'ını kullanabilir ya da Node 24'ten beri NODE_USE_ENV_PROXY=1 ile HTTP_PROXY, HTTPS_PROXY ve NO_PROXY değişkenlerini okutabilirsin; bu ayar dokümanda hâlâ "Active Development" (1.1) seviyesinde.

Body'yi Stream Olarak Okumak

Bookstore'un tedarikçisi her gece yaklaşık 50 MB'lık bir fiyat dosyası yayınlıyor. await response.text() bütün dosyayı tek bir string olarak belleğe alır. response.body ise bir web ReadableStream; Readable.fromWeb onu 09'daki Node stream'ine çeviriyor ve pipeline ile backpressure'ı koruyarak dosyaya yazabiliyorsun:

const response = await fetch("http://127.0.0.1:52183/feeds/prices.csv", { signal: AbortSignal.timeout(60_000) });
if (!response.ok || !response.body) {
    throw new Error(`feed answered ${response.status}`);
}
if (mode === "text") {
    // Bütün body bellekte tek bir string olarak
    await writeFile(target, await response.text());
} else {
    // fetch bir web ReadableStream veriyor; Readable.fromWeb onu pipeline için Node stream'ine çeviriyor (09. yazı)
    await pipeline(Readable.fromWeb(response.body as ReadableStream<Uint8Array>), createWriteStream(target));
}
text: 50.0 MB in 691 ms, peak RSS 329 MB
stream: 50.0 MB in 658 ms, peak RSS 131 MB

Süre aynı, en yüksek RSS (process'in fiziksel bellekte kapladığı alan) 329 MB'tan 131 MB'a indi; boş bir Node process'i bu makinede 49 MB kullanıyor. Dosya büyüdükçe text()'in bellek kullanımı da büyür, stream'inki sabit kalır. Burada da bir timeout var: AbortSignal.timeout(60_000) yalnız header'ları değil, body'nin okunmasını da kapsıyor; sinyal tetiklenirse stream hata verir ve pipeline dosyayı kapatır.

Retry, Exponential Backoff ve Jitter

Dış servisler arada bir 503 döner, bir bağlantı yarıda kopar. Bu geçici hataları bir iki kez tekrar denemek çoğu zaman yeter. Retry mantığını küçük bir fonksiyonda topladık:

// "Full jitter" ile exponential backoff: 0 ile base * 2^(attempt-1) arasında rastgele bekleme, üst sınırlı
export function backoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number, random: () => number = Math.random): number {
    const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
    return Math.round(random() * ceiling);
}

export async function withRetry<T>(operation: (attempt: number) => Promise<T>, options: RetryOptions): Promise<T> {
    for (let attempt = 1; ; attempt++) {
        options.signal?.throwIfAborted();
        try {
            return await operation(attempt);
        } catch (err) {
            if (attempt >= options.attempts || options.signal?.aborted || !options.isRetryable(err)) {
                throw err;
            }
            const hinted = (err as RetryAfterHint).retryAfterMs;
            const delayMs = Math.min(options.maxDelayMs, hinted ?? backoffDelay(attempt, options.baseDelayMs, options.maxDelayMs, options.random));
            options.onRetry?.({ attempt, delayMs, err });
            try {
                await delay(delayMs, undefined, { signal: options.signal });
            } catch (waitError) {
                // timers/promises kendi AbortError'ını fırlatıyor; onun yerine çağıranın sebebini fırlat
                options.signal?.throwIfAborted();
                throw waitError;
            }
        }
    }
}

Üç karar var. Hangi hata tekrar denenir: isRetryable çağırana bırakıldı. Ne kadar beklenir: sunucu Retry-After header'ı gönderdiyse o, yoksa her denemede ikiye katlanan bir üst sınır (exponential backoff). Ne zaman durulur: deneme sayısı dolunca ya da çağıranın sinyali iptal edilince. Bekleme sırasında iptal gelirse node:timers/promises kendi AbortError'ını fırlatıyor; testte yakaladığım bu farkı gizlemek için çağıranın verdiği sebebi (signal.reason) fırlatıyoruz.

Jitter neden gerekli? Kargo firması 1,5 saniye boyunca 503 dönüyor ve 100 istek aynı anda hata alıyor:

no jitter  : 500 requests, done at 3051 ms
  per 250 ms: 200 0 100 0 0 100 0 0 0 0 0 0 100
full jitter: 481 requests, done at 5107 ms
  per 250 ms: 158 88 41 49 26 19 23 15 13 14 8 3 1 10 2 4 3 1 2 0 1

Jitter olmadan 100 istemci aynı anlarda tekrar geliyor: 250 ms'lik dilimlerde 100'lük dalgalar. Toparlanmaya çalışan servis her dalgada aynı yükü yiyor. "Full jitter" (beklemeyi 0 ile üst sınır arasında rastgele seçmek) aynı istekleri zamana yayıyor. Bedeli de çıktıda görünüyor: son istek daha geç tamamlandı. Polly'nin ve Microsoft.Extensions.Http.Resilience'ın standart pipeline'ı jitter'ı varsayılan olarak açık getiriyor; External Service Integration ve Resilience serisinin 03. yazısında bu ayarların .NET tarafını gördük. undici'nin kendi RetryAgent'ı da var (varsayılan olarak en fazla 5 retry, 500 ms'den başlayıp ikiye katlanan bekleme ve Retry-After desteği); bu yazıda mekanizmayı görmek için kendimiz yazdık.

Idempotency: POST'u Ne Zaman Tekrar Deneyebilirsin?

GET, PUT ve DELETE, HTTP standardına (RFC 9110) göre idempotent: aynı isteği iki kez göndermek, bir kez göndermekle aynı sonucu verir. POST öyle değil. Timeout, isteğin karşıya ulaşmadığı anlamına gelmez; belki kargo firması gönderiyi oluşturdu ama cevabı geç kaldı. Bu durumda tekrar denemek ikinci bir gönderi oluşturur:

naive retry, no key:
  carrier: created BK2001TR for order 1001 (key none)
  carrier: created BK2002TR for order 1001 (key none)
  client got BK2002TR
CarrierClient, one Idempotency-Key per operation:
  carrier: created BK2003TR for order 1002 (key c720c577)
  client: attempt 1 failed (carrier did not answer within 2000 ms), retrying in 131 ms
  client got BK2003TR
shipments created at the carrier: 3

İlk denemede kargo firması gönderiyi oluşturdu ama 2,5 saniyede cevap verdi; client 2 saniyede vazgeçti ve tekrar denedi. Key olmadan bir sipariş için iki gönderi oluştu. CarrierClient ise her mantıksal işlem için bir Idempotency-Key (rastgele bir randomUUID()) üretiyor ve aynı key'i bütün denemelerde gönderiyor; kargo firması key'i tanıyınca ilk gönderiyi tekrar dönüyor. Kritik ayrıntı, key'in retry döngüsünün dışında üretilmesi; döngünün içinde üretilirse her deneme yeni bir işlem olur. Bu yalnız karşı taraf Idempotency-Key'i destekliyorsa işe yarar; desteklemiyorsa POST'u tekrar deneme.

Bookstore: Kargo Takibi

Bütün parçaları src/integrations/carrierClient.ts'te birleştirdik. Her deneme kendi timeout'unu alıyor ve çağıranın sinyaline bağlanıyor; cevap ne olursa olsun body ya okunuyor ya da iptal ediliyor:

    // Tek deneme: kendi timeout'u var, çağıranın sinyaline bağlı; yalnız 2xx ve 404 geri döner
    async #send(url: URL, init: RequestInit, signal?: AbortSignal): Promise<Response> {
        const timeout = AbortSignal.timeout(this.#options.timeoutMs);
        const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
        let response: Response;
        try {
            response = await fetch(url, {
                ...init,
                headers: { accept: "application/json", "x-api-key": this.#options.apiKey, ...init.headers },
                signal: combined,
                // fetch'e Node.js'in eklediği seçenek: hangi undici dispatcher'ının (connection pool) kullanılacağı
                ...(this.#options.dispatcher ? { dispatcher: this.#options.dispatcher } : {}),
            } as RequestInit);
        } catch (err) {
            if (signal?.aborted) {
                // Çağıran vazgeçti (client bağlantıyı kapattı): kargo firmasının hatası değil, tekrar deneme
                throw err;
            }
            const timedOut = timeout.aborted;
            throw new CarrierUnavailableError(timedOut ? `carrier did not answer within ${this.#options.timeoutMs} ms` : "carrier unreachable", {
                timedOut,
                cause: err,
            });
        }
        if (response.ok || response.status === 404) {
            return response;
        }
        // Her body'yi oku (ya da iptal et), yoksa bağlantı garbage collection'a kadar meşgul kalır
        await response.body?.cancel();
        if (RETRYABLE_STATUS.has(response.status)) {
            throw new CarrierUnavailableError(`carrier answered ${response.status}`, {
                status: response.status,
                retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
            });
        }
        throw new Error(`carrier rejected the request with ${response.status}`);
    }

Ağ hatası ve timeout CarrierUnavailableError'a dönüşüyor ve tekrar deneniyor. Çağıran iptal ettiyse hata olduğu gibi yukarı çıkıyor. 404 ve 2xx dışındaki 4xx cevaplar tekrar denenmiyor, çünkü aynı istek aynı cevabı alır. getTracking 404'ü TrackingNotFoundError'a çeviriyor. API'de bu client'ı yalnız mağaza çalışanlarının (StoreStaff) kullandığı yeni bir endpoint çağırıyor:


// GET /api/shipments/:trackingNumber: back office çalışanları bir gönderiyi kargo firmasında sorgular
export function shipmentsRouter(carrier: TrackingLookup): Router {
    return Router().get("/:trackingNumber", async (req, res) => {
        const { trackingNumber } = req.params;
        if (!TRACKING_NUMBER.test(trackingNumber)) {
            throw new HttpError(400, "tracking number must be 6-20 upper-case letters or digits");
        }
        // Request'in sinyali: client ayrılırsa kargo çağrısı ve retry'ları da durur
        res.json(await carrier.getTracking(trackingNumber, currentRequestContext()?.signal));
    });
}

server.ts'te 13'teki SQL pool'u gibi bir Agent oluşturup client'a veriyoruz ve 14'teki graceful shutdown'a, sunucu kapandıktan sonra carrierAgent.close()'u çağırmasını söylüyoruz. Error handler TrackingNotFoundError'ı 404'e, CarrierUnavailableError'ı timeout ise 504'e, değilse 502'ye çeviriyor. Geliştirme için scripts/dev-carrier.ts adında sahte bir kargo API'si ekledik; CARRIER_FAIL_EVERY=3 ile her üç istekten yalnız birine cevap veriyor. API'yi bellekteki repository'lerle başlatan labs/18/serve.ts ile denedik:

curl -s -H "authorization: Bearer lab-staff" http://127.0.0.1:52186/api/shipments/BK1001TR
{"level":"warn","time":"2026-11-15T07:23:08.307Z","service":"bookstore-api","requestId":"10661d5b-7565-48c1-b8e9-639d5397e14e","attempt":1,"delayMs":1000,"reason":"carrier answered 503","msg":"retrying carrier call"}
{"level":"warn","time":"2026-11-15T07:23:09.309Z","service":"bookstore-api","requestId":"10661d5b-7565-48c1-b8e9-639d5397e14e","attempt":2,"delayMs":1000,"reason":"carrier answered 503","msg":"retrying carrier call"}
{"trackingNumber":"BK1001TR","status":"in_transit","events":[{"at":"2026-11-14T09:12:00Z","status":"label_created","location":"Istanbul"},{"at":"2026-11-14T18:40:00Z","status":"in_transit","location":"Istanbul hub"}]}

İki 503'ten sonra üçüncü deneme başarılı oldu. Retry-After: 1 geldiği için bekleme 1000 ms; log satırlarındaki requestId 07'deki AsyncLocalStorage'dan geliyor. Sonra sahte kargo API'sini durdurup aynı isteği tekrarladık (log satırı kısaltıldı):

{"level":"warn","time":"2026-11-15T07:23:12.852Z","service":"bookstore-api","requestId":"fe7c7231-b954-4f1b-b1ec-64fd97d6108e","attempt":1,"delayMs":187,"reason":"carrier unreachable","msg":"retrying carrier call"}
{"level":"warn","time":"2026-11-15T07:23:13.042Z","service":"bookstore-api","requestId":"fe7c7231-b954-4f1b-b1ec-64fd97d6108e","attempt":2,"delayMs":330,"reason":"carrier unreachable","msg":"retrying carrier call"}
{"level":"warn","requestId":"fe7c7231-b954-4f1b-b1ec-64fd97d6108e","err":{"type":"CarrierUnavailableError","message":"carrier unreachable: fetch failed: connect ECONNREFUSED 127.0.0.1:52188","stack":"CarrierUnavailableError: carrier unreachable ...","timedOut":false,"name":"CarrierUnavailableError"},"msg":"carrier call failed"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.3","title":"Bad Gateway","status":502,"detail":"the carrier service is not available, try again later","instance":"/api/shipments/BK1001TR","traceId":"fe7c7231-b954-4f1b-b1ec-64fd97d6108e"}
502 0.531372

Bu sefer hata ECONNREFUSED, Retry-After yok; beklemeler jitter'lı (187 ve 330 ms) ve client 531 ms'de 502 aldı. Bağlantı hatasının ayrıntısı yalnız log'da; client'a giden Problem Details'te iç adres yok (15).

Test: undici MockAgent

16'da route'ları sahte bir repository ile test etmiştik. HTTP client için de aynısı geçerli: route testinde TrackingLookup interface'ini sağlayan sahte bir object yeterli. Client'ın kendisini test etmek için ise undici'nin MockAgent'ı var; dispatcher olarak verdiğinde istek ağa çıkmadan cevaplanıyor. .NET'teki karşılığı, HttpMessageHandler'ı sahtesiyle değiştirmek ya da WireMock.Net:

const tracking = { trackingNumber: "BK1001TR", status: "in_transit", events: [] };

describe("CarrierClient", () => {
    let agent: MockAgent;
    let retries: number[];
    let client: CarrierClient;

    beforeEach(() => {
        // Gerçek ağ yok: hiçbir interceptor'la eşleşmeyen istek makineden çıkmak yerine hata verir
        agent = new MockAgent();
        agent.disableNetConnect();
        retries = [];
        client = new CarrierClient({
            baseUrl: BASE,
            apiKey: "fake-carrier-key-for-tests",
            timeoutMs: 200,
            attempts: 3,
            dispatcher: agent,
            onRetry: ({ delayMs }) => retries.push(delayMs),
        });
    });

    afterEach(async () => {
        agent.assertNoPendingInterceptors();
        await agent.close();
    });

    it("retries a 503 and honours Retry-After", async () => {
        const carrier = agent.get(BASE);
        carrier.intercept({ path: "/v1/tracking/BK1001TR" }).reply(503, "", { headers: { "retry-after": "0" } });
        carrier.intercept({ path: "/v1/tracking/BK1001TR", headers: { "x-api-key": "fake-carrier-key-for-tests" } }).reply(200, tracking);

        assert.deepEqual(await client.getTracking("BK1001TR"), tracking);
        assert.deepEqual(retries, [0]);
    });

    it("does not retry a 404", async () => {
        agent.get(BASE).intercept({ path: "/v1/tracking/BK0000TR" }).reply(404, { error: "unknown" });

        await assert.rejects(client.getTracking("BK0000TR"), TrackingNotFoundError);
        assert.deepEqual(retries, []);
    });

    it("gives up after three slow answers", async () => {
        agent.get(BASE).intercept({ path: "/v1/tracking/BK1001TR" }).reply(200, tracking).delay(500).times(3);

        await assert.rejects(client.getTracking("BK1001TR"), (err: unknown) => err instanceof CarrierUnavailableError && err.timedOut);
        assert.equal(retries.length, 2);
    });
node --test tests/unit/carrierClient.test.ts tests/api/shipments.api.test.ts
✔ returns the carrier's tracking for store staff (8.785583ms)
✔ is only for store staff (1.549917ms)
✔ maps the carrier's 404 and its timeouts (1.998916ms)
▶ CarrierClient
  ✔ retries a 503 and honours Retry-After (10.014583ms)
  ✔ does not retry a 404 (0.723667ms)
  ✔ gives up after three slow answers (1027.132542ms)
  ✔ stops retrying when the caller aborts (54.333625ms)
✔ CarrierClient (1093.033916ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0

disableNetConnect(), eşleşmeyen bir isteğin gerçek ağa çıkmasını engelliyor; assertNoPendingInterceptors() tanımladığın her cevabın gerçekten kullanıldığını doğruluyor. Yavaş cevap testi gerçek zamanla çalıştığı için yaklaşık bir saniye sürüyor; bu yüzden testteki süreleri küçük tuttuk. msw paketi de aynı işi daha yüksek seviyeden yapıyor; tarayıcı ve Node testlerini aynı tanımlarla çalıştırmak istiyorsan ona bak.

Üretimde Dikkat

Toplam süreyi düşün. Üç deneme, deneme başına 2 saniye ve aradaki beklemelerle bir request 7 saniyeye yaklaşabilir. API'nin kendi requestTimeout'u (11'de 30 saniye yaptık) ve önündeki load balancer'ın süresi bundan uzun olmalı; yoksa client vazgeçtikten sonra hâlâ tekrar deniyorsun. Çağıranın sinyalini her zaman aktar.

Ölç. Her denemeyi, süresini ve sonucunu log'la ya da metric olarak yaz; kargo firmasının ne kadar sık 503 döndüğünü ancak böyle görürsün. undici, node:diagnostics_channel üzerinden istek başlangıç ve bitiş event'leri yayınlıyor; OpenTelemetry'nin undici instrumentation'ı bunları kullanıyor (Observability serisi).

Dış servis uzun süre çöktüyse her request'te üç kez denemek, ona daha çok yük bindirir. Bu noktada circuit breaker gerekir. Node'da yerleşik bir karşılığı yok; opossum gibi paketler var, kavramı ve .NET'teki uygulamasını External Service Integration ve Resilience serisinin 04. yazısında anlattık.

Sık Düşülen Tuzaklar

response.ok'u kontrol etmemek. fetch 4xx ve 5xx'te reject etmez; hata mesajı veri olarak işlenir.

Timeout vermemek. Varsayılanlar 300 saniye; takılan bir dış servis bütün API'yi bekletir.

Body'yi okumadan bırakmak. Hata cevaplarında bile cancel() çağır; yoksa connection pool tükenir.

POST'u key'siz tekrar denemek. Timeout'tan sonra karşı tarafın işi yapıp yapmadığını bilemezsin.

Her çağrıda yeni Agent oluşturmak. Keep-alive'ın bütün faydası kaybolur; new HttpClient() hatasının Node'daki karşılığı.

Küçük Bir Deneme

★☆☆ labs/18/guess.ts'i response.ok kontrolüyle düzelt: 404'te status ve body'deki hata mesajını içeren bir hata fırlat.

★★☆ labs/18/herd.ts'e üçüncü bir strateji ekle: beklemenin yarısı sabit, yarısı rastgele ("equal jitter"). Dalgaların dağılımını ve bitiş süresini full jitter ile karşılaştır.

★★★ CarrierClient'a basit bir circuit breaker ekle: art arda 5 CarrierUnavailableError'dan sonra 30 saniye boyunca kargo firmasını hiç çağırmadan hata dön, sonra tek bir deneme isteğine izin ver. MockAgent ile test et.

Sıradaki yazıda HTTP'den çıkıp terminale geçiyoruz: util.parseArgs, exit code'lar, readline ve child_process ile Bookstore için bir CLI aracı yazacağız.