惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - 司徒正美
大猫的无限游戏
大猫的无限游戏
Scott Helme
Scott Helme
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
S
Secure Thoughts
Google DeepMind News
Google DeepMind News
博客园_首页
Hacker News: Ask HN
Hacker News: Ask HN
量子位
Jina AI
Jina AI
I
InfoQ
V
V2EX
Martin Fowler
Martin Fowler
Y
Y Combinator Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
人人都是产品经理
人人都是产品经理
B
Blog
IT之家
IT之家
云风的 BLOG
云风的 BLOG
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - Franky
博客园 - 【当耐特】
N
Netflix TechBlog - Medium
Cloudbric
Cloudbric
H
Heimdal Security Blog
TaoSecurity Blog
TaoSecurity Blog
S
Security @ Cisco Blogs
U
Unit 42
Project Zero
Project Zero
Webroot Blog
Webroot Blog
The Register - Security
The Register - Security
N
News | PayPal Newsroom
Microsoft Security Blog
Microsoft Security Blog
H
Help Net Security
Forbes - Security
Forbes - Security
宝玉的分享
宝玉的分享
Last Week in AI
Last Week in AI
C
Check Point Blog
博客园 - 聂微东
M
MIT News - Artificial intelligence
有赞技术团队
有赞技术团队
D
DataBreaches.Net
Cyberwarzone
Cyberwarzone
N
News and Events Feed by Topic
N
News and Events Feed by Topic
Simon Willison's Weblog
Simon Willison's Weblog
J
Java Code Geeks
G
Google Developers Blog
GbyAI
GbyAI
T
Threatpost

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
Node.js Native Fetch in Production: Timeouts, Streaming, and Error Handling Patterns
The Practica · 2026-06-23 · via The Practical Developer

Your Node.js service calls three external APIs, two internal microservices, and a database proxy. Every call goes through fetch(), the shiny new built-in that replaced node-fetch and axios in your stack.

Then production breaks.

One external API takes 90 seconds to respond because their endpoint is busted. Your service holds 512 concurrent connections open, waiting. Connection pool exhaustion cascades to every downstream call. All your endpoints return 502s. The incident postmortem says “add timeouts.”

You add a timeout. Now requests that fail throw an AbortError. But you do not catch it differently from a network error, and you do not catch a 500 status code at all because fetch() only rejects on network failures. Your error monitoring dashboard lights up with TypeError: fetch failed and AbortError and your catch block handles all of them the same way: log and retry. The retries hit the same timeout. You have amplified the outage.

The built-in fetch() is not broken. It is just raw. It gives you the primitives — AbortSignal, Response.body as a ReadableStream, Response.ok — but it does not compose them into production-safe patterns. That is your job.

Here is how to build a zero-dependency HTTP client that handles timeouts, streaming, retries, response size limits, and error classification correctly.

The three lies of fetch()

The fetch() API looks simple. You call it, you get a response. Three properties of the API are deceptive, and each one will burn you in production if you do not handle it explicitly.

Lie 1: fetch() rejects on network errors only

HTTP 4xx and 5xx responses are not exceptions. They are valid HTTP responses. A 500 error resolves successfully with response.ok === false.

Most team code looks like this:

const response = await fetch('https://api.example.com/data');
const data = await response.json();
// If the server returned 500, data might be HTML or an error page
// response.json() will throw a JSON parse error, not an HTTP error

The fix is trivial but widely missed: check response.ok before reading the body.

Lie 2: Response.json() has no timeout

You set a timeout on fetch() with AbortSignal.timeout(). You think you are safe. But response.json() also reads the body, and if the server starts sending a response but dribbles the bytes at 1 KB/s, the initial fetch completes (headers arrived) but response.json() blocks forever.

The AbortSignal you passed to fetch() is already consumed by the time you call .json(). You need a fresh signal for the body read.

Lie 3: Response.body is a ReadableStream, but you cannot always read it twice

You call response.text() in a logging middleware and then try response.json() in the handler. The body is already consumed. The second call throws a TypeError: body is already consumed.

This is correct per the Fetch spec, but it means you must decide upfront how you will consume the body. If you need to log the raw body and also parse it, clone the response or buffer the stream.

The production client

Here is a self-contained HTTP client that addresses every lie above. It uses zero external dependencies — just what Node.js ships.

// http-client.ts
import { setTimeout as sleep } from 'node:timers/promises';

