Node.js'te Asenkron Pattern'ler: Callback'ten Promise'e, EventEmitter, AbortSignal ve AsyncLocalStorage
Node.js'te asenkron kodun temel parçaları promise API'leri, senkron çalışan EventEmitter, iptal için AbortSignal ve request bağlamını await'ler boyunca taşıyan AsyncLocalStorage'dır. Gerçek çıktılarla emit'in listener'ları senkron çağırması, dinlenmeyen 'error' ile çöken process, captureRejections, AbortSignal.timeout ve any(), global değişkenle karışan request id'leri ve Bookstore'da orderPlaced event'i yayan OrderService.
Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS) ve TypeScript 7.0.2 ile çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) bir sipariş oluşunca orderPlaced adlı bir event yayınlıyoruz. İki listener var: biri onay e-postası gönderiyor, diğeri async ve satış panosunu güncelliyor.
import { EventEmitter } from "node:events";
const orders = new EventEmitter();
orders.on("orderPlaced", (id: string) => console.log(`send confirmation e-mail for ${id}`));
orders.on("orderPlaced", async (id: string) => {
await Promise.resolve();
console.log(`update sales dashboard for ${id}`);
});
console.log("before emit");
const handled = orders.emit("orderPlaced", "ORD-1001");
console.log(`after emit, handled = ${handled}`);
"Event yayınlamak" kulağa bir message bus gibi geliyor: emit mesajı bırakır, listener'lar sonra bir zaman çalışır. Dört satır hangi sırayla yazılır?
node labs/07/guess.ts
before emit
send confirmation e-mail for ORD-1001
after emit, handled = true
update sales dashboard for ORD-1001
İlk listener emit dönmeden çalıştı. EventEmitter listener'ları kayıt sırasıyla ve senkron çağırır; emit yalnızca listener olup olmadığını döndürür. İkinci listener da hemen çağrıldı, ama ilk await'te durdu ve kalanı microtask olarak sonra çalıştı. Ortada bir queue yok, araya bir thread girmiyor; emit düpedüz bir fonksiyon çağrısı.
Bu yazıda Node'un asenkron kodu bir arada tutan pattern'lerini görüyoruz: error-first callback'ten promise'e geçiş, EventEmitter, AbortSignal ile iptal ve request başına bağlam taşıyan AsyncLocalStorage. Promise ve async/await'in kendisi JavaScript Orta serisinde anlatıldı; burada Node'a özgü taraf var. Sonunda Bookstore API'sine OrderService'i ve her log satırına otomatik eklenen bir correlation id ekliyoruz.
Error-First Callback'ten Promise'e
Node'un eski API'leri tek bir konvansiyona uyar: son argüman bir callback'tir, callback'in ilk parametresi hatadır. Hata yoksa null gelir, sonuç ikinci parametrededir:
import { readFile } from "node:fs";
import { promisify } from "node:util";
// Error-first callback: ilk argüman hata, gerisi sonuç
readFile("missing-catalog.csv", "utf8", (err, text) => {
if (err) {
console.log(`callback: ${err.code} (${err.syscall})`);
return;
}
console.log(text.length);
});
// Yalnız callback bilen eski bir kargo istemcisi
function quoteShipping(city: string, weightGrams: number, callback: (err: Error | null, priceTry?: number) => void): void {
setTimeout(() => {
if (weightGrams <= 0) {
callback(new Error(`invalid weight: ${weightGrams}`));
return;
}
callback(null, city === "Istanbul" ? 49.9 : 79.9);
}, 10);
}
const quoteShippingAsync = promisify(quoteShipping);
console.log(`promisified: ${await quoteShippingAsync("Ankara", 450)} TRY`);
try {
await quoteShippingAsync("Izmir", 0);
} catch (err) {
console.log(`promisified rejection: ${(err as Error).message}`);
}
callback: ENOENT (open)
promisified: 79.9 TRY
promisified rejection: invalid weight: 0
util.promisify, bu konvansiyona uyan her fonksiyonu promise döndüren bir fonksiyona çevirir: callback'e hata gelirse promise reject olur, gelmezse ikinci argümanla resolve olur. Bu, .NET'te eski APM (BeginRead/EndRead) API'lerini Task.Factory.FromAsync ile sarmaya benzer. Kendi kodunda bunu nadiren yazarsın, çünkü Node'un kendi module'lerinin promise sürümleri zaten var: node:fs/promises, node:dns/promises, node:stream/promises, node:readline/promises ve 06'da kullandığımız node:timers/promises. Yeni kodda callback sürümünü değil, bunları kullan; promisify'ı callback'ten başka bir şey bilmeyen eski bir paket için sakla.
Callback'in en büyük sorunu hata akışı: callback'in içinde fırlatılan bir exception'ı dışarıdaki try/catch yakalayamaz, çünkü callback çalıştığında o try çoktan bitmiştir. Promise'e geçince hata, await edildiği yerde normal bir exception olarak yakalanır.
EventEmitter: Senkron Yayın, 'error' Event'i ve Async Listener'lar
EventEmitter, Node'un yarısının üzerine kurulduğu class: HTTP server, socket'ler, stream'ler, child process'ler hepsi bir emitter. API'si küçük: on listener ekler, once bir kez çalışacak listener ekler, off çıkarır, emit yayınlar. C#'taki event ve delegate'lerle aynı fikir; farkı, event adlarının string olması ve error adlı event'in özel davranışı.
import { EventEmitter } from "node:events";
const payments = new EventEmitter();
payments.on("captured", (orderId: string) => console.log(`captured ${orderId}`));
payments.emit("captured", "ORD-1001");
// 'error' listener'ı yok: emitter hatanın kendisini fırlatır
payments.emit("error", new Error("card declined for ORD-1002"));
console.log("this line never runs");
captured ORD-1001
file:///home/user/bookstore-api/labs/07/emitter-error.ts:8
payments.emit("error", new Error("card declined for ORD-1002"));
^
Error: card declined for ORD-1002
at file:///home/user/bookstore-api/labs/07/emitter-error.ts:8:24
...
Emitted 'error' event at:
at file:///home/user/bookstore-api/labs/07/emitter-error.ts:8:10
...
Node.js v26.13.1
Dinlenmeyen bir error event'i process'i exit code 1 ile kapatır. Bir socket'in, bir stream'in ya da kendi emitter'ının error'ını dinlemeyi unutmak, production'da bir bağlantı hatasının bütün API'yi düşürmesi demek. Kural basit: hata üretebilecek her emitter'a bir error listener'ı ekle.
Async listener'ın hatası nereye gider?
Önce Tahmin Et'te gördük: emit async bir listener'ın döndürdüğü promise'i beklemez, sonucunu da kontrol etmez. Listener reject olursa ne olur?
import { EventEmitter } from "node:events";
// Hata veren async bir listener: rejection'ı kim yakalar?
const orders = new EventEmitter({ captureRejections: process.argv[2] === "capture" });
orders.on("orderPlaced", async (id: string) => {
await Promise.resolve();
throw new Error(`loyalty service unavailable for ${id}`);
});
orders.on("error", (err: Error) => console.log(`'error' listener: ${err.message}`));
orders.emit("orderPlaced", "ORD-1001");
setTimeout(() => console.log("process is still alive"), 10);
node labs/07/async-listener.ts; echo "exit=$?"
node labs/07/async-listener.ts capture; echo "exit=$?"
file:///home/user/bookstore-api/labs/07/async-listener.ts:7
throw new Error(`loyalty service unavailable for ${id}`);
^
Error: loyalty service unavailable for ORD-1001
at EventEmitter.<anonymous> (file:///home/user/bookstore-api/labs/07/async-listener.ts:7:11)
Node.js v26.13.1
exit=1
'error' listener: loyalty service unavailable for ORD-1001
process is still alive
exit=0
Varsayılan durumda rejection kimse tarafından yakalanmıyor, unhandled rejection oluyor ve process kapanıyor; error listener'ının olması işe yaramıyor. captureRejections: true ile emitter, async listener'ın promise'ine kendisi bir catch bağlıyor ve hatayı error event'ine yönlendiriyor. Async listener kullanan her emitter'da bu ayarı aç. Node dokümanı bir şeyi daha söylüyor: error listener'ının kendisi async olmamalı, çünkü onun rejection'ı tekrar yakalanmıyor.
events.once ve events.on: Event'leri await ile beklemek
import { EventEmitter, once, on } from "node:events";
import { setTimeout as delay } from "node:timers/promises";
const shipments = new EventEmitter();
// events.once: tek bir event'i promise olarak bekle
setTimeout(() => shipments.emit("labelPrinted", "SHP-77", "standard"), 20);
const [shipmentId, carrier] = await once(shipments, "labelPrinted");
console.log(`once -> ${shipmentId} via ${carrier}`);
// events.on: event'leri for await ile tüket; 'closed' döngüyü bitirir
(async () => {
for (const status of ["InTransit", "OutForDelivery", "Delivered"]) {
await delay(10);
shipments.emit("status", "SHP-77", status);
}
shipments.emit("closed");
})();
for await (const [id, status] of on(shipments, "status", { close: ["closed"] })) {
console.log(`on -> ${id} ${status}`);
}
console.log(`listeners left: ${shipments.listenerCount("status")}`);
once -> SHP-77 via standard
on -> SHP-77 InTransit
on -> SHP-77 OutForDelivery
on -> SHP-77 Delivered
listeners left: 0
once, event'in argümanlarını bir array olarak resolve eder; beklerken emitter error yayınlarsa reject olur. 06'da worker'ın mesajını tam bu şekilde bekledik. on, event'leri async iterator'a çevirir; close listesindeki event gelince döngü biter ve listener'lar temizlenir. İkisi de signal seçeneğiyle iptal edilebilir. Async iterator'ın kendisi JavaScript İleri serisinin "Async Iterator" yazısında.
MaxListenersExceededWarning
import { EventEmitter } from "node:events";
const orders = new EventEmitter();
// Bir hata: her request yeniden abone oluyor ve hiç ayrılmıyor
function handleRequest(requestNo: number): void {
orders.on("orderPlaced", (id: string) => console.log(`request ${requestNo} saw ${id}`));
}
for (let i = 1; i <= 11; i++) {
handleRequest(i);
}
console.log(`listeners: ${orders.listenerCount("orderPlaced")}`);
listeners: 11
(node:47510) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 orderPlaced listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
Varsayılan sınır event başına 10 listener. Bu bir hata değil, uyarı: listener yine eklenir. Uyarıyı gördüğünde ilk tepki setMaxListeners(100) olmamalı. Uzun yaşayan, uygulama genelindeki bir emitter'a request başına listener ekleyip off ile çıkarmamak klasik bir memory leak; her listener closure'ıyla birlikte request'in tuttuğu her şeyi bellekte tutar. Request'e bağlı abonelikleri once ile ya da iş bitince off ile kaldır.
İptal: AbortController, AbortSignal.timeout() ve AbortSignal.any()
.NET'te iptal CancellationToken ile yapılır ve ASP.NET Core her request için HttpContext.RequestAborted verir. Node'daki karşılığı web standardı olan AbortController ve AbortSignal: fetch, fs/promises, timers/promises, events.once, stream'ler ve child process'ler bir signal seçeneği kabul ediyor. Yavaş cevap veren bir stok servisine üç farklı iptalle istek atalım:
import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises";
// Yavaş bir stok servisi: 2 saniye sonra cevap veriyor
const stock = createServer(async (req, res) => {
await delay(2000);
res.end(JSON.stringify({ isbn: "9780441172719", available: 12 }));
});
await new Promise<void>((resolve) => stock.listen(0, "127.0.0.1", resolve));
const { port } = stock.address() as { port: number };
const url = `http://127.0.0.1:${port}/stock/9780441172719`;
// 1) Yalnız timeout: CancellationTokenSource.CancelAfter karşılığı
let started = performance.now();
try {
await fetch(url, { signal: AbortSignal.timeout(300) });
} catch (err) {
const e = err as Error;
console.log(`timeout: ${e.name} after ${Math.round(performance.now() - started)} ms (${e.message})`);
}
// 2) Bağlı signal'ler: client gitti YA DA timeout doldu, hangisi önce olursa
const clientGone = new AbortController();
setTimeout(() => clientGone.abort(new Error("client closed the connection")), 100);
started = performance.now();
try {
await fetch(url, { signal: AbortSignal.any([clientGone.signal, AbortSignal.timeout(300)]) });
} catch (err) {
const e = err as Error;
console.log(`any(): ${e.name} after ${Math.round(performance.now() - started)} ms (${e.message})`);
}
// 3) Promise tabanlı timer'lar da signal kabul ediyor
const ac = new AbortController();
setTimeout(() => ac.abort(), 50);
try {
await delay(1000, undefined, { signal: ac.signal });
} catch (err) {
console.log(`delay(): ${(err as Error).name}`);
}
stock.closeAllConnections();
stock.close();
timeout: TimeoutError after 306 ms (The operation was aborted due to timeout)
any(): Error after 102 ms (client closed the connection)
delay(): AbortError
Üç ayrıntı önemli. AbortSignal.timeout(), AbortError değil TimeoutError adında bir hata üretir; log'da ve retry kararında ikisini ayırabilirsin. AbortSignal.any(), C#'taki CancellationTokenSource.CreateLinkedTokenSource'un karşılığı: listedeki signal'lerden biri abort olunca o da abort olur. abort(reason)'a verdiğin değer de olduğu gibi rejection'a dönüşüyor; ikinci satırdaki hata bizim Error'ımız. Son olarak AbortController iptali istemek içindir, AbortSignal ise dinlemek için; fonksiyonlarına controller'ı değil yalnız signal'i geçir, tıpkı CancellationTokenSource yerine CancellationToken geçirdiğin gibi. İptali veritabanına kadar taşımanın .NET tarafı ASP.NET Core Web API serisinin "Async/Await ve CancellationToken" yazısında; Node'da client bağlantıyı kapattığında oluşan signal'i 10'da kendi sunucumuzda kuracağız.
AsyncLocalStorage: Request Başına Bağlam
ASP.NET Core'da bir service, IHttpContextAccessor ya da Activity.Current üzerinden o anki request'in bilgisine parametre almadan ulaşabilir. Arkada AsyncLocal<T> var: değer, async çağrı zinciri boyunca taşınır. Node'da tek thread'de aynı anda yüzlerce request işlendiği için, "şu anki request" bilgisini module seviyesinde bir değişkende tutmak işe yaramaz. Bunu bir deneyle görelim: aynı log fonksiyonu hem global bir değişkeni hem de AsyncLocalStorage'ı okuyor, üç request aynı anda geliyor ve her biri farklı sürede bitiyor:
import { createServer } from "node:http";
import { AsyncLocalStorage } from "node:async_hooks";
import { setTimeout as delay } from "node:timers/promises";
// Yanlış: uçuştaki bütün request'lerin paylaştığı tek değişken
let currentRequestId = "";
// Doğru: her request'in async çağrı zincirine bağlı bir değer
const requestContext = new AsyncLocalStorage<{ requestId: string }>();
function log(message: string): void {
const fromStore = requestContext.getStore()?.requestId;
console.log(`global=${currentRequestId} als=${fromStore} | ${message}`);
}
// Service request id'yi hiçbir zaman parametre olarak almıyor
async function loadOrder(orderId: string, dbLatencyMs: number): Promise<void> {
log(`loading ${orderId}`);
await delay(dbLatencyMs);
log(`loaded ${orderId}`);
}
const server = createServer((req, res) => {
const requestId = String(req.headers["x-request-id"]);
currentRequestId = requestId;
requestContext.run({ requestId }, async () => {
const url = new URL(req.url ?? "/", "http://localhost");
await loadOrder(url.searchParams.get("id")!, Number(url.searchParams.get("latency")));
res.end("ok");
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as { port: number };
const call = (requestId: string, orderId: string, latency: number) =>
fetch(`http://127.0.0.1:${port}/orders?id=${orderId}&latency=${latency}`, { headers: { "x-request-id": requestId } });
await Promise.all([call("req-A", "ORD-1001", 60), call("req-B", "ORD-1002", 20), call("req-C", "ORD-1003", 40)]);
server.close();
global=req-A als=req-A | loading ORD-1001
global=req-B als=req-B | loading ORD-1002
global=req-C als=req-C | loading ORD-1003
global=req-C als=req-B | loaded ORD-1002
global=req-C als=req-C | loaded ORD-1003
global=req-C als=req-A | loaded ORD-1001
İlk üç satırda ikisi de doğru, çünkü her request değişkeni yazdıktan hemen sonra log atıyor. await'ten sonra global değişken son gelen request'te, req-C'de kalmış; ORD-1002'nin log'u yanlış request'e yazılıyor. AsyncLocalStorage ise her satırda doğru. run(store, callback), callback'i ve onun başlattığı bütün async işleri o store ile çalıştırır; getStore() o anki zincirin değerini döndürür. Node 24'ten beri AsyncLocalStorage varsayılan olarak AsyncContextFrame adlı daha verimli bir implementasyonu kullanıyor; module stable. enterWith() gibi hâlâ experimental olan method'lar yerine run() kullan.
Bookstore: OrderService, 'orderPlaced' ve Correlation Id
Şimdi bu parçaları API'ye koyuyoruz. Önce request bağlamı ve onu okuyan küçük bir logger:
import { AsyncLocalStorage } from "node:async_hooks";
// Async çağrı zincirini takip eden request değerleri (HttpContext / Activity karşılığı)
export interface RequestContext {
requestId: string;
method: string;
path: string;
}
const storage = new AsyncLocalStorage<RequestContext>();
export function runWithRequestContext<T>(context: RequestContext, work: () => T): T {
return storage.run(context, work);
}
export function currentRequestContext(): RequestContext | undefined {
return storage.getStore();
}
import { currentRequestContext } from "../context/requestContext.ts";
type Level = "info" | "warn" | "error";
// Her kayıt tek bir JSON satırı; request id parametreden değil AsyncLocalStorage'dan geliyor
export function log(level: Level, msg: string, fields: Record<string, unknown> = {}): void {
const entry = { time: new Date().toISOString(), level, requestId: currentRequestContext()?.requestId, msg, ...fields };
const line = JSON.stringify(entry);
if (level === "error") {
console.error(line);
} else {
console.log(line);
}
}
Bu logger geçici; 14'te yerini pino alacak ve correlation id aynı store'dan gelmeye devam edecek. OrderService, typed bir EventEmitter: @types/node'daki generic parametre, event adlarını ve argümanlarını derleme zamanında kontrol ediyor. captureRejections açık ve kendi error listener'ı var:
interface OrderEvents {
orderPlaced: [order: Order];
error: [err: Error];
}
// Siparişleri bellekte oluşturur ve duyurur; listener'lar (bildirim, rapor) bağımsız kalır
export class OrderService extends EventEmitter<OrderEvents> {
#orders = new Map<string, Order>();
#nextNumber = 1001;
constructor() {
// Async listener'ların rejection'ları process'i çökertmek yerine 'error' event'ine gider
super({ captureRejections: true });
this.on("error", (err) => {
log("error", "orderPlaced listener failed", { error: `${err.name}: ${err.message}`, cause: (err.cause as Error | undefined)?.name });
});
}
placeOrder(customerId: string, lines: readonly OrderLine[]): Order {
const items: OrderItem[] = lines.map((line) => {
const book = findBook(line.isbn);
if (!book) {
throw new UnknownBookError(line.isbn);
}
return { isbn: book.isbn, quantity: line.quantity, unitPrice: book.price };
});
const order: Order = {
id: `ORD-${this.#nextNumber++}`,
customerId,
status: "Pending",
items,
createdAtUtc: new Date().toISOString(),
};
this.#orders.set(order.id, order);
log("info", "order placed", { orderId: order.id, items: items.length });
this.emit("orderPlaced", order);
return order;
}
}
UnknownBookError, Error'dan türeyen küçük bir class; field'ını constructor parametresinde değil ayrı bir satırda tanımlıyoruz, çünkü parameter property type stripping'de çalışmıyor (05). Bildirim tarafı OrderService'i tanımıyor, yalnız event'e abone oluyor ve e-posta sağlayıcısına yapılan çağrıyı 2 saniyeyle sınırlıyor:
// E-posta sağlayıcısı çağrısının yerine geçiyor; abort signal'ine fetch gibi uyuyor
async function sendEmail(to: string, subject: string, signal: AbortSignal): Promise<void> {
await delay(Number(process.env.EMAIL_LATENCY_MS ?? 30), undefined, { signal });
log("info", "e-mail sent", { to, subject });
}
async function sendOrderConfirmation(order: Order): Promise<void> {
await sendEmail(`${order.customerId}@example.com`, `Order ${order.id} received`, AbortSignal.timeout(2000));
}
export function registerOrderNotifications(orders: OrderService): void {
orders.on("orderPlaced", sendOrderConfirmation);
}
POST /api/orders endpoint'ini 10'da yazacağız; şimdilik labs/07/place-order.ts siparişi bir request bağlamının içinde oluşturuyor. Önce normal durum, sonra e-posta sağlayıcısının 3 saniye geciktiği durum:
node labs/07/place-order.ts
EMAIL_LATENCY_MS=3000 node labs/07/place-order.ts
{"time":"2026-11-14T18:32:24.984Z","level":"info","requestId":"req-7f3a","msg":"order placed","orderId":"ORD-1001","items":2}
placeOrder returned ORD-1001
UnknownBookError: unknown ISBN 9780000000000
{"time":"2026-11-14T18:32:25.018Z","level":"info","requestId":"req-7f3a","msg":"e-mail sent","to":"ada@example.com","subject":"Order ORD-1001 received"}
---
{"time":"2026-11-14T18:32:28.542Z","level":"info","requestId":"req-7f3a","msg":"order placed","orderId":"ORD-1001","items":2}
placeOrder returned ORD-1001
UnknownBookError: unknown ISBN 9780000000000
{"time":"2026-11-14T18:32:30.546Z","level":"error","requestId":"req-7f3a","msg":"orderPlaced listener failed","error":"AbortError: The operation was aborted","cause":"TimeoutError"}
İki şeye dikkat et. E-posta log'u, sipariş log'undan 34 ms sonra ve run()'ın callback'i çoktan bittikten sonra yazıldığı hâlde doğru request id'yi taşıyor: listener, emit anındaki bağlamı devralıyor. İkinci koşuda timeout dolunca hata process'i düşürmedi, captureRejections sayesinde error listener'ına gitti; cause alanı da asıl sebebin timeout olduğunu gösteriyor. Bir not: bu bildirim aynı process'te ve bellekte çalışıyor, process çökerse e-posta kaybolur. Kaybolmaması gereken işler için Message Queue'lar serisindeki outbox ve queue yaklaşımı gerekir.
Son parça src/server.ts. Her request'te gelen x-request-id header'ı makul görünüyorsa onu, değilse randomUUID() ile ürettiğimiz yeni bir UUID'yi (rastgele üretilen 128 bitlik benzersiz kimlik) kullanıyoruz, cevaba geri yazıyoruz ve handler'ı bağlamın içinde çalıştırıyoruz. Response'un finish event'i de bir EventEmitter event'i; request süresini orada log'luyoruz. Sunucu artık varsayılan olarak yalnız 127.0.0.1'i dinliyor (HOST ile değişiyor); bunun nedenini 10'da konuşacağız.
// Çağıranın correlation id'si makulse onu kullan, değilse yenisini üret
function requestIdFrom(header: string | string[] | undefined): string {
return typeof header === "string" && /^[\w.-]{1,64}$/.test(header) ? header : randomUUID();
}
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const requestId = requestIdFrom(req.headers["x-request-id"]);
res.setHeader("x-request-id", requestId);
runWithRequestContext({ requestId, method: req.method ?? "GET", path: url.pathname }, async () => {
const started = performance.now();
res.on("finish", () => {
log("info", "request finished", { method: req.method, path: url.pathname, status: res.statusCode, durationMs: Math.round(performance.now() - started) });
});
// ... route'lar 06'daki gibi
});
});
curl -s -o /dev/null -D - -H "x-request-id: angular-5d1c" http://127.0.0.1:3000/api/books | grep -i x-request
curl -s -o /dev/null -D - -H "x-request-id: bad id with spaces" http://127.0.0.1:3000/api/books | grep -i x-request
x-request-id: angular-5d1c
x-request-id: 0be9eab5-069d-47ed-b49c-4dd89686afd3
Bookstore API listening on http://127.0.0.1:3000 (development)
{"time":"2026-11-14T18:32:41.433Z","level":"info","requestId":"angular-5d1c","msg":"request finished","method":"GET","path":"/api/books","status":200,"durationMs":0}
{"time":"2026-11-14T18:32:41.444Z","level":"info","requestId":"0be9eab5-069d-47ed-b49c-4dd89686afd3","msg":"request finished","method":"GET","path":"/api/books","status":200,"durationMs":0}
Angular istemcisinin gönderdiği id aynen döndü, boşluk içeren id reddedilip yerine UUID üretildi. Header'ı doğrulamak önemli: client'tan gelen değeri kontrol etmeden kullanmak, log'a çok uzun ya da anlamsız değerlerin girmesine, düz metin log yazan sistemlerde de satır sonu karakterleriyle sahte log satırları eklenmesine yol açar.
unhandledRejection ve uncaughtException
Hiç await edilmeyen ya da catch'i olmayan bir promise reject olursa:
// Bir promise reject oluyor ve kimse onu beklemiyor
async function chargeCard(orderId: string): Promise<void> {
throw new Error(`payment gateway timeout for ${orderId}`);
}
chargeCard("ORD-1003");
setTimeout(() => console.log("still serving requests?"), 10);
file:///home/user/bookstore-api/labs/07/unhandled.ts:3
throw new Error(`payment gateway timeout for ${orderId}`);
^
Error: payment gateway timeout for ORD-1003
at chargeCard (file:///home/user/bookstore-api/labs/07/unhandled.ts:3:11)
...
Node.js v26.13.1
exit=1
Node 15'ten beri varsayılan mod --unhandled-rejections=throw: rejection, yakalanmamış bir exception gibi ele alınır ve process kapanır. warn moduyla çalıştırınca yalnız UnhandledPromiseRejectionWarning yazılıyor ve still serving requests? basılıyor; bu modu production'da kullanma, hatayı gizler. .NET'te durum farklı: gözlemlenmeyen bir Task exception'ı process'i düşürmez, yalnız TaskScheduler.UnobservedTaskException event'ini tetikler.
Peki process.on("uncaughtException") ile her hatayı yakalayıp devam etmek? Şu örnekte stok rezervasyonu iki adımlı bir güncelleme ve iki adımın arasında bir bug var:
// Anti-pattern: her çökmeyi yut ve devam et
const reservations: { isbn: string; quantity: number }[] = [];
let reservedUnits = 0;
process.on("uncaughtException", (err) => {
console.log(`ignored: ${err.message}`);
});
function reserveStock(isbn: string, quantity: number, warehouse: string | undefined): void {
reservedUnits += quantity;
// Güncellemenin iki adımı arasında bir bug
const code = warehouse!.toUpperCase();
reservations.push({ isbn, quantity });
console.log(`reserved ${quantity} x ${isbn} in ${code}`);
}
setTimeout(() => reserveStock("9780441172719", 3, undefined), 0);
setTimeout(() => console.log(`reservedUnits: ${reservedUnits}, reservations: ${reservations.length}`), 10);
ignored: Cannot read properties of undefined (reading 'toUpperCase')
reservedUnits: 3, reservations: 0
exit=0
Process yaşıyor ama durumu bozuk: sayaç 3 birim rezerve edildiğini söylüyor, rezervasyon listesi boş. Bundan sonra gelen her request bu tutarsız durumla çalışacak. Node dokümanı da uncaughtException'ı yalnız son bir temizlik için kullanmayı, ardından process'i kapatmayı öneriyor. Doğru yaklaşım: hatayı log'la, açık kaynakları kapatmaya çalış, exit code 1 ile çık ve yeniden başlatmayı process'in dışındaki bir supervisor'a bırak (Kubernetes, systemd, Docker'ın restart policy'si). Graceful shutdown'u 14'te kuracağız.
Üretimde Dikkat
Her log satırında correlation id olsun. Gelen x-request-id'yi doğrula, yoksa üret, cevaba ve downstream çağrılarına aynı header'la taşı. OpenTelemetry kullandığında trace context da aynı mekanizmayla, AsyncLocalStorage üzerinden taşınıyor; ayrıntısı Observability serisinde.
Dışarıya yapılan her çağrının bir timeout'u olsun. fetch'in toplam süre için varsayılan bir timeout'u yok; AbortSignal.timeout() vermezsen yavaş bir servis request'ini dakikalarca açık tutabilir.
Process'i çökmeye hazırla. unhandledRejection ve uncaughtException process'i kapatır ve bu doğru davranış. Önemli olan, bir supervisor'ın onu hemen yeniden başlatması ve bellekte kaybolan işin (bizim e-posta bildirimimiz gibi) kalıcı bir queue'da durması.
Uzun yaşayan emitter'ları izle. MaxListenersExceededWarning'i CI'da ve log'da bir hata gibi ele al; çoğu zaman gerçek bir leak'in ilk belirtisidir.
Sık Düşülen Tuzaklar
emit'in asenkron olduğunu sanmak. Listener'lar senkron çalışır; ağır bir listener placeOrder'ı ve onu çağıran request'i bekletir, senkron bir listener'ın exception'ı da doğrudan emit'i çağıran koda fırlar.
Async listener'ı captureRejections olmadan kullanmak. Hata unhandled rejection olur ve process'i kapatır; error listener'ının olması bunu engellemez.
error event'ini dinlememek. Socket, stream, child process ya da kendi emitter'ın: dinlenmeyen error process'i düşürür.
Request bilgisini module seviyesinde bir değişkende tutmak. İlk await'e kadar doğru görünür, yük altında başka request'in verisine karışır. AsyncLocalStorage kullan.
Callback API'sine async fonksiyon vermek. fs.readFile(path, async (err, data) => { ... }) gibi bir kullanımda callback'in döndürdüğü promise'i kimse beklemez; içindeki hata yine unhandled rejection olur. Promise API'sini kullan.
Küçük Bir Deneme
★☆☆ labs/07/guess.ts'teki ilk listener'ı orders.once ile ekle ve emit'i iki kez çağır. İkinci çağrıda hangi satırlar yazılır, emit ne döndürür?
★★☆ sendOrderConfirmation'ı, timeout'a ek olarak uygulama kapanırken iptal edilecek bir AbortController'ın signal'ini de dinleyecek şekilde AbortSignal.any() ile değiştir. SIGINT geldiğinde log'da hangi hata adı görünüyor?
★★★ OrderService'e orderPlaced event'ini events.on ile tüketen bir "satış özeti" consumer'ı ekle: her 10 siparişte bir toplam ciroyu log'lasın ve AbortSignal ile durdurulabilsin. Consumer'ın log satırlarında hangi request id'si görünüyor ve neden?
Asenkron akışın parçalarını gördük: callback, promise, event, iptal ve bağlam. Bir sonraki yazıda bu araçları en sık kullandığımız yere, file system'e geçiyoruz: fs/promises, path, Buffer ile Bookstore'un kitap kapağı klasörü ve CSV (virgülle ayrılmış değerler) dosyasıyla katalog içe aktarma.