Network Programming · Part 10 — Robust Networking: Errors, Retries & Debugging
Capstone: treat the network as unreliable — socket error codes, mandatory error handlers, timeouts at every layer, retries with backoff & jitter, reconnecting clients, and a layered debugging toolkit with Node.js + TypeScript.
Đây là Phần 10 / 10 — bài kết của series lập trình mạng với Node.js + TypeScript. Phần 1–9 dạy dữ liệu đi thế nào, mở socket, nói HTTP, mã hóa TLS, đóng khung message, và scale server. Hôm nay ta trả lời câu hỏi mọi hệ thống production cuối cùng đều gặp: điều gì xảy ra khi mạng hỏng?.
Câu trả lời thật: lỗi là mặc định. Packet rơi, cáp giật, cache DNS cũ, load balancer drain node, cert TLS hết hạn, client ngắt giữa request. Code giả định “gọi connect() là xong mãi mãi” sẽ crash, treo, hoặc hỏng dữ liệu. Mạng robust nghĩa là chờ lỗi và xây đường phục hồi ở mọi tầng.
Mạng mặc định không tin cậy
Ở Phần 1 ta học TCP ráp packet thành luồng có thứ tự. Độ tin cậy đó là best-effort đầu-cuối — không đảm bảo app luôn thành công lần đầu. Giữa socket.write() và socket.on('data') của peer có router, NAT, firewall, buffer kernel, và GC pause.
Your process → OS socket buffer → NIC → Internet → … → peer
↑ any hop can drop, delay, reset, or refuse
Giả định thiết kế: mọi lời gọi ra có thể fail; mọi kết nối vào có thể chết không báo; mọi retry có thể làm tệ hơn nếu bạn cẩu thả. Phần còn lại của bài là bộ công cụ để sống với thực tế đó.
Lỗi socket phổ biến
Node đưa lỗi cấp OS ra err.code trên object Error (và errno trên một số API). Học thuộc — chúng cho biết tầng nào hỏng và làm gì tiếp.
| Code | Ý nghĩa | Nguyên nhân | Xử lý |
|---|---|---|---|
ECONNREFUSED | Không ai listen IP:port đó | Server tắt, sai port, firewall DROP | Sửa đích, health-check upstream, retry backoff |
ECONNRESET | Peer đóng TCP đột ngột | Server crash, timeout idle, proxy kill | Kết nối lại; đừng giả định write một phần thành công |
ETIMEDOUT | Vượt timeout OS/mạng | Host không tới, routing đen, server quá tải | Timeout connect ngắn hơn + retry; kiểm tra routing/DNS |
EHOSTUNREACH | Không có route tới host | Sai IP, VPN tắt, lỗi mạng local | Xác minh IP/DNS; retry app không đủ |
EPIPE | Ghi vào socket peer đã đóng | Tiếp tục ghi sau end() / disconnect | Gắn handler error; dừng ghi khi close |
EADDRINUSE | Port đã bị process khác bind | Hai server cùng port | Chọn port khác hoặc dừng process xung đột |
ENOTFOUND | DNS lookup fail — hostname không có địa chỉ | Gõ sai, domain hết hạn, resolver lỗi (Phần 4) | Kiểm tra hostname; cache DNS cẩn thận; retry resolver |
Ý chính:
ECONNREFUSEDlà “không ai ở nhà”;ECONNRESETlà “bên kia cúp máy”;ETIMEDOUTlà “chờ quá lâu rồi bỏ cuộc”.
Luôn gắn handler error
Trong Node.js, error event trên socket không có listener là fatal — ném exception và có thể crash cả process. Lỗi này bẫy người mới liên tục.
import { createServer, type Socket } from 'node:net';
function attachSafeHandlers(socket: Socket, label: string): void {
socket.on('error', (err: NodeJS.ErrnoException) => {
// Log with context — never let this event go unhandled.
console.error(`[${label}] socket error:`, err.code ?? err.message);
});
socket.on('close', (hadError) => {
console.log(`[${label}] closed`, hadError ? '(with error)' : '(clean)');
});
}
const server = createServer((socket) => {
attachSafeHandlers(socket, 'server-conn');
socket.write('hello\n');
});
server.on('error', (err: NodeJS.ErrnoException) => {
// Server-level errors too — e.g. EADDRINUSE on listen().
console.error('server error:', err.code ?? err.message);
});
server.listen(3000);
Quy tắc tương tự cho tls.TLSSocket, http.Server, WebSocket, và stream child_process: nếu kế thừa EventEmitter và emit error, bạn phải listen. Trong code async, cũng xử lý promise reject từ fetch, connect(), và wrapper của bạn.
Timeout ở mọi tầng
Không timeout nghĩa là “treo mãi mãi”. Code production cần ba loại timeout khác nhau:
1. Timeout kết nối
Chờ bao lâu cho bắt tay TCP (và TLS) hoàn tất. node:net không có connect timeout sẵn — bạn tự implement:
import { Socket } from 'node:net';
export function connectWithTimeout(
host: string,
port: number,
connectMs: number,
): Promise<Socket> {
return new Promise((resolve, reject) => {
const socket = new Socket();
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`connect timeout after ${connectMs}ms`));
}, connectMs);
socket.once('connect', () => {
clearTimeout(timer);
resolve(socket);
});
socket.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
socket.connect(port, host);
});
}
// Usage
const socket = await connectWithTimeout('127.0.0.1', 3000, 3_000);
2. Timeout idle / socket
Kết nối có thể không nhận dữ liệu bao lâu trước khi coi là chết. Cần thiết cho WebSocket và TCP client sống lâu (Phần 6):
socket.setTimeout(30_000); // 30 s idle
socket.on('timeout', () => {
console.warn('socket idle timeout — destroying');
socket.destroy();
});
Kết hợp heartbeat ứng dụng (ping/pong) để idle timeout chỉ kích hoạt khi peer thật sự chết.
3. Timeout toàn bộ request
Trần cho toàn bộ thao tác — connect + TLS + gửi + chờ response. Node 18+ có sẵn AbortSignal.timeout():
async function fetchWithDeadline(url: string, totalMs: number): Promise<Response> {
const response = await fetch(url, {
signal: AbortSignal.timeout(totalMs),
});
return response;
}
try {
const res = await fetchWithDeadline('https://api.example.com/health', 5_000);
console.log('status:', res.status);
} catch (err) {
// DOMException with name 'TimeoutError' when AbortSignal fires.
console.error('request failed or timed out:', err);
}
Với socket thô, bọc toàn flow trong Promise.race với timer, hoặc dùng AbortController trong client library của bạn. Quy tắc ngón tay cái: connect timeout ≪ idle timeout ≪ overall request timeout.
Retry đúng cách
Retry mù quáng biến sự cố ngắn thành sự cố nhiều giờ.
Chỉ retry thao tác idempotent
Idempotent nghĩa là làm hai lần có cùng hiệu ứng với một lần.
| An toàn retry | Nguy hiểm retry |
|---|---|
GET, HEAD, PUT with full body | POST payment, POST create order |
| DNS lookup | Non-idempotent RPC without dedup key |
| TCP/WebSocket reconnect (new session) | Retry after partial write without framing (Part 8) |
Nếu server có thể đã xử lý request, dùng header idempotency key trước khi retry.
Backoff lũy thừa có jitter
Khi service down, mọi client retry cùng nhịp tạo bão retry khiến nó tiếp tục down. Backoff lũy thừa giãn các lần thử; jitter random hóa delay để client không đồng bộ.
export interface RetryOptions {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
/** Return false to stop retrying this error immediately. */
shouldRetry?: (err: unknown, attempt: number) => boolean;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function backoffDelay(attempt: number, base: number, max: number): number {
const exp = Math.min(max, base * 2 ** (attempt - 1));
const jitter = Math.random() * exp * 0.3; // up to 30% random spread
return Math.floor(exp + jitter);
}
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
opts: RetryOptions,
): Promise<T> {
const { maxAttempts, baseDelayMs, maxDelayMs, shouldRetry } = opts;
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (shouldRetry && !shouldRetry(err, attempt)) throw err;
if (attempt === maxAttempts) break;
const delay = backoffDelay(attempt, baseDelayMs, maxDelayMs);
console.warn(`attempt ${attempt} failed, retrying in ${delay}ms`, err);
await sleep(delay);
}
}
throw lastErr;
}
// Example: retry GET on transient network errors only
async function fetchHealth(): Promise<string> {
return retryWithBackoff(
async () => {
const res = await fetch('http://127.0.0.1:3000/health', {
signal: AbortSignal.timeout(2_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
},
{
maxAttempts: 5,
baseDelayMs: 200,
maxDelayMs: 8_000,
shouldRetry: (err) => {
const code = (err as NodeJS.ErrnoException).code;
return code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET';
},
},
);
}
Circuit breaker (một đoạn)
Circuit breaker theo dõi tỷ lệ lỗi và ngừng gọi dependency đang bệnh trong thời gian cooldown. Sau N lỗi liên tiếp, breaker mở — code fail nhanh tại chỗ thay vì đập server đang chết. Sau timeout breaker nửa-mở: một request thăm dò; thành công đóng mạch (retry bình thường), thất bại mở lại. Không cần thư viện nặng ngay — counter, timestamp disabledUntil, và helper backoff ở trên đủ cho hầu hết service Node.
Client reconnect có khả năng phục hồi
Kết nối sống lâu — chat WebSocket (Phần 6), telemetry TCP, session game — sẽ rớt. Pattern: khi close hoặc error, lên lịch reconnect với backoff lũy thừa có trần, reset backoff khi open thành công.
import WebSocket from 'ws';
interface ReconnectOptions {
url: string;
minDelayMs: number;
maxDelayMs: number;
}
export function createReconnectingWebSocket(opts: ReconnectOptions): WebSocket {
let attempt = 0;
let ws: WebSocket;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
const scheduleReconnect = (): void => {
attempt++;
const exp = Math.min(opts.maxDelayMs, opts.minDelayMs * 2 ** (attempt - 1));
const jitter = Math.random() * exp * 0.2;
const delay = Math.floor(exp + jitter);
console.warn(`reconnecting in ${delay}ms (attempt ${attempt})`);
reconnectTimer = setTimeout(connect, delay);
};
const connect = (): void => {
ws = new WebSocket(opts.url);
ws.on('open', () => {
attempt = 0; // success — reset backoff
console.log('connected');
});
ws.on('message', (data) => {
console.log('←', data.toString());
});
ws.on('error', (err) => {
// MUST exist — prevents process crash.
console.error('ws error:', err.message);
});
ws.on('close', () => {
console.warn('ws closed — scheduling reconnect');
scheduleReconnect();
});
};
connect();
return ws!;
}
Cấu trúc tương tự cho TCP thô với net.createConnection(): theo dõi attempt, giới hạn delay, reset khi connect, không bỏ handler error. Khi reconnect, xác thực lại và replay state (subscription, cursor) — đường truyền mới dù URL giống.
Bộ công cụ debug
Khi hỏng, chọn công cụ ở tầng có triệu chứng. Đừng dùng Wireshark khi lỗi là HTTP header sai.
Tầng ứng dụng — HTTP & API
# Verbose HTTP — see request, response headers, TLS timing, redirects
curl -v http://127.0.0.1:3000/health
curl -v https://api.example.com/users -H 'Accept: application/json'
# Time breakdown: DNS, connect, TLS, TTFB
curl -w 'dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer}\n' -o /dev/null -s https://example.com
curl -v là cách nhanh nhất trả lời “server có trả đúng như tôi nghĩ?” mà không viết client code.
Tầng socket & TLS — byte thô
# Raw TCP — type bytes, see what the server sends (Part 2)
nc -v 127.0.0.1 3000
echo 'PING' | nc -w 3 127.0.0.1 9000
# TLS handshake + cert chain (Part 7)
openssl s_client -connect example.com:443 -servername example.com </dev/null
openssl s_client hiện chuỗi certificate, cipher, và SNI có khớp. Dùng khi fetch fail lỗi certificate mà bạn không chắc vì sao.
Khả năng tới & bảng socket
# Is the host alive at the IP layer?
ping -c 3 8.8.8.8
traceroute api.example.com
# What is listening? Established connections? (Linux: ss; macOS: netstat)
ss -tlnp
ss -tn state established
netstat -an | grep LISTEN
Nếu ping fail nhưng curl ok, vấn đề trên tầng IP (hoặc ICMP bị chặn). Nếu không có gì LISTEN trên port, ECONNREFUSED là đúng.
Bắt packet — sự thật cuối cùng
Khi log ứng dụng và curl không khớp thực tế, bắt packet:
# Capture TCP port 3000 on loopback (Linux; macOS: lo0)
sudo tcpdump -i any port 3000 -nn -A
# TLS on 443 — ciphertext on the wire; combine with openssl s_client for keys in dev
sudo tcpdump -i any host api.example.com and port 443 -nn
Đọc một dòng tcpdump:
12:34:56.789012 IP 192.168.1.10.54321 > 93.184.216.34.443: Flags [P.], seq 100, ack 50, win 256, length 42
- hướng: client port 54321 tới server 443.
- Push (dữ liệu ứng dụng), . trường ack hợp lệ.
- số thứ tự TCP để debug reorder/retransmit.
- 42 byte payload trong segment này.
Wireshark decode HTTP, TLS, DNS, WebSocket — cùng capture, nhìn phong phú hơn. Ở Phần 7 ta mã hóa đường truyền; trong capture bạn thấy bản ghi TLS Application Data, không phải mật khẩu plaintext.
Gắn lại: HTTP client nhỏ có khả năng phục hồi
import { retryWithBackoff } from './retry-with-backoff.js';
export async function resilientGet(path: string): Promise<string> {
return retryWithBackoff(
async () => {
const res = await fetch(`http://127.0.0.1:3000${path}`, {
signal: AbortSignal.timeout(4_000),
});
if (res.status >= 500) throw new Error(`server error ${res.status}`);
if (!res.ok) throw new Error(`client error ${res.status}`);
return res.text();
},
{
maxAttempts: 4,
baseDelayMs: 100,
maxDelayMs: 4_000,
shouldRetry: (err, attempt) => {
const code = (err as NodeJS.ErrnoException).code;
const msg = err instanceof Error ? err.message : '';
if (msg.includes('server error')) return true;
return code === 'ECONNREFUSED' || code === 'ETIMEDOUT';
},
},
);
}
Mọi tầng: timeout tổng trên fetch, chỉ retry transient / 5xx, backoff + jitter trong retryWithBackoff, và socket bên dưới vẫn cần handler error trên server bạn sở hữu.
Lỗi người mới hay mắc
- Không handler
errortrên socket/server — mộtECONNRESETcrash process Node. - Retry request không idempotent —
POST /chargetrùng có thể tính tiền hai lần. - Bão retry không backoff/jitter — client đồng bộ và DDoS service đang hồi phục.
- Không timeout —
connect()hoặcfetch()kẹt giữ event-loop và file descriptor mãi. - Nuốt lỗi im lặng —
catch {}rỗng cheENOTFOUNDvàECONNREFUSEDđến khi user phàn nàn.
Bài tập
Thử từng bài trước khi mở lời giải.
- Chạy server TCP port 3000, kết nối
nc. Tắt server khincđang kết nối — quan sátECONNRESETtrên server nếu thiếu handlererror. - Viết
retryWithBackoffgọifetch('http://127.0.0.1:9/')và log mỗi delay. Xác nhận delay tăng và khác giữa các lần chạy (jitter). - Chạy
curl -vvào server HTTP Phần 5, đồng thờitcpdumpở terminal khác. Tìm một dòng[P.]và ghi source, destination,length.
Lời giải
// Exercise 1 — server without error handler crashes on client reset
import { createServer } from 'node:net';
const server = createServer((socket) => {
// Missing socket.on('error') → process may throw on ECONNRESET
socket.on('data', (buf) => socket.write(buf));
});
server.listen(3000, () => console.log('listening :3000'));
// Terminal 2: nc localhost 3000
// Kill server (Ctrl+C) or kill nc abruptly → watch for uncaughtExceptionSửa: thêm socket.on('error') và server.on('error') — process sống sót.
// Exercise 2 — jittered backoff on ECONNREFUSED
import { retryWithBackoff } from './retry-with-backoff.js';
await retryWithBackoff(
async () => fetch('http://127.0.0.1:9/'),
{ maxAttempts: 4, baseDelayMs: 200, maxDelayMs: 3_000 },
);
// Logs show ~200ms, ~400ms, ~800ms ± jitter — different each run# Exercise 3
curl -v http://127.0.0.1:3000/
sudo tcpdump -i lo0 port 3000 -nn -A # macOS loopback
# Example line:
# IP 127.0.0.1.54321 > 127.0.0.1.3000: Flags [P.], seq 1, ack 1, win 6379, length 78
# Source: 127.0.0.1:54321 (curl client)
# Destination: 127.0.0.1:3000 (your server)
# length 78: 78 bytes of HTTP request payload in this segmentTóm tắt series
Bạn đã có lộ trình đầy đủ từ bit trên dây đến hành vi production:
- Part 1 — Networking fundamentals — layers, IP, ports, packets
- Part 2 — TCP sockets — connect, listen, byte streams
- Part 3 — UDP datagrams — connectionless, lossy-by-design
- Part 4 — DNS & addressing — names to IPs,
ENOTFOUND - Part 5 — HTTP from the socket up — request/response framing
- Part 6 — WebSockets & real-time — upgrade, push, heartbeats
- Part 7 — TLS & HTTPS — encryption, certificates,
node:https - Part 8 — Streams, backpressure & framing — message boundaries over TCP
- Part 9 — Concurrency & scaling servers — workers, connection fan-out
Phần 10 gắn kết: chờ lỗi, giới hạn mọi chờ bằng timeout, chỉ retry cái an toàn, và debug từng tầng.
Điều cốt lõi
Mạng không phải hàm luôn trả về — nó là kênh xác suất. Gắn handler error, đặt timeout connect / idle / request, retry công việc idempotent với backoff + jitter, reconnect client sống lâu có trần delay, và khi log không đủ, curl -v → nc / openssl s_client → ss / ping → tcpdump đến khi tầng đó nói sự thật. Tư duy đó tách server demo khỏi phần mềm sống sót qua đợt deploy chiều thứ Ba.