export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export interface HttpClientOptions {
  baseUrl?: string;
  timeout?: number;           // Total request+body timeout in ms (default: 10_000)
  retries?: number;           // Number of retry attempts (default: 2)
  retryDelay?: number;        // Base delay in ms (default: 500, exponential with jitter)
  maxBodyBytes?: number;      // Maximum response body size (default: 10 MB)
  headers?: Record<string, string>;
}

export class HttpError extends Error {
  constructor(
    public readonly status: number,
    public readonly statusText: string,
    public readonly body: unknown,
    public readonly url: string,
    public readonly method: string
  ) {
    super(`HTTP ${status} ${statusText} for ${method} ${url}`);
    this.name = 'HttpError';
  }

  get isClientError(): boolean {
    return this.status >= 400 && this.status < 500;
  }

  get isServerError(): boolean {
    return this.status >= 500;
  }

  get isRetryable(): boolean {
    // 429 (rate limit), 5xx (server errors), and connection errors are retryable
    return this.isServerError || this.status === 429;
  }
}

export class NetworkError extends Error {
  constructor(
    message: string,
    public readonly url: string,
    public readonly method: string,
    public readonly cause: unknown
  ) {
    super(message);
    this.name = 'NetworkError';
  }

  get isRetryable(): boolean {
    return true;
  }
}

export class TimeoutError extends Error {
  constructor(
    public readonly url: string,
    public readonly method: string,
    public readonly ms: number
  ) {
    super(`Request to ${url} timed out after ${ms}ms`);
    this.name = 'TimeoutError';
  }

  get isRetryable(): boolean {
    return true;
  }
}

export class BodyTooLargeError extends Error {
  constructor(
    public readonly url: string,
    public readonly maxBytes: number,
    public readonly actualBytes: number
  ) {
    super(`Response body from ${url} exceeded ${maxBytes} bytes (was ${actualBytes})`);
    this.name = 'BodyTooLargeError';
  }

  get isRetryable(): boolean {
    return false;
  }
}

interface RetryConfig {
  attempt: number;
  maxRetries: number;
  baseDelay: number;
}

function calculateDelay({ attempt, maxRetries, baseDelay }: RetryConfig): number {
  if (attempt >= maxRetries) return 0;
  const exponential = Math.min(baseDelay * Math.pow(2, attempt), 10_000);
  // Full-jitter: random between 0 and exponential
  return Math.random() * exponential;
}

async function readBody(
  response: Response,
  maxBytes: number
): Promise<Buffer> {
  const reader = response.body?.getReader();
  if (!reader) return Buffer.alloc(0);

  const chunks: Uint8Array[] = [];
  let totalBytes = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    totalBytes += value.byteLength;
    if (totalBytes > maxBytes) {
      await reader.cancel();
      throw new BodyTooLargeError(
        response.url,
        maxBytes,
        totalBytes
      );
    }

    chunks.push(value);
  }

  return Buffer.concat(chunks);
}

export type HttpClientResponse<T = unknown> = {
  data: T;
  status: number;
  statusText: string;
  headers: Headers;
  url: string;
};

export class HttpClient {
  private readonly baseUrl: string;
  private readonly defaultTimeout: number;
  private readonly maxRetries: number;
  private readonly retryDelay: number;
  private readonly maxBodyBytes: number;
  private readonly defaultHeaders: Record<string, string>;

  constructor(options: HttpClientOptions = {}) {
    this.baseUrl = options.baseUrl ?? '';
    this.defaultTimeout = options.timeout ?? 10_000;
    this.maxRetries = options.retries ?? 2;
    this.retryDelay = options.retryDelay ?? 500;
    this.maxBodyBytes = options.maxBodyBytes ?? 10 * 1024 * 1024;
    this.defaultHeaders = {
      'accept': 'application/json',
      'user-agent': 'production-http-client/1.0',
      ...options.headers,
    };
  }

  async request<T = unknown>(
    method: HttpMethod,
    path: string,
    options?: {
      body?: unknown;
      headers?: Record<string, string>;
      timeout?: number;
      query?: Record<string, string | string[] | undefined>;
    }
  ): Promise<HttpClientResponse<T>> {
    const url = this.buildUrl(path, options?.query);
    const timeout = options?.timeout ?? this.defaultTimeout;

    let lastError: Error | null = null;

    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      try {
        return await this.executeRequest<T>(url, method, timeout, options);
      } catch (err) {
        lastError = err as Error;

        const shouldRetry = err instanceof HttpError
          ? err.isRetryable
          : err instanceof NetworkError || err instanceof TimeoutError;

        if (!shouldRetry || attempt >= this.maxRetries) {
          throw err;
        }

        const delay = calculateDelay({
          attempt,
          maxRetries: this.maxRetries,
          baseDelay: this.retryDelay,
        });

        await sleep(delay);
      }
    }

