Features

Timeouts

Rezo supports both simple timeouts (a single number) and staged timeouts that apply different limits to each phase of the HTTP request. Staged timeouts let you fail fast on connection issues while allowing more time for slow response bodies.

Simple Timeout

Pass a number (milliseconds) to set a single timeout for the entire request:

import { Rezo } from 'rezo';

// 10 second timeout for the entire request
const { data } = await rezo.get('https://api.example.com/data', {
  timeout: 10000
});

// Instance-level default
const client = new Rezo({
  timeout: 5000
});

When a simple timeout is provided, Rezo internally converts it into staged timeouts using parseStagedTimeouts():

PhaseValue
connectmin(timeout, 10000) — capped at 10 seconds
headersmin(timeout, 30000) — capped at 30 seconds
totalThe full timeout value

So timeout: 60000 becomes { connect: 10000, headers: 30000, total: 60000 }.

Staged Timeouts

For fine-grained control, pass a StagedTimeoutConfig object with separate timeouts for each request phase:

const { data } = await rezo.get('https://api.example.com/large-file', {
  timeout: {
    connect: 5000,    // 5s to establish TCP connection
    headers: 10000,   // 10s to receive response headers
    body: 120000,     // 2min to receive the full body
    total: 150000     // 2.5min maximum total duration
  }
});

StagedTimeoutConfig

interface StagedTimeoutConfig {
  /** Timeout for TCP connection establishment (milliseconds) */
  connect?: number;
  /** Timeout for receiving response headers after connection (milliseconds) */
  headers?: number;
  /** Timeout for receiving the full response body (milliseconds) */
  body?: number;
  /** Maximum total duration for the entire request (milliseconds) */
  total?: number;
}

All phases are optional. Omit a phase to skip its timeout entirely.

Phases Explained

connect

The time allowed to establish a TCP connection to the server (and complete the TLS handshake for HTTPS). If the server is unreachable, firewalled, or slow to respond to SYN packets, this timeout fires.

timeout: { connect: 3000 } // Fail fast if server is unreachable

Error code: ETIMEDOUT Retryable: Yes — connection timeouts are almost always transient.

headers

The time allowed to receive the first response headers after the connection is established and the request is sent. Fires when the server accepts the connection but takes too long to start responding.

timeout: { headers: 15000 } // Server must start responding within 15s

Error code: ESOCKETTIMEDOUT Retryable: Yes — header timeouts usually indicate a temporarily overloaded server.

body

The time allowed to receive the complete response body after headers arrive. Useful for large downloads where you want a generous body transfer time but a strict initial response time.

timeout: {
  connect: 5000,
  headers: 10000,
  body: 300000 // 5 minutes for a large file download
}

Error code: ECONNRESET Retryable: No — a body timeout means partial data was received and the transfer stalled. The server state may have changed, so retrying could produce inconsistent results.

total

The absolute maximum duration for the entire request, from initiation to completion. Acts as a safety net that overrides all other phase timeouts.

timeout: { total: 60000 } // Never spend more than 60s on this request

Error code: ECONNRESET Retryable: No — the total timeout is a hard cap. The request may have been partially processed.

StagedTimeoutManager

The StagedTimeoutManager class manages phase timers internally. The HTTP adapter creates one per request and drives it through the lifecycle:

  1. Request starts: startPhase('connect') and startPhase('total') begin
  2. Socket connects: clearPhase('connect'), then startPhase('headers')
  3. Headers received: clearPhase('headers'), then startPhase('body')
  4. Response complete: clearAll() cancels all remaining timers
  5. Timeout fires: Socket and request are destroyed, AbortController is aborted
// Internal flow (for understanding, not direct usage):
const manager = new StagedTimeoutManager(
  { connect: 5000, headers: 10000, body: 60000, total: 90000 },
  config,
  requestConfig
);

manager.setSocket(socket);
manager.setRequest(req);
manager.setAbortController(controller);
manager.setTimeoutCallback((phase, elapsed) => {
  // Handle timeout
});

manager.startPhase('connect');
manager.startPhase('total');
// ... on socket connect:
manager.clearPhase('connect');
manager.startPhase('headers');
// ... on headers received:
manager.clearPhase('headers');
manager.startPhase('body');
// ... on response complete:
manager.clearAll();

Timeout Errors

Timeout errors are RezoError instances with additional properties:

