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 = rezo.create({
  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

import type { StagedTimeoutConfig } from 'rezo';

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.

How Phase Timers Work Internally

The HTTP adapter creates an internal phase-timer manager for each request and drives it through the request lifecycle:

  1. Request starts: connect and total timers begin
  2. Socket connects: connect is cleared, headers timer starts
  3. Headers received: headers is cleared, body timer starts
  4. Response complete: all remaining timers are cancelled
  5. Timeout fires: the socket and request are destroyed, the abort controller is signaled

This is plumbing — you do not need to wire it up yourself. Pass timeout: { connect, headers, body, total } and the adapter handles it.

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;
  }
}

How a Number Becomes Staged Timeouts

When you pass timeout: 30000, the adapter normalizes it internally to:

{ connect: 10000, headers: 30000, total: 30000 }

The connect phase is capped at 10 s and headers at 30 s — so a very long total never lets initial connection setup hang for the full duration. Pass an object form (see above) when you need different caps.

Practical Examples

Fast API Client

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

const api = rezo.create({
  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 = rezo.create({ 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 = rezo.create({
  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);