    throw lastError;
  }

  private async executeRequest<T>(
    url: string,
    method: HttpMethod,
    timeout: number,
    options?: {
      body?: unknown;
      headers?: Record<string, string>;
    }
  ): Promise<HttpClientResponse<T>> {
    const abortController = new AbortController();
    // Timeout timer that fires once for the whole operation
    const timeoutHandle = setTimeout(() => abortController.abort(), timeout);

    try {
      const headers: Record<string, string> = {
        ...this.defaultHeaders,
        ...options?.headers,
      };

      if (options?.body !== undefined) {
        headers['content-type'] = 'application/json';
      }

      let response: Response;
      try {
        response = await fetch(url, {
          method,
          headers,
          body: options?.body !== undefined
            ? JSON.stringify(options.body)
            : undefined,
          signal: abortController.signal,
        });
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          throw new TimeoutError(url, method, timeout);
        }
        throw new NetworkError(
          `Failed to connect to ${url}: ${(err as Error).message}`,
          url,
          method,
          err
        );
      }

      // Read the body with a size limit
      const rawBody = await readBody(response, this.maxBodyBytes);

      let data: T;
      const contentType = response.headers.get('content-type') ?? '';

      if (contentType.includes('application/json') && rawBody.length > 0) {
        data = JSON.parse(rawBody.toString('utf-8')) as T;
      } else {
        data = rawBody.toString('utf-8') as unknown as T;
      }

      if (!response.ok) {
        throw new HttpError(
          response.status,
          response.statusText,
          data,
          url,
          method
        );
      }

      return {
        data,
        status: response.status,
        statusText: response.statusText,
        headers: response.headers,
        url: response.url,
      };
    } finally {
      clearTimeout(timeoutHandle);
    }
  }

  private buildUrl(path: string, query?: Record<string, string | string[] | undefined>): string {
    const base = this.baseUrl ? `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}` : path;

    if (!query || Object.keys(query).length === 0) return base;

    const params = new URLSearchParams();
    for (const [key, value] of Object.entries(query)) {
      if (value === undefined) continue;
      if (Array.isArray(value)) {
        for (const v of value) {
          params.append(key, v);
        }
      } else {
        params.set(key, value);
      }
    }

    const qs = params.toString();
    return qs ? `${base}${base.includes('?') ? '&' : '?'}${qs}` : base;
  }

  // Convenience methods
  get<T = unknown>(path: string, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
    return this.request<T>('GET', path, options);
  }

  post<T = unknown>(path: string, body?: unknown, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
    return this.request<T>('POST', path, { ...options, body });
  }

  put<T = unknown>(path: string, body?: unknown, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
    return this.request<T>('PUT', path, { ...options, body });
  }

  patch<T = unknown>(path: string, body?: unknown, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
    return this.request<T>('PATCH', path, { ...options, body });
  }

  delete<T = unknown>(path: string, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
    return this.request<T>('DELETE', path, options);
  }
}

The executeRequest method fixes all three lies. It uses AbortController to enforce a total timeout that covers both the header receive and the body read phase. It catches AbortError from the signal and translates it into a typed TimeoutError. It reads the body as a stream with a byte limit so a misbehaving server cannot exhaust your memory. It checks response.ok before returning and throws a structured HttpError if the status code indicates failure.

Streaming responses without memory bloat

Some APIs return large collections as NDJSON (newline-delimited JSON) streams. Loading the entire response into memory defeats the purpose of streaming. Here is how to process a stream row by row using ReadableStream with the same timeout and size protections.

// stream-client.ts
import { createInterface } from 'node:readline';

export async function* streamLines(
  url: string,
  options: {
    timeout?: number;
    maxBytes?: number;
    signal?: AbortSignal;
  } = {}
): AsyncGenerator<string> {
  const { timeout = 30_000, maxBytes = 100 * 1024 * 1024 } = options;

  const abortController = new AbortController();
  const timeoutHandle = setTimeout(() => abortController.abort(), timeout);

  // Link external signal so the caller can cancel too
  if (options.signal) {
    options.signal.addEventListener('abort', () => abortController.abort());
  }

  try {
    const response = await fetch(url, {
      signal: abortController.signal,
    });

    if (!response.ok) {
      throw new HttpError(
        response.status,
        response.statusText,
        null,
        url,
        'GET'
      );
    }

    const reader = response.body?.getReader();
    if (!reader) return;

    let totalBytes = 0;

    // Wrap the ReadableStream into a Node.js Readable so we can use readline
    const stream = new ReadableStream({
      async pull(controller) {
        const { done, value } = await reader.read();
        if (done) {
          controller.close();
          return;
        }
        totalBytes += value.byteLength;
        if (totalBytes > maxBytes) {
          controller.error(new BodyTooLargeError(url, maxBytes, totalBytes));
          return;
        }
        controller.enqueue(value);
      },
    });

    const nodeStream = Readable.fromWeb(stream);
    const rl = createInterface({ input: nodeStream, crlfDelay: Infinity });

    for await (const line of rl) {
      yield line;
    }
  } finally {
    clearTimeout(timeoutHandle);
  }
}

