Features

Hooks

Rezo’s hooks system provides 26 hook points spanning the entire HTTP request lifecycle. Every hook is an array of functions, allowing multiple handlers per hook. Hooks are the foundation that interceptors are built on — they give you fine-grained control over every phase from DNS resolution to cookie processing.

Quick Start

import { Rezo } from 'rezo';

const client = new Rezo({
  hooks: {
    beforeRequest: [
      (config, context) => {
        console.log(`Request #${context.retryCount} to ${config.url}`);
      }
    ],
    afterResponse: [
      (response, config, context) => {
        console.log(`${response.status} in ${Date.now() - config.timing.startTime}ms`);
        return response;
      }
    ]
  }
});

Hook Execution Types

Each hook uses one of five execution models. Understanding these is key to writing correct hooks.

Void Hooks

Run each handler in sequence. Handlers do not return a value. If any handler throws, execution stops.

Runner: runVoidHooks() (async) / runVoidHooksSync() (sync)

Hooks: init (sync), beforeRequest, beforeRedirect, beforeRetry, afterHeaders, afterCookie

hooks: {
  beforeRequest: [
    async (config, context) => {
      // Mutate config in place, return nothing
      config.headers.set('X-Request-ID', crypto.randomUUID());
    }
  ]
}

Transform Hooks

Each handler receives the previous handler’s output and returns a transformed value. The final result is used by the caller.

Runner: runTransformHooks()

Hooks: afterResponse, beforeError, afterParse

hooks: {
  afterResponse: [
    (response, config, context) => {
      // Must return the response (original or modified)
      response.data = response.data.results;
      return response;
    }
  ]
}

Boolean Hooks

Each handler can return false to veto an action. If any handler returns false, the action is cancelled. Returning void or true allows it to proceed.

Runner: runBooleanHooks() (sync) / runBooleanHooksAsync()

Hooks: beforeCache, beforeCookie, beforeProxyDisable

hooks: {
  beforeCache: [
    (event) => {
      // Return false to prevent caching this response
      if (event.url.includes('/real-time/')) return false;
    }
  ]
}

Early Return Hooks

Handlers can return a value to short-circuit execution. The first non-void return value is used and remaining handlers are skipped. If all handlers return void, execution continues normally.

Runner: runEarlyReturnHooks()

Hooks: beforeRequest (can return a Response to bypass the adapter), beforeProxySelect (can return a ProxyInfo to override selection)

hooks: {
  beforeRequest: [
    (config, context) => {
      // Return a Response to skip the actual HTTP request
      if (config.url === '/health') {
        return new Response('OK', { status: 200 });
      }
      // Return void to let the request proceed normally
    }
  ]
}

Event Hooks

Fire-and-forget handlers. Errors are caught and logged but never propagate — event hooks cannot break the request flow.

Runner: runEventHooks()

Hooks: onSocket, onDns, onTls, onTimeout, onAbort

hooks: {
  onDns: [
    (event, config) => {
      // Errors here are caught and logged, never thrown
      metrics.recordDnsLookup(event.hostname, event.duration);
    }
  ]
}

All 26 Hooks

Core Lifecycle Hooks

init

Called synchronously during options initialization. Use to normalize or validate request options before anything else runs.

type InitHook = (plainOptions: Partial<RezoRequestConfig>, options: RezoRequestConfig) => void;
hooks: {
  init: [
    (plainOptions, options) => {
      // Force JSON response type for API requests
      if (options.url?.toString().includes('/api/')) {
        options.responseType = 'json';
      }
    }
  ]
}

Execution: sync void | Can throw: yes

beforeRequest

Called before the request is sent to the adapter. Can modify the config, add headers, or sign requests. Can return a Response to bypass the actual request entirely.

type BeforeRequestHook = (
  config: RezoConfig,
  context: BeforeRequestContext
) => void | Response | Promise<void | Response>;

The context provides:

  • retryCount — 0 for initial request, 1+ for retries
  • isRedirect — whether this is a redirect follow-up
  • redirectCount — number of redirects followed so far
  • startTime — timestamp when request processing started
