Skip to content
rhttp.io

Advanced Features

This guide covers every advanced feature shipped in the core package. All classes (CircuitBreaker, RateLimiter, RequestProfiler, etc.) are imported from the root entry point:

import {
  CircuitBreaker,
  RateLimiter,
  RequestProfiler,
  RequestHistory,
  RequestPool,
  // …all exported from "rhttp.io" (not a sub-path)
} from "rhttp.io"; 

Interceptors

Interceptors let you transform request options before they are sent and transform (or reject) responses after they arrive — similar to Axios interceptors but with a cleaner, typed API.

Registering interceptors

import { createHttp } from "rhttp.io";
 
const http = createHttp({ baseURL: "https://api.example.com" });
 
// Request interceptor — receives options, must return them (or a replacement).
const requestId = http.interceptors.request.use(
  (options) => {
    console.log(`→ ${options.method} ${options.url}`);
    return options;
  },
  (error) => {
    console.error("Request interceptor error:", error);
    return Promise.reject(error);
  },
); 
 
// Response interceptor — receives the full HttpResponse.
http.interceptors.response.use(
  (response) => {
    console.log(`← ${response.status} ${response.durationMs}ms`);
    return response;
  },
  (error) => {
    console.error("Response interceptor error:", error);
    return Promise.reject(error);
  },
); 

Ejecting and clearing

// Remove a specific interceptor by its ID.
requestId.eject(); 
 
// Remove all interceptors of a type.
http.interceptors.request.clear();
http.interceptors.response.clear();

Execution order

  • Request interceptors run sequentially in registration order (first registered = first executed).
  • Response interceptors are chained via .then() in registration order — the first registered sees the response first.
  • If a request interceptor throws and provides no onRejected, subsequent request interceptors are short-circuited and the pipeline jumps to response interceptors' onRejected handlers.

Caching Strategies

rhttp.io ships five built-in cache strategies and an ETag manager for conditional requests.

Strategies

StrategyFresh cache?Stale/empty cache?
cache-onlyreturn cachethrow
network-onlyfetchfetch + cache
cache-firstreturn cachefetch + cache
network-firstfetchreturn stale cache on failure, else rethrow
stale-while-revalidatereturn stale + BG fetchfetch + cache

Global configuration

import { createHttp } from "rhttp.io";
 
const http = createHttp({
  cache: {
    enabled: true,
    ttl: 60_000, // 60 seconds
    strategy: "stale-while-revalidate", 
  },
});

Per-request override

// Override the strategy for a single request.
await http.get("/users", { cache: { strategy: "cache-first" } }); 
 
// Or use a cache key shorthand:
await http.get("/users", { cache: true });

Cache invalidation

// Remove all entries whose key contains the pattern.
http.invalidateCache("/orders"); 
 
// Clear the entire cache.
http.clearCache();

ETag negotiation

ETag support is enabled by default on all GET requests. The client automatically sends If-None-Match and handles 304 Not Modified.

import { createHttp } from "rhttp.io";
 
const http = createHttp({
  etag: { enabled: true }, // default
});
 
// First request: fetches, stores ETag.
const res1 = await http.get("/users"); // 200 OK
 
// Second request: sends If-None-Match, gets 304 if content unchanged.
const res2 = await http.get("/users"); // 304 Not Modified → data: null, but cached entry is fresh

Custom cache key

By default the cache key is "GET:<url>:<JSON.stringify(params)>". Override with keyBuilder:

const http = createHttp({
  cache: {
    enabled: true,
    ttl: 30_000,
    keyBuilder: (url, options) => `${options.method}:${url}`, 
  },
});

Authentication

Two layers of authentication are available: static/dynamic token injection via config, and an automatic JWT refresh interceptor.

Static and dynamic tokens

import { createHttp } from "rhttp.io";
 
// Static token (e.g. service-to-service).
const http = createHttp({
  auth: {
    accessToken: "svc-api-key-xyz", 
    scheme: "Bearer",
  },
});
 
// Dynamic token — called on every request.
const http = createHttp({
  auth: {
    getToken: () => localStorage.getItem("access_token"), 
    scheme: "Bearer",
  },
});

Priority: if accessToken is set, it is used directly. If getToken() is also set, the dynamic call overrides the static token when it returns a non-null value. On the server, forwardCookies can also inject the incoming request's cookies.

JWT refresh with createRefreshAuthInterceptor

This interceptor attaches to the response pipeline and automatically refreshes expired tokens. It handles concurrent 401s by queuing them and replaying them once the refresh completes, and prevents infinite loops via a _retry flag.

import { createClientHttp, createRefreshAuthInterceptor } from "rhttp.io"; 
 
const http = createClientHttp({ baseURL: "https://api.example.com" });
 
