Skip to content

Ali-Raza-Arain/fetch-smartly

version

fetch-smartly

A production-grade fetch wrapper that makes HTTP requests resilient, intelligent, and effortless.
Zero-dependency, isomorphic HTTP client with intelligent retry, circuit breaker, and offline queue for Node.js and browsers

license node typescript tests codecov downloads


The Problem

The native fetch API gives you no help when things go wrong. Servers return 503, rate limits hit 429, networks drop, timeouts expire — and fetch just throws a generic error. You end up writing the same retry logic, timeout management, and error classification in every project, or pulling in heavyweight libraries with dozens of dependencies.


Why fetch-smartly?

  • Zero dependencies — built entirely on native Web APIs, no supply chain risk
  • Intelligent retry — exponential backoff with jitter, Retry-After header respect, never retries 4xx client errors
  • Typed error hierarchyNetworkError, TimeoutError, HttpError, RateLimitError with instanceof support
  • Circuit breaker — automatic failure isolation (open/half-open/closed)
  • Request deduplication — concurrent identical GET/HEAD requests share one fetch
  • Offline queue — queue failed requests for replay with pluggable storage
  • Isomorphic — Node.js 18+, browsers, Cloudflare Workers, Deno, Bun
  • Strict TypeScript — no any, full type safety, JSDoc on every export

Table of Contents


How It Works

  Request ──► Timeout Guard ──► fetch() ──► Response Parser
     │              │               │               │
     │              │          on failure           │
     │              │               ▼               │
     │              │       Failure Analyzer        │
     │              │        (classify error)       │
     │              │               │               │
     │              │          retryable?           │
     │              │          ▼       ▼            │
     │              │        YES      NO ──► throw  │
     │              │         │                     │
     │              │    Backoff Manager            │
     │              │    (delay + jitter)           │
     │              │         │                     │
     │              └─── retry loop ◄──┘            │
     │                                              │
     └──────────── SmartFetchResponse ◄─────────────┘

Installation

npm install fetch-smartly

Quick Start

import { fetchWithRetry } from 'fetch-smartly';

const response = await fetchWithRetry({
  url: 'https://api.example.com/data',
  method: 'GET',
  timeout: 5000,
});

console.log(response.data);    // parsed JSON or text
console.log(response.status);  // 200
console.log(response.retries); // 0
console.log(response.duration); // 142 (ms)

Configuration Options

Option Type Default Description
url string required The URL to fetch
method HttpMethod 'GET' HTTP method
timeout number 10000 Request timeout in ms
retry.maxRetries number 3 Maximum retry attempts
retry.baseDelay number 1000 Base backoff delay in ms
retry.maxDelay number 30000 Maximum delay cap in ms
retry.backoffFactor number 2 Exponential multiplier
retry.jitter boolean true Randomize delay
retry.retryOn number[] [408,429,500,502,503,504] Status codes to retry
retry.retryOnNetworkError boolean true Retry on network failures
retry.shouldRetry function undefined Custom retry predicate
signal AbortSignal undefined User-provided abort signal
onRetry function undefined Callback before each retry
debug boolean false Verbose logging

All native RequestInit options (headers, body, credentials, etc.) are also supported.


Error Handling

All errors extend SmartFetchError and support instanceof checks:

import {
  fetchWithRetry,
  NetworkError,
  TimeoutError,
  HttpError,
  RateLimitError,
} from 'fetch-smartly';

try {
  await fetchWithRetry({ url: 'https://api.example.com/data' });
} catch (error) {
  if (error instanceof RateLimitError) {
    console.log('Rate limited. Retry after:', error.retryAfter, 'ms');
  } else if (error instanceof TimeoutError) {
    console.log('Timed out after', error.timeout, 'ms');
  } else if (error instanceof HttpError) {
    console.log('HTTP', error.status, error.body);
  } else if (error instanceof NetworkError) {
    console.log('Network failure:', error.message);
  }
}
Error Class Code Retryable Extra Fields
NetworkError ERR_NETWORK Yes cause
TimeoutError ERR_TIMEOUT Yes timeout
HttpError ERR_HTTP Per status status, headers, body
RateLimitError ERR_RATE_LIMIT Yes retryAfter
CircuitOpenError ERR_CIRCUIT_OPEN No resetAt