This generator yields one line at a time. Processing 100,000 rows of NDJSON uses almost no heap because you never hold the full response body. The BodyTooLargeError and timeout guards still apply.

Testing the client

A production HTTP client needs tests that simulate every failure mode. Here is a test suite using Node’s built-in test runner (covered in another post on this site) and a test server.

// http-client.test.ts
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { HttpClient, HttpError, TimeoutError, BodyTooLargeError } from './http-client';

describe('HttpClient', () => {
  let server;
  let baseUrl: string;

  before(async () => {
    server = createServer((req, res) => {
      if (req.url === '/ok') {
        res.writeHead(200, { 'content-type': 'application/json' });
        res.end(JSON.stringify({ status: 'ok' }));
      } else if (req.url === '/slow') {
        // Hold the connection open past any reasonable timeout
        setTimeout(() => {
          res.writeHead(200);
          res.end('too late');
        }, 60_000);
      } else if (req.url === '/500') {
        res.writeHead(500, { 'content-type': 'application/json' });
        res.end(JSON.stringify({ error: 'internal error' }));
      } else if (req.url === '/429') {
        res.writeHead(429, { 'retry-after': '1' });
        res.end();
      } else if (req.url === '/big') {
        res.writeHead(200, { 'content-type': 'application/json' });
        res.end('x'.repeat(5 * 1024 * 1024)); // 5 MB
      } else if (req.url === '/empty') {
        res.writeHead(200);
        res.end();
      } else if (req.url === '/not-json') {
        res.writeHead(200, { 'content-type': 'text/plain' });
        res.end('plain text response');
      }
    });

    await new Promise<void>((resolve) => server.listen(0, resolve));
    const addr = server.address();
    baseUrl = `http://localhost:${addr.port}`;
  });

  after(() => server.close());

  it('makes a successful GET request', async () => {
    const client = new HttpClient({ baseUrl });
    const result = await client.get('/ok');
    assert.equal(result.status, 200);
    assert.deepEqual(result.data, { status: 'ok' });
  });

  it('throws HttpError on 500', async () => {
    const client = new HttpClient({ baseUrl });
    await assert.rejects(
      () => client.get('/500'),
      (err: HttpError) => {
        assert.equal(err.status, 500);
        assert.equal(err.isRetryable, true);
        return true;
      }
    );
  });

  it('throws HttpError on 429 but marks it retryable', async () => {
    const client = new HttpClient({ baseUrl });
    await assert.rejects(
      () => client.get('/429'),
      (err: HttpError) => {
        assert.equal(err.status, 429);
        assert.equal(err.isRetryable, true);
        return true;
      }
    );
  });

  it('times out on slow responses', async () => {
    const client = new HttpClient({ baseUrl, timeout: 500, retries: 0 });
    await assert.rejects(
      () => client.get('/slow'),
      (err: TimeoutError) => {
        assert.ok(err.message.includes('timed out'));
        assert.equal(err.isRetryable, true);
        return true;
      }
    );
  });

  it('limits response body size', async () => {
    const client = new HttpClient({ baseUrl, maxBodyBytes: 1024, retries: 0 });
    await assert.rejects(
      () => client.get('/big'),
      (err: BodyTooLargeError) => {
        assert.ok(err.message.includes('exceeded'));
        return true;
      }
    );
  });

  it('handles non-JSON responses', async () => {
    const client = new HttpClient({ baseUrl });
    const result = await client.get('/not-json');
    assert.equal(result.status, 200);
    assert.equal(result.data, 'plain text response');
  });

  it('handles empty response bodies', async () => {
    const client = new HttpClient({ baseUrl });
    const result = await client.get('/empty');
    assert.equal(result.status, 200);
    assert.equal(result.data, '');
  });

  it('retries on 500 and succeeds on third attempt', async () => {
    let attempts = 0;
    const retryServer = createServer((_req, res) => {
      attempts++;
      if (attempts < 3) {
        res.writeHead(500);
        res.end();
      } else {
        res.writeHead(200, { 'content-type': 'application/json' });
        res.end(JSON.stringify({ success: true }));
      }
    });

    await new Promise<void>((resolve) => retryServer.listen(0, resolve));
    const addr = retryServer.address();
    const url = `http://localhost:${addr.port}`;

    const client = new HttpClient({ baseUrl: url, retries: 3, retryDelay: 10 });
    const result = await client.get('/');
    assert.equal(result.data.success, true);
    assert.equal(attempts, 3);

    retryServer.close();
  });
});