http.interceptors.response.use(
  (response) => response,
  createRefreshAuthInterceptor(http, {
    refreshToken: async () => {
      const res = await fetch("/api/auth/refresh", {
        method: "POST",
        credentials: "include",
      });
      if (!res.ok) return null;
      const { token } = await res.json();
      return token as string;
    }, 
    onTokenRefreshed: (newToken) => {
      localStorage.setItem("access_token", newToken); 
    },
    statusCodes: [401], // triggers on these HTTP statuses
  }),
);

How it works internally:

  1. A 401 response arrives. The interceptor checks error.options._retry — if already true, it rejects immediately (anti-loop).
  2. It marks _retry = true and calls refreshToken().
  3. While refreshing, any other 401s are queued (refreshQueue). When the token arrives, all queued requests are replayed.
  4. The new token is stored in client.config.auth.accessToken and sent as Authorization: <scheme> <token>.
  5. The original request is replayed via client.customFetch().

CSRF Protection

CSRF protection prevents cross-site request forgery on state-changing methods (POST, PUT, PATCH, DELETE).

Configuration

import { createHttp } from "rhttp.io";
 
const http = createHttp({
  csrf: {
    enabled: true,
    fetchEndpoint: "/api/csrf", // GET endpoint that returns the token
    cookieName: "csrf-token",   // read from this cookie first
    headerName: "X-CSRF-Token", // sent as this header
    methods: ["POST", "PUT", "PATCH", "DELETE"], 
    prefetch: true,             // fetch token at construction time
  },
});

Token resolution order

  1. Read the csrf-token cookie (browser only).
  2. If absent, GET /api/csrf (response body data.token || data.csrfToken).
  3. The result is cached — the fetch only happens once.

Disabling per request

// Skip CSRF on a specific request.
await http.post("/public/webhook", payload, { csrf: false }); 

Server-side CSRF endpoint

Your backend needs to set a CSRF cookie and return the token:

// Example Express endpoint
app.get("/api/csrf", (req, res) => {
  const token = generateCsrfToken();
  res.cookie("csrf-token", token, { httpOnly: false, sameSite: "strict" });
  res.json({ token });
});

Retry Logic

Retry logic lets requests survive transient failures. It supports linear and exponential backoff, custom retry predicates, and per-request overrides.

Configuration

import { createHttp } from "rhttp.io";
 
const http = createHttp({
  retry: {
    attempts: 3,                              // 3 extra attempts after the first failure (4 total)
    strategy: "exponential",                  // "none" | "linear" | "exponential"
    delay: 300,                              // initial delay in ms
    maxDelay: 5_000,                         // cap per-retry delay
    statusCodes: [408, 429, 500, 502, 503, 504], // auto-retry on these
  },
});

Delay calculation

StrategyFormula
none0 ms (immediate retry)
lineardelay × attempt
exponentialdelay × 2^attempt, capped at maxDelay

NetworkError (status 0) always triggers a retry if attempts > 0 and no shouldRetry is set.

Custom retry predicate

const http = createHttp({
  retry: {
    attempts: 5,
    strategy: "exponential",
    delay: 500,
    maxDelay: 10_000,
    shouldRetry: (error, attemptNumber) => { 
      // Only retry 429 (rate limited) and network errors.
      if (error instanceof HttpError && error.status === 429) return true;
      if (error instanceof NetworkError) return attemptNumber <= 3;
      return false;
    },
  },
});

Per-request override

// More retries for a critical endpoint.
await http.get("/payment/process", { retry: { attempts: 5, strategy: "linear" } }); 
 
// Disable retry for a fire-and-forget call.
await http.post("/analytics/track", event, { retry: false }); 

Retry with jitter

For distributed systems, add random jitter to avoid thundering-herd effects:

import { calculateRetryDelayWithJitter } from "rhttp.io"; 
 
const delay = calculateRetryDelayWithJitter(attemptNumber, {
  initialDelay: 300,
  maxDelay: 5_000,
  jitterFactor: 0.5, // ±50% randomness
});

Adaptive retry strategy

AdaptiveRetryStrategy learns from recent success/failure rates per URL and adjusts the max retry count dynamically:

import { AdaptiveRetryStrategy } from "rhttp.io"; 
 
const adaptive = new AdaptiveRetryStrategy();
 
// After each request, record the outcome:
adaptive.recordSuccess("/users");
adaptive.recordFailure("/users");
 
// Check if we should retry:
if (adaptive.shouldRetry("/users", attemptNumber, 3)) {
  // …retry
}

Rate Limiting

The RateLimiter uses a Token Bucket algorithm. It is not wired into createHttp() automatically — you connect it via an interceptor.

Basic usage

import { createHttp, RateLimiter } from "rhttp.io"; 
 
const limiter = new RateLimiter({
  enabled: true,
  tokensPerSecond: 100, // refill rate
  maxBurst: 150,       // bucket capacity
});
 