hooks: {
  beforeRequest: [
    async (config, context) => {
      // Sign the request with HMAC
      const signature = await signRequest(config.method, config.url, config.data);
      config.headers.set('X-Signature', signature);
    }
  ]
}

Execution: async early-return | Can throw: yes

beforeRedirect

Called before following an HTTP redirect. Use to inspect or modify redirect behavior, strip sensitive headers on cross-domain redirects, or log redirect chains.

type BeforeRedirectHook = (
  context: BeforeRedirectContext,
  config: RezoConfig,
  response: RezoResponse
) => void | Promise<void>;

The BeforeRedirectContext provides:

  • redirectUrl — the URL being redirected to
  • fromUrl — the URL being redirected from
  • status — the HTTP status code (301, 302, 303, 307, 308)
  • headers — response headers from the redirect response
  • sameDomain — whether the redirect stays on the same domain
  • method — HTTP method of the current request
  • body — the original request body
  • request — the full request configuration
  • redirectCount — number of redirects followed so far
  • timestamp — event timestamp
hooks: {
  beforeRedirect: [
    (context, config) => {
      // Strip auth header on cross-domain redirects
      if (!context.sameDomain) {
        config.headers.delete('Authorization');
      }
      console.log(`Redirect ${context.status}: ${context.fromUrl} -> ${context.redirectUrl}`);
    }
  ]
}

Execution: async void | Can throw: yes

beforeRetry

Called before a retry attempt. Use for custom backoff logic, logging, or to prepare the next attempt.

type BeforeRetryHook = (
  config: RezoConfig,
  error: RezoError,
  retryCount: number
) => void | Promise<void>;
hooks: {
  beforeRetry: [
    async (config, error, retryCount) => {
      console.log(`Retry ${retryCount}: ${error.code} - ${error.message}`);
      // Refresh auth token before retrying
      if (error.response?.status === 401) {
        const token = await refreshToken();
        config.headers.set('Authorization', `Bearer ${token}`);
      }
    }
  ]
}

Execution: async void | Can throw: yes

afterResponse

Called after the response is received. Use to transform responses, unwrap API envelopes, or trigger retries via context.retryWithMergedOptions. Must return the response.

type AfterResponseHook<T = any> = (
  response: RezoResponse<T>,
  config: RezoConfig,
  context: AfterResponseContext
) => RezoResponse<T> | Promise<RezoResponse<T>>;

The AfterResponseContext provides:

  • retryCount — current retry count
  • retryWithMergedOptions(options) — call to retry the request with merged options (throws internally to abort current flow)
hooks: {
  afterResponse: [
    (response, config, context) => {
      // Token refresh pattern
      if (response.status === 401 && context.retryCount === 0) {
        const newToken = refreshTokenSync();
        context.retryWithMergedOptions({
          headers: { Authorization: `Bearer ${newToken}` }
        });
      }
      return response;
    }
  ]
}

Execution: async transform | Can throw: yes

beforeError

Called before an error is thrown. Use to transform errors, add context, or return custom error subclasses. Must return an error object.

type BeforeErrorHook = (
  error: RezoError | Error
) => RezoError | Error | Promise<RezoError | Error>;
hooks: {
  beforeError: [
    (error) => {
      // Enrich error with request ID
      if ('config' in error && error.config?.requestId) {
        error.message = `[${error.config.requestId}] ${error.message}`;
      }
      return error;
    }
  ]
}

Execution: async transform | Can throw: yes


Caching Hooks

beforeCache

Called before caching a response. Return false to prevent caching. The CacheEvent provides parsed Cache-Control directives and a default isCacheable assessment.

type BeforeCacheHook = (event: CacheEvent) => boolean | void;

The CacheEvent provides:

  • status — HTTP status code
  • headers — response headers
  • url — the request URL
  • cacheKey — generated cache key
  • isCacheable — whether the response is cacheable by default rules
  • cacheControl — parsed Cache-Control directives (maxAge, sMaxAge, noCache, noStore, mustRevalidate, private, public)
hooks: {
  beforeCache: [
    (event) => {
      // Never cache user-specific data
      if (event.url.includes('/me') || event.cacheControl?.private) {
        return false;
      }
    }
  ]
}

