npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@martinj/fetchx

v2.0.0

Published

fetch with batteries

Readme

@martinj/fetchx

fetch with extras

Installation

npm install @martinj/fetchx

Usage

import fetchx from '@martinj/fetchx';

// Basic GET request
const response = await fetchx('https://api.example.com/data');

// GET request with JSON response
const data = await fetchx('https://api.example.com/data', { json: true });

// POST request with JSON body
const result = await fetchx('https://api.example.com/create', {
  method: 'POST',
  jsonBody: { name: 'John' }
});

// Using search parameters
// Note this use URLSearchParams so it doesn't support nested objects as `qs` does
const searchResult = await fetchx('https://api.example.com/search', {
  searchParams: {
    q: 'search term',
    page: '1'
  }
});

// Creating an instance with default options
const api = fetchx.extend({
  prefixUrl: 'https://api.example.com',
  headers: {
    'Authorization': 'Bearer token'
  },
  json: true
});

// Using the configured instance
const userData = await api('/users/123');

Use cases

Service-to-service resilience (timeout + retry)

const res = await fetchx('https://service.internal/health', {
  timeout: 1500,
  retry: {retries: 2, factor: 1, minTimeout: 100, statusCodes: [503, 504]}
});

timeout applies to each request attempt. Retry delays and later attempts get their own timeout budget.

Set factor: 1 for constant backoff. The default factor: 2 uses exponential backoff.

Total operation deadline

await fetchx('https://api.example.com/data', {
  timeout: 1500,
  signal: AbortSignal.timeout(5000),
  retry: {retries: 2, minTimeout: 100}
});

Use signal for a global deadline or cancellation shared across retries. If both timeout and signal are provided, whichever aborts first cancels the current attempt.

The initial signal remains the operation-level cancellation signal for retry delays and future attempts.

If a hook replaces opts.signal, that replacement only affects request attempts:

  • in beforeRequest, it replaces the first fetch attempt's internal timeout signal
  • in afterResponse, it affects later fetch attempts, but those attempts still honor timeout if configured
  • it does not retarget the retry controller or backoff cancellation once the operation has started

Rate-limit handling (Retry-After)

await fetchx('https://api.example.com/limit', {
  retry: {retries: 3, minTimeout: 0, statusCodes: [429], maxRetryAfter: 10_000}
});

Cookie-backed session

import {CookieJar} from 'tough-cookie';

const cookieJar = new CookieJar();
const res = await fetchx('https://example.com/me', {cookieJar});

Request signing / auth header

const client = fetchx.extend({
  async beforeRequest(url, opts) {
    opts.headers.set('authorization', `Bearer ${process.env.TOKEN}`);
    return {url, opts};
  }
});

await client('https://api.example.com/secure');

Options

The module accepts all standard fetch options plus these additional features:

Basic Options

  • json: boolean - Automatically parse response as JSON
  • throwOnHttpError: boolean - Throw HttpError for non-2xx responses (default: true)
  • jsonBody: unknown - Automatically JSON.stringify request body and set JSON headers
  • timeout: number - Per-request-attempt timeout in milliseconds
  • prefixUrl: string - Base URL to prepend to all request URLs
  • searchParams: string | URLSearchParams | Record<string, string> | string[][] - Query parameters to append to URL, accepts same types as URLSearchParams
  • signal: AbortSignal - Cancels the whole operation, including retry delays and future attempts

Retry Options

{
  retry: {
    retries: number;           // Number of retry attempts (default: 2)
    factor: number;            // Backoff multiplier (default: 2, use 1 for constant delays)
    minTimeout: number;        // Minimum time between retries in ms (default: 50)
    maxRetryAfter: number;     // Maximum retry-after time to respect in ms
    statusCodes: number[];     // Status codes to retry (default: [408, 413, 429, 500, 502, 503, 504, 521, 522, 524])
    networkErrors: boolean;    // Whether to retry on network errors (default: true)
    onFailedAttempt: (context: {error: Error; attemptNumber: number; retriesLeft: number;}) => void | Promise<void> | undefined

    /**
     * Should retry will only be called for non HTTPError
     * The exception being if networkErrors is true it will not be called with network related errors
     */
    shouldRetry: (context: {error: Error; attemptNumber: number; retriesLeft: number;}) => boolean | Promise<boolean>
  }
}

Advanced Options

  • cookieJar: Cookie jar instance for handling cookies across requests
  • beforeRequest: Hook function called before the request is made
  • afterResponse: Hook function called after receiving the response

Hook semantics

beforeRequest receives pre-normalized request options:

  • headers is always a mutable Headers instance
  • beforeRequest can change high-level fields like searchParams, jsonBody, cookieJar, signal, and headers
  • those values are normalized after the hook returns
  • the initial request signal remains the whole-operation cancellation signal for retry delays and future attempts
  • replacing opts.signal in beforeRequest replaces the first fetch attempt's internal timeout signal, but does not redefine the operation-level retry controller
  • when opts.searchParams is present, it is the source of truth for the final query string
  • direct edits to url.searchParams may be overwritten by later searchParams normalization
  • if a hook wants full control of the URL query, delete opts.searchParams first and then update url.searchParams
  • when opts.cookieJar is present, it is the source of truth for request cookies
  • a Cookie header set in beforeRequest may be overwritten by later cookieJar normalization
  • if a hook wants full control of the outgoing Cookie header, delete opts.cookieJar first and then set opts.headers.set('cookie', ...)
  • deleting opts.cookieJar is all-or-nothing for that request path: it also disables response cookie persistence