const http = createHttp({ baseURL: "https://api.example.com" });
 
// Wire into the request pipeline via an interceptor.
http.interceptors.request.use(async (options) => {
  await limiter.acquire(options.url, options.method); 
  return options;
});
 
// Now every request waits for a token before being sent.
await http.get("/users"); // proceeds immediately if tokens available
await http.get("/users"); // may wait if bucket is low

API

const limiter = new RateLimiter({ enabled: true, tokensPerSecond: 100, maxBurst: 150 });
 
// Acquire a token (blocks until available). Default weight = 1.
await limiter.acquire(url, method, weight?); 
 
// Inspect a specific bucket.
limiter.getStatus("GET:/users");
// → { tokens: 87, lastRefill: 1718900000 }
 
// Reset a bucket or all buckets.
limiter.reset("GET:/users");
limiter.reset(); // clears all
 
// Inspect config and all buckets.
limiter.getConfig();     // → Required<RateLimitConfig>
limiter.getAllBuckets(); // → Map<string, { tokens, lastRefill }>

Request Profiling & Observability

rhttp.io provides multiple observability layers: built-in metrics, a request profiler, structured logging, and request history.

Built-in metrics (getMetrics)

Enable metrics collection and inspect totals, durations, and status code breakdowns.

import { createHttp } from "rhttp.io";
 
const http = createHttp({
  observability: { metrics: true }, 
});
 
await http.get("/users");
await http.get("/users");
 
const metrics = http.getMetrics(); 
console.log(metrics);
// {
//   totalRequests: 2,
//   successfulRequests: 2,
//   failedRequests: 0,
//   durations: [45, 38],
//   statusCodes: { 200: 2 }
// }

Request profiling

RequestProfiler provides per-request timing, sizes, and status for detailed analysis.

import { createHttp, RequestProfiler } from "rhttp.io"; 
 
const profiler = new RequestProfiler();
const http = createHttp({
  baseURL: "https://api.example.com",
  observability: { metrics: true },
});
 
// Start profiling a request.
const requestId = crypto.randomUUID();
profiler.start(requestId, "/users", "GET"); 
 
const response = await http.get("/users");
 
// End profiling.
profiler.end(requestId, response.status); 
 
// Get a single profile.
const profile = profiler.getProfile(requestId);
// → { requestId, url, method, duration, status, cached, deduplicated, … }
 
// Get aggregated stats.
const stats = profiler.getStats(); 
// → { totalRequests, averageDuration, maxDuration, minDuration }
 
// Filter profiles by URL or method.
const userProfiles = profiler.getProfiles({ url: "/users" });

Structured logging

InMemoryStructuredLogger stores structured log entries with timestamps, levels, and optional context.

import { InMemoryStructuredLogger } from "rhttp.io"; 
 
const logger = new InMemoryStructuredLogger();
 
const http = createHttp({
  observability: { logger }, // pass the custom logger instance
});
 
await http.get("/users");
 
const logs = logger.getLogs();
// → [{ timestamp, level: "info", message: "Request completed", context: { url, method, durationMs }, requestId }]
logger.clear();

Request history

Every request is recorded in a ring buffer (default max size: 100) for debugging.

import { createHttp } from "rhttp.io";
 
const http = createHttp({
  baseURL: "https://api.example.com",
  observability: { metrics: true },
});
 
await http.get("/users");
await http.post("/users", { name: "Ada" });
 
const history = http.getHistory(); 
// → [
//     { requestId, url: "/users", method: "GET", status: 200, durationMs: 42, timestamp: 1718900000 },
//     { requestId, url: "/users", method: "POST", status: 201, durationMs: 88, timestamp: 1718900001 }
//   ]

Bonus: Circuit Breaker & Request Pool

These two features are automatically wired into createHttp() when configured.

Circuit breaker

Prevents cascading failures by opening the circuit after consecutive errors.

import { createHttp } from "rhttp.io";
 
const http = createHttp({
  circuitBreaker: {
    enabled: true,
    failureThreshold: 5,   // open after 5 failures
    successThreshold: 2,  // close after 2 successes in half-open
    timeout: 60_000,      // try half-open after 60s
  },
});
 
// Check status:
const status = http.getCircuitBreakerStatus(); 
// → { state: "closed" | "open" | "half-open", failures, successes }
 
// Reset manually:
http.resetCircuitBreaker();

Request pooling

Limits concurrent requests and queues excess ones.

import { createHttp } from "rhttp.io";
 
const http = createHttp({
  requestPool: {
    enabled: true,
    maxConcurrent: 5,
    queueLimit: 20, // throw if queue exceeds 20
  },
});
 
const stats = http.getPoolStats(); 
// → { activeRequests: 3, queueLength: 0 }