Execution: sync boolean | Can throw: no (false = veto)


Response Processing Hooks

afterHeaders

Called when response headers are received but before the body is read. Use to inspect headers, abort early, or prepare for response processing.

type AfterHeadersHook = (
  event: HeadersReceivedEvent,
  config: RezoConfig
) => void | Promise<void>;

The HeadersReceivedEvent provides:

  • status, statusText — HTTP status
  • headers — response headers
  • contentType, contentLength — parsed header values
  • ttfb — time to first byte in milliseconds
  • timestamp — event timestamp
hooks: {
  afterHeaders: [
    (event, config) => {
      console.log(`TTFB: ${event.ttfb}ms, Content-Type: ${event.contentType}`);
      if (event.contentLength && event.contentLength > 100_000_000) {
        throw new Error('Response too large');
      }
    }
  ]
}

Execution: async void | Can throw: yes

afterParse

Called after the response body is parsed. Use to transform parsed data or validate the response structure. Must return the (possibly transformed) data.

type AfterParseHook<T = any> = (
  event: ParseCompleteEvent<T>,
  config: RezoConfig
) => T | Promise<T>;

The ParseCompleteEvent provides:

  • data — the parsed data
  • rawData — original raw data before parsing
  • contentType — Content-Type that triggered parsing
  • parseDuration — how long parsing took in milliseconds
  • timestamp — event timestamp
hooks: {
  afterParse: [
    (event, config) => {
      // Validate JSON schema
      if (!isValidSchema(event.data)) {
        throw new Error('Invalid response schema');
      }
      // Transform: strip metadata wrapper
      return event.data.payload ?? event.data;
    }
  ]
}

Execution: async transform | Can throw: yes


beforeCookie

Called before a cookie is set. Return false to reject the cookie. Can also modify cookie properties.

type BeforeCookieHook = (
  event: CookieEvent,
  config: RezoConfig
) => boolean | void | Promise<boolean | void>;

The CookieEvent provides:

  • cookie — the Cookie object being set
  • source — where it came from: 'response', 'request', or 'manual'
  • url — URL context
  • isValid — whether the cookie passes validation
  • validationErrors — array of validation error strings (if any)
hooks: {
  beforeCookie: [
    (event, config) => {
      // Block third-party tracking cookies
      if (event.cookie.name.startsWith('_ga') || event.cookie.name.startsWith('_fb')) {
        return false;
      }
    }
  ]
}

Execution: async boolean | Can throw: no (false = veto)

afterCookie

Called after cookies are processed. Use for cookie logging or analytics.

type AfterCookieHook = (
  cookies: Cookie[],
  config: RezoConfig
) => void | Promise<void>;
hooks: {
  afterCookie: [
    (cookies, config) => {
      for (const cookie of cookies) {
        console.log(`Cookie set: ${cookie.name}=${cookie.value} (domain: ${cookie.domain})`);
      }
    }
  ]
}

Execution: async void | Can throw: yes


Low-Level Event Hooks

These hooks fire during network operations and use fire-and-forget semantics. Errors are caught and logged but never propagate.

onSocket

Called on socket lifecycle events (connect, close, drain, error, timeout, end).

type OnSocketHook = (event: SocketEvent, socket: Socket | TLSSocket) => void;

The SocketEvent provides:

  • type'connect', 'close', 'drain', 'error', 'timeout', 'end'
  • localAddress, localPort, remoteAddress, remotePort — connection endpoints
  • bytesWritten, bytesRead — transfer stats
  • error — error object if applicable
  • timestamp — event timestamp
hooks: {
  onSocket: [
    (event, socket) => {
      if (event.type === 'connect') {
        console.log(`Connected to ${event.remoteAddress}:${event.remotePort}`);
      }
    }
  ]
}

Execution: sync event (fire-and-forget)

onDns

Called when DNS lookup completes.

type OnDnsHook = (event: DnsLookupEvent, config: RezoConfig) => void;