Retry with Callbacks

await fetchWithRetry({
  url: 'https://api.example.com/data',
  retry: {
    maxRetries: 5,
    baseDelay: 500,
    shouldRetry: (ctx) => ctx.attempt < 3,
  },
  onRetry: (ctx) => {
    console.log(`Attempt ${ctx.attempt}/${ctx.maxRetries}, waiting ${ctx.delay}ms`);
  },
  debug: true,
});

Circuit Breaker

import { CircuitBreaker, CircuitOpenError, fetchWithRetry } from 'fetch-smartly';

const breaker = new CircuitBreaker({
  enabled: true,
  failureThreshold: 5,
  resetTimeout: 30000,
  halfOpenMaxAttempts: 1,
});

try {
  breaker.allowRequest(url, method);
  const res = await fetchWithRetry({ url });
  breaker.onSuccess();
} catch (error) {
  if (error instanceof CircuitOpenError) {
    console.log('Circuit is open, failing fast');
  } else {
    breaker.onFailure();
  }
}

Request Deduplication

import { DedupManager, getDedupKey, isDedupEligible, fetchWithRetry } from 'fetch-smartly';

const dedup = new DedupManager();

async function deduplicatedFetch(url: string) {
  const key = getDedupKey('GET', url);
  const existing = dedup.get(key);
  if (existing) return existing;
  return dedup.track(key, fetchWithRetry({ url }));
}

// Both resolve with the same response from a single fetch:
const [a, b] = await Promise.all([
  deduplicatedFetch('https://api.example.com/data'),
  deduplicatedFetch('https://api.example.com/data'),
]);

Offline Queue

import { OfflineQueue, MemoryStorage, LocalStorageBackend } from 'fetch-smartly';

const queue = new OfflineQueue(new MemoryStorage());

// Enqueue a failed request
queue.enqueue({ url: 'https://api.example.com/submit', method: 'POST', body: '{"data":1}' });

// Replay when back online
const successes = await queue.replay(async (entry) => {
  await fetch(entry.url, {
    method: entry.method,
    headers: entry.headers,
    body: entry.body,
  });
});

console.log(`Replayed ${successes}/${queue.size} requests`);

User Abort

const controller = new AbortController();
setTimeout(() => controller.abort(), 1000);

try {
  await fetchWithRetry({
    url: 'https://api.example.com/slow',
    signal: controller.signal,
  });
} catch (error) {
  // User abort — NOT a TimeoutError
}

Full API Reference

View full API docs


Comparison with Alternatives

Feature fetch-smartly axios ky got
Zero dependencies Yes No No No
Native fetch based Yes No Yes No
Isomorphic Yes Yes Yes Node only
Edge runtime support Yes No Yes No
Strict TypeScript Yes Partial Yes Yes
Retry with backoff Yes Plugin Yes Yes
Retry-After respect Yes No No No
Circuit breaker Yes No No No
Request deduplication Yes No No No
Offline queue Yes No No No
Typed error hierarchy Yes Partial Partial Yes

Running Tests

npm test

86 tests across 6 test suites covering errors, request engine, retry, circuit breaker, deduplication, and offline queue.


Contributing

We welcome contributions! Please read the Contributing Guide before submitting a PR.

Look for issues labeled good first issue to get started.


Security

To report vulnerabilities, please see our Security Policy.


Support

If this package helps you, consider supporting its development:

GitHub Sponsors Buy Me a Coffee

Contributors

Contributors

License

MIT — Made by Ali Raza