try {
  await rezo.get(url, { timeout: { connect: 1000 } });
} catch (error) {
  if (error.code === 'ETIMEDOUT') {
    console.log(error.phase);      // 'connect'
    console.log(error.elapsed);    // actual milliseconds elapsed
    console.log(error.isRetryable); // true for connect/headers, false for body/total
    console.log(error.message);    // 'Connection timeout: Failed to establish TCP connection within 1000ms'
  }
}

Phase-Specific Error Messages

PhaseError CodeMessageRetryable
connectETIMEDOUTConnection timeout: Failed to establish TCP connection within XmsYes
headersESOCKETTIMEDOUTHeaders timeout: Server did not send response headers within XmsYes
bodyECONNRESETBody timeout: Response body transfer stalled for XmsNo
totalECONNRESETTotal timeout: Request exceeded maximum duration of XmsNo

Retryability

The isRetryable property is set on timeout errors to guide the retry system:

  • Connect and headers timeouts are retryable because the server likely never processed the request, making it safe to retry.
  • Body and total timeouts are not retryable because the server may have already processed the request and started sending data. Retrying could result in duplicate operations.

The retry system checks isRetryable when retryOnTimeout: true is configured. You can use it in custom retry conditions:

retry: {
  maxRetries: 3,
  condition: (error, attempt) => {
    // Only retry if the error is marked retryable
    return error.isRetryable === true;
  }
}

parseStagedTimeouts()

This utility function normalizes timeout configuration from either format into a StagedTimeoutConfig:

import { parseStagedTimeouts } from 'rezo';

// From a number
parseStagedTimeouts(30000);
// { connect: 10000, headers: 30000, total: 30000 }

// From an object (passed through as-is)
parseStagedTimeouts({ connect: 5000, headers: 10000, body: 60000 });
// { connect: 5000, headers: 10000, body: 60000 }

// From undefined
parseStagedTimeouts(undefined);
// {}

Practical Examples

Fast API Client

Short timeouts for a responsive API that should always reply quickly:

const api = new Rezo({
  timeout: {
    connect: 2000,
    headers: 5000,
    total: 10000
  },
  retry: {
    maxRetries: 2,
    retryOnTimeout: true,
    backoff: 'exponential'
  }
});

Large File Download

Generous body timeout for slow transfers, strict connection timeout:

const response = await rezo.get('https://cdn.example.com/large-file.zip', {
  timeout: {
    connect: 5000,
    headers: 10000,
    body: 600000,   // 10 minutes for the body
    total: 660000   // 11 minutes total
  }
});

Microservice Health Check

Ultra-fast timeout for health check endpoints:

async function isHealthy(serviceUrl: string): Promise<boolean> {
  try {
    await rezo.get(`${serviceUrl}/health`, {
      timeout: { connect: 1000, headers: 2000, total: 3000 }
    });
    return true;
  } catch {
    return false;
  }
}

Different Timeouts Per Environment

const timeout = process.env.NODE_ENV === 'production'
  ? { connect: 3000, headers: 10000, body: 60000, total: 90000 }
  : { connect: 1000, headers: 3000, total: 5000 }; // Strict in dev

const client = new Rezo({ timeout });

Interaction with Other Features

Retry System

When retries are enabled and a retryable timeout occurs, the request is automatically retried:

const { data } = await rezo.get(url, {
  timeout: { connect: 2000, headers: 5000 },
  retry: { maxRetries: 3, retryOnTimeout: true }
});
// If connect or headers timeout occurs, retries up to 3 times
// If body timeout occurs, does NOT retry (isRetryable = false)

onTimeout Hook

The onTimeout hook fires when any timeout occurs, providing visibility into timeout patterns:

const client = new Rezo({
  timeout: { connect: 5000, headers: 15000 },
  hooks: {
    onTimeout: [
      (event, config) => {
        metrics.increment('http.timeout', {
          phase: event.type,
          url: event.url,
          configuredMs: event.timeout,
          actualMs: event.elapsed
        });
      }
    ]
  }
});

AbortController

Staged timeouts work alongside AbortController. When a timeout fires, the abort controller is signaled, which cancels the request cleanly:

const controller = new AbortController();

// External abort and timeout racing
const { data } = await rezo.get(url, {
  signal: controller.signal,
  timeout: { connect: 5000, headers: 10000, total: 30000 }
});

// External abort takes priority if it fires first
setTimeout(() => controller.abort(), 2000);