The DnsLookupEvent provides:

  • hostname — hostname being resolved
  • address — resolved IP address
  • family — address family (4 for IPv4, 6 for IPv6)
  • duration — DNS lookup duration in milliseconds
  • timestamp — event timestamp
hooks: {
  onDns: [
    (event) => {
      metrics.histogram('dns_lookup_ms', event.duration, { hostname: event.hostname });
    }
  ]
}

Execution: sync event (fire-and-forget)

onTls

Called when TLS handshake completes.

type OnTlsHook = (event: TlsHandshakeEvent, config: RezoConfig) => void;

The TlsHandshakeEvent provides:

  • protocol — TLS version (e.g., 'TLSv1.3')
  • cipher — cipher suite used
  • authorized — whether certificate is authorized
  • authorizationError — authorization error if any
  • certificate — server certificate info (subject, issuer, validity dates, fingerprint)
  • duration — handshake duration in milliseconds
  • timestamp — event timestamp
hooks: {
  onTls: [
    (event) => {
      if (!event.authorized) {
        console.warn(`TLS warning: ${event.authorizationError}`);
      }
      console.log(`TLS ${event.protocol} using ${event.cipher} (${event.duration}ms)`);
    }
  ]
}

Execution: sync event (fire-and-forget)

onTimeout

Called when a timeout occurs.

type OnTimeoutHook = (event: TimeoutEvent, config: RezoConfig) => void;

The TimeoutEvent provides:

  • type'connect', 'request', 'response', 'socket', 'lookup'
  • timeout — configured timeout value in milliseconds
  • elapsed — elapsed time before timeout in milliseconds
  • url — URL being requested
  • timestamp — event timestamp
hooks: {
  onTimeout: [
    (event) => {
      alerting.send(`Timeout: ${event.type} for ${event.url} after ${event.elapsed}ms`);
    }
  ]
}

Execution: sync event (fire-and-forget)

onAbort

Called when a request is aborted.

type OnAbortHook = (event: AbortEvent, config: RezoConfig) => void;

The AbortEvent provides:

  • reason'user', 'timeout', 'signal', 'error'
  • message — abort message
  • url — URL being requested
  • elapsed — elapsed time before abort in milliseconds
  • timestamp — event timestamp
hooks: {
  onAbort: [
    (event) => {
      console.log(`Request aborted (${event.reason}): ${event.url} after ${event.elapsed}ms`);
    }
  ]
}

Execution: sync event (fire-and-forget)


Rate Limit Hook

onRateLimitWait

Called when the client is about to wait due to rate limiting. Informational only — you cannot abort the wait from this hook.

type OnRateLimitWaitHook = (
  event: RateLimitWaitEvent,
  config: RezoConfig
) => void | Promise<void>;

The RateLimitWaitEvent provides:

  • status — HTTP status code that triggered the wait (e.g., 429)
  • waitTime — time to wait in milliseconds
  • attempt — current wait attempt number (1-indexed)
  • maxAttempts — maximum wait attempts configured
  • source — where the wait time was extracted from: 'header', 'body', 'function', 'default'
  • sourcePath — the header or body path used (if applicable)
  • url, method — request details
  • timestamp — event timestamp
hooks: {
  onRateLimitWait: [
    (event) => {
      console.log(
        `Rate limited (${event.status}): waiting ${event.waitTime}ms ` +
        `(attempt ${event.attempt}/${event.maxAttempts}, source: ${event.source})`
      );
    }
  ]
}

Execution: async void | Can throw: errors are caught in debug mode


Proxy Hooks

All proxy hooks require a proxyManager to be configured on the Rezo instance. They provide visibility into proxy selection, rotation, errors, and pool health.

beforeProxySelect

Called before a proxy is selected. Can return a specific ProxyInfo to override the selection algorithm.

type BeforeProxySelectHook = (
  context: BeforeProxySelectContext
) => ProxyInfo | void | Promise<ProxyInfo | void>;
hooks: {
  beforeProxySelect: [
    (context) => {
      // Force a specific proxy for certain domains
      if (context.url.includes('restricted-api.com')) {
        return { protocol: 'socks5', host: '10.0.0.1', port: 1080 };
      }
    }
  ]
}