afterResponse receives normalized retry state:

  • headers is a mutable Headers instance
  • searchParams has already been applied to url
  • jsonBody has already been serialized into body
  • afterResponse can mutate request options for later retry attempts
  • replacing opts.signal in afterResponse affects later fetch attempts, but those attempts still honor timeout if configured and retry delays / whole-operation cancellation still use the initial signal
  • retry policy and throwOnHttpError are fixed when the request starts, so changing them in afterResponse has no effect

For afterResponse, some original input fields are already consumed:

  • changing opts.searchParams has no effect; update url.searchParams instead
  • changing opts.jsonBody has no effect; update opts.body instead

Retry-time hook mutations are preserved for later attempts. This includes:

  • mutating or replacing opts.headers
  • replacing opts.signal for later fetch attempts
  • reassigning opts.cookieJar
  • deleting options like opts.afterResponse to disable them on retries

Hooks Example

const client = fetchx.extend({
  beforeRequest: async (url, opts) => {
    // High-level fields are still mutable here
    opts.searchParams = {...opts.searchParams, trace: '1'};
    opts.jsonBody = {signed: true};
    opts.headers.set('authorization', 'Bearer token');
    return { url, opts };
  },
  afterResponse: async (response, url, opts) => {
    // Mutations here affect later retry attempts
    return response;
  }
});

If you want to modify the URL query directly instead of using opts.searchParams, remove searchParams first:

const client = fetchx.extend({
  beforeRequest: async (url, opts) => {
    delete opts.searchParams;
    url.searchParams.set('trace', '1');
    return {url, opts};
  }
});

If you want to control the outgoing Cookie header directly instead of using cookieJar, remove cookieJar first:

const client = fetchx.extend({
  beforeRequest: async (url, opts) => {
    delete opts.cookieJar;
    opts.headers.set('cookie', 'session=impersonated');
    return {url, opts};
  }
});

Custom Retry Logic with HttpError

You can throw HttpError with isRetryable: true from the afterResponse hook to implement custom retry logic based on response content or specific conditions:

import fetchx, { HttpError } from '@martinj/fetchx';

const client = fetchx.extend({
  retry: {
    retries: 3,
    minTimeout: 1000
  },
  afterResponse: async (response, url, opts) => {
    // Retry on specific response conditions
    if (response.ok) {
      const data = await response.json();

      // Custom retry logic based on response body
      if (data.status === 'processing' || data.requiresRetry) {
        throw new HttpError(response, 'Resource not ready, retrying...', {
          isRetryable: true,
          jsonBody: data
        });
      }

      // Return a new Response with the parsed data
      return new Response(JSON.stringify(data), {
        status: response.status,
        headers: response.headers
      });
    }

    return response;
  }
});

// The request will automatically retry if the response indicates processing
const result = await client('https://api.example.com/async-job', { json: true });

This is particularly useful for:

  • Polling async operations until complete
  • Retrying on specific error codes in the response body
  • Implementing custom backoff strategies based on response headers
  • Handling rate limits with custom logic

Note: The jsonBody property on HttpError allows you to access the parsed response body in error handlers without consuming the response stream again.

TypeScript

Return type depends on json and throwOnHttpError:

  • json: true (or extend({json: true})) + throwOnHttpError: true (default) => Promise<T>
  • json: true + throwOnHttpError: false => Promise<T | Response>
  • otherwise => Promise<Response>
// json: true => typed JSON
const user = await fetchx<User>('https://api.example.com/users/1', {json: true});

// no json => Response
const res = await fetchx('https://api.example.com/users/1');

// extend default json, override per call
const api = fetchx.extend({json: true});
const a = await api<User>('/users/1'); // Promise<User>
const b = await api('/users/1', {json: false}); // Promise<Response>
const c = await api('/users/1', {throwOnHttpError: false}); // Promise<User | Response>

Error Handling

The module throws HttpError for non-2xx responses:

try {
  await fetchx('https://api.example.com/data');
} catch (error) {
  if (error instanceof HttpError) {
    console.log(error.statusCode);  // HTTP status code
    console.log(error.response);    // Original Response object
  }
}

To handle non-2xx responses without exceptions, set throwOnHttpError: false:

const res = await fetchx('https://example.com', {
  redirect: 'manual',
  throwOnHttpError: false
});

if (res.status >= 300 && res.status < 400) {
  console.log(res.headers.get('location'));
}

Note: When json: true and throwOnHttpError: false, non-2xx responses return a raw Response (not parsed JSON).

Runtime

  • Node.js 22+ (global fetch / Response)
  • Works in Bun (uses standard fetch APIs)