The test server simulates every failure mode: slow connections, 5xx errors, rate limiting, oversized bodies, non-JSON responses, and empty responses. The suite verifies that the client classifies each error correctly and that retries work.

Integrating with your service

Here is how the client fits into a real service that calls a payment provider and a notification service.

// services/payment.ts
import { HttpClient, HttpError } from '../lib/http-client';

interface ChargeRequest {
  amount: number;
  currency: string;
  source: string;
  idempotencyKey: string;
}

interface ChargeResponse {
  id: string;
  status: 'succeeded' | 'failed';
  amount: number;
}

const paymentClient = new HttpClient({
  baseUrl: process.env.PAYMENT_API_URL,
  timeout: 5_000,       // Payments need fast feedback
  retries: 1,           // One retry on network error or 5xx
  retryDelay: 200,
  headers: {
    'authorization': `Bearer ${process.env.PAYMENT_API_KEY}`,
  },
});

export async function chargeCustomer(params: ChargeRequest): Promise<ChargeResponse> {
  try {
    const response = await paymentClient.post<ChargeResponse>('/charges', {
      ...params,
      idempotencyKey: params.idempotencyKey,
    });
    return response.data;
  } catch (err) {
    if (err instanceof HttpError && err.status === 402) {
      // Payment declined by the provider -- business logic, not a retry
      throw new PaymentDeclinedError(params.source);
    }
    if (err instanceof TimeoutError) {
      // Payment may or may not have succeeded. Check with idempotency.
      throw new PaymentAmbiguousError(params.idempotencyKey);
    }
    throw err;
  }
}
// services/notifications.ts
import { HttpClient } from '../lib/http-client';

const notifyClient = new HttpClient({
  baseUrl: process.env.NOTIFICATION_API_URL,
  timeout: 2_000,         // Notifications are fire-and-forget
  retries: 2,
  retryDelay: 100,
});

export async function sendWelcomeEmail(userId: string, email: string): Promise<void> {
  // Fire and forget: log failures but do not fail the request
  try {
    await notifyClient.post('/emails', {
      template: 'welcome',
      to: email,
      userId,
    });
  } catch {
    console.error(`Failed to send welcome email to user ${userId}`);
  }
}

Each downstream service gets its own client instance with per-service timeouts, retry counts, and headers. The payment client uses a short timeout and minimal retries because payment operations must fail fast. The notification client uses a longer retry budget because notifications are non-critical and benefit from best-effort delivery.

The practical takeaway

The built-in fetch() API is a solid primitive, but it is not production-ready out of the box. You need explicit handling for every failure mode: timeouts that cover the full request-and-body lifecycle, response size limits, typed errors that distinguish network failures from HTTP errors from timeouts, retry logic with jitter, and per-service configuration.

The HttpClient class above gives you all of that in about 200 lines with zero dependencies. Drop it into any Node.js 18+ project and you get typed, predictable HTTP calls that handle every failure mode a production service encounters.

Before your next API integration, run through this checklist:

  • Every fetch() call has a timeout that covers both header receive and body read.
  • HTTP 4xx and 5xx status codes are thrown as typed errors, not swallowed.
  • Response body reads have an explicit byte limit.
  • Retries use full-jitter backoff, not fixed delays.
  • Distinguishable error types (network, timeout, HTTP client, HTTP server, body too large) allow caller-specific handling.
  • Streaming responses enforce both a timeout and a byte limit.

Your downstream APIs will fail. Your HttpClient should handle the failure without taking down your service.


A note from Yojji

Building resilient service-to-service communication is a core engineering challenge that separates production-grade infrastructure from prototypes. Yojji’s engineering teams apply these same HTTP client patterns when integrating payment gateways, notification providers, and internal microservices for clients, ensuring that a single upstream failure does not cascade through the entire system. Yojji is an international custom software development company founded in 2016, specializing in the JavaScript ecosystem, cloud platforms, and full-cycle product delivery from discovery through deployment.