Execution: async early-return

afterProxySelect

Called after a proxy is selected. Use for logging or analytics.

type AfterProxySelectHook = (context: AfterProxySelectContext) => void | Promise<void>;
hooks: {
  afterProxySelect: [
    (context) => {
      console.log(`Using proxy: ${context.proxy.host}:${context.proxy.port}`);
    }
  ]
}

Execution: async void

beforeProxyError

Called before a proxy error is processed.

type BeforeProxyErrorHook = (context: BeforeProxyErrorContext) => void | Promise<void>;

Execution: async void

afterProxyError

Called after a proxy error is processed. Use for error logging or fallback logic.

type AfterProxyErrorHook = (context: AfterProxyErrorContext) => void | Promise<void>;
hooks: {
  afterProxyError: [
    (context) => {
      console.error(`Proxy ${context.proxy.host} failed: ${context.error.message}`);
    }
  ]
}

Execution: async void

beforeProxyDisable

Called before a proxy is disabled. Return false to prevent disabling.

type BeforeProxyDisableHook = (
  context: BeforeProxyDisableContext
) => boolean | void | Promise<boolean | void>;
hooks: {
  beforeProxyDisable: [
    (context) => {
      // Keep premium proxies alive even after errors
      if (context.proxy.host.includes('premium')) {
        return false; // Prevent disabling
      }
    }
  ]
}

Execution: async boolean

afterProxyDisable

Called after a proxy is disabled. Use for notifications or logging.

type AfterProxyDisableHook = (context: AfterProxyDisableContext) => void | Promise<void>;

Execution: async void

afterProxyRotate

Called when proxy rotation occurs.

type AfterProxyRotateHook = (context: AfterProxyRotateContext) => void | Promise<void>;
hooks: {
  afterProxyRotate: [
    (context) => {
      console.log(`Proxy rotated: ${context.previous?.host} -> ${context.current.host}`);
    }
  ]
}

Execution: async void

afterProxyEnable

Called when a previously disabled proxy is re-enabled.

type AfterProxyEnableHook = (context: AfterProxyEnableContext) => void | Promise<void>;

Execution: async void

onNoProxiesAvailable

Called when no proxies are available and an error is about to be thrown. Use for alerting, triggering external proxy pool refresh, or recording statistics about proxy pool health.

type OnNoProxiesAvailableHook = (
  context: OnNoProxiesAvailableContext
) => void | Promise<void>;
hooks: {
  onNoProxiesAvailable: [
    async (context) => {
      await alerting.critical('All proxies exhausted', {
        totalProxies: context.totalProxies,
        disabledProxies: context.disabledProxies
      });
    }
  ]
}

Execution: async void


Utility Functions

createDefaultHooks()

Creates an empty hooks object with all 26 arrays initialized. Called internally by the Rezo constructor.

import { createDefaultHooks } from 'rezo';

const hooks = createDefaultHooks();
// { init: [], beforeRequest: [], beforeRedirect: [], ... }

mergeHooks(base, overrides)

Merges two hooks objects by concatenating arrays. Base hooks run first, then overrides are appended.

import { createDefaultHooks, mergeHooks } from 'rezo';

const base = createDefaultHooks();
base.beforeRequest.push(loggingHook);

const merged = mergeHooks(base, {
  beforeRequest: [authHook],
  afterResponse: [transformHook]
});
// beforeRequest: [loggingHook, authHook]
// afterResponse: [transformHook]

This is exactly what the Rezo constructor does — it creates default hooks and merges any hooks from your config:

// Internal: this.hooks = mergeHooks(createDefaultHooks(), config?.hooks);

Hooks at Instance vs Request Level

Hooks set on the Rezo instance apply to all requests:

const client = new Rezo({
  hooks: {
    beforeRequest: [globalLogger],
    afterResponse: [globalTransform]
  }
});

The hooks live on client.hooks and can be modified at any time:

// Add a hook after construction
client.hooks.onDns.push((event) => {
  dnsMetrics.record(event);
});

For per-request hooks, pass them through the hooks on the config and the hook system’s merging behavior ensures both instance-level and request-level hooks execute.