Skip to content

Policies

FetchEngine provides three resilience policies that share a common architecture: request deduplication, response caching, and rate limiting.

Using Policies Together

Policies are config keys, not wrappers you write. Before reaching for a retry loop, an in-flight promise map, a response cache, or a throttle around FetchEngine, wire the policy instead — each one is a line of config, and they compose on a single engine:

typescript
import { FetchEngine } from '@logosdx/fetch';
import { attempt } from '@logosdx/utils';

const api = new FetchEngine({
    baseUrl: 'https://api.example.com',

    // Transport resilience: 3 attempts with exponential backoff
    retry: {
        maxAttempts: 3,
        baseDelay: 150
    },

    // Concurrent identical GETs share one in-flight request
    dedupePolicy: true,

    // Cache reads; serve stale while revalidating in the background
    cachePolicy: {
        ttl: 30000,
        staleIn: 10000,
        rules: [
            { startsWith: '/reports', ttl: 300000 },   // expensive queries: cache 5 min
            { is: '/session', enabled: false }          // never cache auth state
        ]
    },

    // Stay under the provider's 60 req/min; excess requests wait for a token
    rateLimitPolicy: {
        maxCalls: 60,
        windowMs: 60000,
        waitForToken: true,
        rules: [
            { startsWith: '/exports', waitForToken: false }   // bulk work fails fast instead
        ]
    }
});

// Every call now gets retry + dedupe + cache + rate limiting. No wrappers needed.
const [res, err] = await attempt(() => api.get<Loan[]>('/loans', { totalTimeout: 5000 }));

if (err) {
    // Transport failure — includes a rate-limit wait cut short by totalTimeout
    console.error('Request failed:', err.message);
    return;
}

if (!res.ok) {
    console.error('Server said no:', res.status);
    return;
}

console.log('Loans:', res.data);

What each key replaces:

Hand-rolled versionPolicy
Retry loop, p-retryretry
In-flight promise map keyed by URLdedupePolicy
Response cache, lru-cache, SWR wrappercachePolicy
Token bucket, setTimeout throttle, p-queuerateLimitPolicy
A second FetchEngine so one endpoint gets its own limitrules — each matched rule gets an isolated bucket
Per-endpoint variants of any of the aboverules on each policy

One engine covers many budgets. Needing a different limit for /embed than /query is a rules entry ({ is: '/embed', maxCalls: 10 }), not a reason to instantiate a second client — each matched rule gets its own token bucket, and a per-rule serializer can split buckets even further (per user, per payload field).

rules is not specific to rate limiting: all three policies share the same route-matching layer (is, startsWith, endsWith, includes, match), each overriding its own options per route — cache TTLs, dedupe toggles, rate budgets. See Route Matching.

Reconfiguring at Runtime

Policy config keys can be changed after construction — the running policy picks the new values up on the next request:

typescript
api.config.set('retry.maxAttempts', 5);                       // deep path
api.config.set('rateLimitPolicy', { maxCalls: 10, windowMs: 60000 });  // whole key
api.config.set({ cachePolicy: { ttl: 5000 } });               // merge form

Rules that keep the store truthful:

  • Validation runs before the store mutates. A rejected set() — single key or multi-key merge — changes nothing: config.get() keeps reporting the prior value.
  • A policy key owned by a plugins:-array plugin cannot be set at runtime (throws the same ownership error construction does) — the plugin instance holds its own config.
  • The cache adapter is construction-only: a set() that changes cachePolicy.adapter throws. Swapping the store would silently drop every cached entry; construct a new engine instead. TTLs, rules, and methods reconfigure in place and existing entries survive.
  • Rate-limit reconfiguration rebuilds token buckets (a new budget starts fresh); cookie reconfiguration never clears the jar.

Policies run in a fixed order (cache check → rate limit → dedupe → retry → network; see Rate Limiting Order), so a cache hit never spends a rate-limit token and deduplicated callers share one retry sequence. Per-call escape hatches exist on every request, and each policy accepts rules for per-route overrides — details in the sections below:

typescript
await api.get('/loans', {
    totalTimeout: 5000,               // bound the whole operation
    attemptTimeout: 2000,             // bound each attempt (retries get a fresh budget)
    abortController: controller,      // manual cancellation
    retry: false,                     // no retries for this request (a config object works too,
                                      // and overrides even an engine-level `retry: false`)
    skipCache: true,                  // bypass the cache: no lookup, no store
});

Request Deduplication

When multiple parts of your application make identical requests simultaneously, FetchEngine can deduplicate them by sharing a single in-flight promise. This reduces network traffic, server load, and prevents race conditions.

Quick Start

typescript
// Enable with defaults (GET requests only)
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    dedupePolicy: true
});

// Three concurrent calls → one network request
const [user1, user2, user3] = await Promise.all([
    api.get('/users/123'),
    api.get('/users/123'),
    api.get('/users/123')
]);
// All three receive the same result from a single HTTP request

Configuration

OptionTypeDefaultDescription
enabledbooleantrueEnable/disable deduplication
methodsHttpMethod[]['GET']HTTP methods to deduplicate
serializerRequestSerializerdefaultRequestSerializerFunction to generate request keys
shouldDedupe(ctx) => boolean-Dynamic skip check (called per-request)
rulesDedupeRule[]-Route-specific configuration

Full Configuration Example:

typescript
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    dedupePolicy: {
        enabled: true,
        methods: ['GET', 'POST'],
        serializer: (ctx) => `${ctx.method}:${ctx.path}:${JSON.stringify(ctx.payload)}`,
        shouldDedupe: (ctx) => !ctx.headers?.['X-Force-Fresh'],
        rules: [
            // Disable deduplication for admin endpoints
            { startsWith: '/admin', enabled: false },

            // Custom serializer for search (ignore timestamp param)
            {
                startsWith: '/search',
                serializer: (ctx) => `${ctx.method}:${ctx.path}:${ctx.payload?.query}`
            },

            // Enable POST deduplication for specific endpoint
            { is: '/graphql', methods: ['POST'] }
        ]
    }
});

Deduplication Events

typescript
// Emitted when a new request starts tracking
api.on('dedupe-start', (event) => {
    console.log('New request:', event.key);
});

// Emitted when a caller joins an existing in-flight request
api.on('dedupe-join', (event) => {
    console.log('Joined:', event.key, 'waiters:', event.waitingCount);
});

Independent Timeout per Caller

Each caller can have independent timeout and abort constraints:

typescript
// Caller A starts request with 10s timeout
const promiseA = api.get('/slow-endpoint', { totalTimeout: 10000 });

// Caller B joins with 2s timeout
const promiseB = api.get('/slow-endpoint', { totalTimeout: 2000 });

// After 2s: B times out and rejects → A continues waiting
// At 5s: Request completes → A gets the result

// Semantics:
// - Initiator's abort/timeout → cancels fetch → everyone fails
// - Joiner's abort/timeout → only that joiner fails → others unaffected

Response Caching

FetchEngine supports response caching with TTL and stale-while-revalidate (SWR) for improved performance and reduced API load.

Quick Start

typescript
// Enable with defaults (GET requests, 60s TTL)
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    cachePolicy: true
});

// First call: fetches from network, caches response
const users1 = await api.get('/users');

// Subsequent calls within TTL: instant cache hit
const users2 = await api.get('/users');

Configuration

OptionTypeDefaultDescription
enabledbooleantrueEnable/disable caching
methodsHttpMethod[]['GET']HTTP methods to cache
ttlnumber60000Time to live in milliseconds
staleInnumber-Time until stale for SWR (ms)
serializerRequestSerializerdefaultRequestSerializerFunction to generate cache keys
skip(ctx) => boolean-Dynamic skip check
rulesCacheRule[]-Route-specific configuration
adapterCacheAdapter<unknown>MapCacheAdapterCustom cache storage backend

Full Configuration with SWR:

typescript
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    cachePolicy: {
        enabled: true,
        methods: ['GET'],
        ttl: 300000,          // 5 minutes
        staleIn: 60000,       // Consider stale after 1 minute

        // Skip caching for certain requests
        skip: (ctx) => ctx.headers?.['Cache-Control'] === 'no-cache',

        rules: [
            // Long cache for static content
            { startsWith: '/static', ttl: 3600000 },

            // Short cache for user data
            { startsWith: '/user', ttl: 30000, staleIn: 10000 },

            // No caching for realtime endpoints
            { includes: '/realtime', enabled: false },

            // No caching for admin
            { startsWith: '/admin', enabled: false }
        ]
    }
});

Stale-While-Revalidate (SWR)

When staleIn is configured, FetchEngine implements stale-while-revalidate:

typescript
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    cachePolicy: {
        ttl: 60000,      // Expire after 60 seconds
        staleIn: 30000   // Consider stale after 30 seconds
    }
});

// Timeline:
// 0-30s:  Fresh cache hit - returns cached data immediately
// 30-60s: Stale cache hit - returns cached data + background revalidation
// >60s:   Cache miss - fetches fresh data

Cache Events

typescript
// Fresh cache hit
api.on('cache-hit', (event) => {
    console.log('Cache hit:', event.key, 'expires in:', event.expiresIn);
});

// Stale cache hit (SWR)
api.on('cache-stale', (event) => {
    console.log('Stale hit:', event.key, 'revalidating...');
});

// Cache miss
api.on('cache-miss', (event) => {
    console.log('Cache miss:', event.key);
});

// New cache entry stored
api.on('cache-set', (event) => {
    console.log('Cached:', event.key, 'TTL:', event.expiresIn);
});

// SWR background revalidation started
api.on('cache-revalidate', (event) => {
    console.log('Background revalidation:', event.key);
});

// SWR background revalidation failed — the existing stale entry is kept,
// never overwritten. `outcome` is a resolved `ok: false` FetchResponse or a
// rejected FetchError (a non-2xx revalidation never throws, so the cause
// isn't always an Error) — narrow with isFetchError(outcome).
api.on('cache-revalidate-error', (event) => {
    if (isFetchError(event.outcome)) {
        console.error('Revalidation transport failure:', event.key, event.outcome.message);
        return;
    }
    console.warn('Revalidation got a non-2xx status:', event.key, event.outcome?.status);
});

Cache Invalidation

typescript
// Clear all cached responses
api.clearCache();

// Delete specific cache entry (sync, fire-and-forget)
api.clearCacheKey(cacheKey);

// Delete specific cache entry (async, returns whether it existed)
const existed = await api.deleteCache(cacheKey);

// Invalidate entries matching a predicate
const count = await api.invalidateCache((key) => key.includes('user'));
console.log(`Invalidated ${count} entries`);

// Invalidate by path pattern (string prefix)
await api.invalidatePath('/users');

// Invalidate by path pattern (RegExp)
await api.invalidatePath(/^\/api\/v\d+\/users/);

// Invalidate with custom predicate (for custom serializers)
await api.invalidatePath((key) => {
    // Full control over key matching - useful when using custom serializers
    return key.includes('/users') && key.includes('Bearer');
});

// Get cache statistics
const stats = api.cacheStats();
console.log('Cache size:', stats.cacheSize);
console.log('In-flight:', stats.inflightCount);

Custom Cache Adapters

FetchEngine supports pluggable cache backends via the CacheAdapter interface. This enables caching to Redis, IndexedDB, AsyncStorage, localStorage, or any custom storage.

typescript
import { FetchEngine } from '@logosdx/fetch';
import { CacheAdapter, CacheItem } from '@logosdx/utils';

// Example: localStorage adapter
class LocalStorageCacheAdapter implements CacheAdapter<unknown> {

    #prefix: string;
    #data = new Map<string, CacheItem<unknown>>();

    constructor(prefix = 'api-cache') {
        this.#prefix = prefix;
        this.#loadFromStorage();
    }

    get size() { return this.#data.size; }

    async get(key: string) {
        return this.#data.get(key);
    }

    async set(key: string, item: CacheItem<unknown>) {
        this.#data.set(key, item);
        this.#saveToStorage();
    }

    async delete(key: string) {
        const existed = this.#data.delete(key);
        this.#saveToStorage();
        return existed;
    }

    async has(key: string) {
        return this.#data.has(key);
    }

    async clear() {
        this.#data.clear();
        localStorage.removeItem(this.#prefix);
    }

    #loadFromStorage() {
        const stored = localStorage.getItem(this.#prefix);
        if (stored) {
            const entries = JSON.parse(stored);
            this.#data = new Map(entries);
        }
    }

    #saveToStorage() {
        localStorage.setItem(this.#prefix, JSON.stringify([...this.#data]));
    }
}

// Use the custom adapter
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    cachePolicy: {
        adapter: new LocalStorageCacheAdapter('my-api'),
        ttl: 300000
    }
});

The CacheAdapter interface:

typescript
interface CacheAdapter<T> {

    get(key: string): Promise<CacheItem<T> | undefined>;
    set(key: string, item: CacheItem<T>, expiresAt?: number): Promise<void>;
    delete(key: string): Promise<boolean>;
    has(key: string): Promise<boolean>;
    clear(): Promise<void>;
    readonly size: number;
}

interface CacheItem<T> {

    value: T;
    createdAt: number;
    expiresAt: number;
    staleAt?: number;  // For SWR
}

Rate Limiting

Control outgoing request rates using a token bucket algorithm. Each unique request key (generated by the serializer) gets its own rate limiter, enabling per-endpoint or per-user throttling.

This re-uses the same rate limiting logic found in the function utility in the utils package.

Quick Start

typescript
// Enable with defaults (100 requests/minute, all HTTP methods)
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    rateLimitPolicy: true
});

// Requests are automatically throttled
// If rate limit is exceeded, requests wait for tokens by default
await api.get('/users');  // Waits if needed

Configuration

typescript
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    rateLimitPolicy: {
        // Global settings
        enabled: true,
        methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],  // All by default
        maxCalls: 100,            // Requests per window (default: 100)
        windowMs: 60000,          // Time window in ms (default: 60000 = 1 minute)
        waitForToken: true,       // true = wait, false = reject immediately

        // Custom key generation (default: method + pathname)
        serializer: (ctx) => `${ctx.method}|${ctx.url.pathname}`,

        // Dynamic bypass
        shouldRateLimit: (ctx) => {
            // Return false to bypass rate limiting
            return !ctx.headers?.['X-Bypass-RateLimit'];
        },

        // Callback when rate limited
        onRateLimit: (ctx, waitTimeMs) => {
            console.log(`Rate limited for ${waitTimeMs}ms:`, ctx.path);
        },

        // Route-specific rules
        rules: [
            // Stricter limits for search
            { startsWith: '/api/search', maxCalls: 10, windowMs: 60000 },

            // Reject immediately for bulk operations
            { startsWith: '/api/bulk', waitForToken: false },

            // No rate limiting for health checks
            { startsWith: '/health', enabled: false },

            // Custom serializer for user-specific limiting
            {
                startsWith: '/api/user',
                serializer: (ctx) => `user:${ctx.headers?.['X-User-ID'] ?? 'anonymous'}`
            }
        ]
    }
});

Token Bucket Algorithm

Rate limiting uses a token bucket that refills continuously:

  • Capacity: maxCalls tokens
  • Refill Rate: maxCalls / windowMs tokens per millisecond
  • Each request consumes 1 token
  • If no tokens available:
    • waitForToken: true → waits until token available
    • waitForToken: false → throws RateLimitError immediately
  • The wait observes the request's abort signal (totalTimeout, timeout, or a manual abortController.abort()): an aborted request stops waiting promptly, never consumes a token, and rejects with a FetchError (status: 499, aborted: true, and timedOut: true when the abort came from totalTimeout)
typescript
// Example: 10 requests per minute = 1 token every 6 seconds
{
    maxCalls: 10,
    windowMs: 60000  // 60000ms / 10 = 6000ms per token
}

Rate Limit Events

typescript
// Emitted when request must wait for a token
api.on('ratelimit-wait', (event) => {
    console.log('Waiting for rate limit:', {
        key: event.key,
        waitTimeMs: event.waitTimeMs,
        currentTokens: event.currentTokens,
        capacity: event.capacity,
        nextAvailable: event.nextAvailable
    });
});

// Emitted when request is rejected (waitForToken: false)
api.on('ratelimit-reject', (event) => {
    console.log('Rate limit exceeded:', {
        key: event.key,
        waitTimeMs: event.waitTimeMs  // How long they would have waited
    });
});

// Emitted after token is successfully acquired
api.on('ratelimit-acquire', (event) => {
    console.log('Token acquired:', {
        key: event.key,
        currentTokens: event.currentTokens,  // Remaining tokens
        capacity: event.capacity
    });
});

// Emitted when a wait ends because the request aborted
// (totalTimeout fired or the caller aborted). Terminal pair
// of `ratelimit-wait` — every wait ends in acquire or abort.
api.on('ratelimit-abort', (event) => {
    console.log('Wait abandoned:', {
        key: event.key,
        waitTimeMs: event.waitTimeMs  // How long the request actually waited
    });
});

Rate Limiting Order

Rate limiting is evaluated after the cache check but before deduplication and retry:

beforeRequest (run):
  Cache Check → Rate Limit → [user hooks]

execute (pipe):
  Dedupe → Retry → Network Request

afterRequest (run):
  Cache Store → [user hooks]

This means:

  • Cached responses return immediately without consuming rate limit tokens
  • Rate limiting only gates actual outbound requests (after cache miss)
  • Deduplicated requests only consume one token (the initiator's)
  • Retry wraps the network call, not the entire pipeline

Per-User Rate Limiting

typescript
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    rateLimitPolicy: {
        maxCalls: 100,
        windowMs: 60000,
        // Group requests by user ID
        serializer: (ctx) => `user:${ctx.state?.userId ?? 'anonymous'}`
    }
});

// Each user gets their own 100 req/min bucket
api.state.set('userId', 'user-123');
await api.get('/data');  // Uses user-123's bucket

api.state.set('userId', 'user-456');
await api.get('/data');  // Uses user-456's bucket

Global Rate Limiting

typescript
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    rateLimitPolicy: {
        maxCalls: 1000,
        windowMs: 60000,
        // All requests share one bucket
        serializer: () => 'global'
    }
});

Handling Rate Limit Errors

typescript
import { attempt, isRateLimitError } from '@logosdx/utils';

const [response, err] = await attempt(() => api.get('/users'));

if (err) {
    if (isRateLimitError(err)) {
        console.log('Rate limited:', err.message);
        console.log('Limit:', err.limit);  // maxCalls value
        // Retry after some time, or show user feedback
    }
}

Route Matching

Deduplication, caching, and rate limiting all support flexible route matching via MatchTypes:

typescript
interface MatchTypes {

    is?: string;           // Exact path match
    startsWith?: string;   // Path prefix match
    endsWith?: string;     // Path suffix match
    includes?: string;     // Path contains substring
    match?: RegExp;        // Regular expression match
}

Match Type Behavior:

  • is requires an exact match and cannot be combined with other types
  • Other types can be combined with AND logic (all must match)

Examples:

typescript
const rules = [
    // Exact match
    { is: '/users' },

    // Prefix match
    { startsWith: '/api/v2' },

    // Suffix match
    { endsWith: '.json' },

    // Substring match
    { includes: 'admin' },

    // Regex match
    { match: /^\/v\d+\/users/ },

    // Combined (AND logic)
    { startsWith: '/api', endsWith: '.json' },  // Must satisfy both
    { includes: 'user', match: /\/\d+$/ }       // Must satisfy both
];

Regex Performance (ReDoS)

Route matching runs on every request. Poorly written regular expressions can cause catastrophic backtracking, severely degrading performance or hanging your application.

Dangerous patterns to avoid:

typescript
// BAD: Nested quantifiers cause exponential backtracking
{ match: /(a+)+b/ }
{ match: /^\/api\/v\d+\/.*$/ }     // .* with anchors can backtrack
{ match: /(\w+)*@/ }               // Nested quantifiers

// BAD: Overlapping alternatives
{ match: /(a|a)+/ }
{ match: /(\d+|\d+\.)+/ }

Safe patterns:

typescript
// GOOD: Simple, non-nested quantifiers
{ match: /^\/v\d+\/users/ }        // No trailing .*
{ match: /\/users\/\d+$/ }         // Anchored end, simple pattern
{ match: /\.(json|xml)$/ }         // Non-overlapping alternatives

// BETTER: Use string matchers when possible (faster, no ReDoS risk)
{ startsWith: '/api/v2' }          // Instead of /^\/api\/v2/
{ endsWith: '.json' }              // Instead of /\.json$/
{ includes: '/users/' }            // Instead of /\/users\//

Best practice: Prefer string-based matchers (startsWith, endsWith, includes, is) over regex. They're faster and immune to ReDoS. Only use match when you need pattern complexity that strings can't express.

Request Serializers

Serializers generate unique keys for identifying requests. These keys are used by deduplication, caching, and rate limiting to determine which requests should share state.

Built-in Serializers

FetchEngine provides two built-in serializers, each optimized for different use cases:

Request Serializer (Default for Cache & Dedupe)

Generates keys based on full request identity: method, path, query string, payload, and stable headers.

typescript
// Key format: method|path+query|payload|headers
// Example: "GET|/users/123?page=1|undefined|{"accept":"application/json","authorization":"Bearer token"}"

Stable Headers Only: The request serializer only includes semantically meaningful headers that affect response content:

Included HeadersPurpose
authorizationDifferent users get different responses
acceptDifferent response formats (JSON, XML, etc.)
accept-languageLocalized responses
content-typeFormat of request payload (for POST/PUT)
accept-encodingResponse compression format

Excluded Headers (Dynamic):

  • X-Timestamp, Date - Change every request
  • X-HMAC-Signature - Computed per-request
  • X-Request-Id, X-Correlation-Id - Unique per-request
  • Cache-Control, Pragma - Control directives, not identity

This prevents cache pollution from dynamic headers that would make every request unique.

Endpoint Serializer (Default for Rate Limit)

Generates keys based on endpoint identity only: method and pathname (excludes query string and payload).

typescript
// Key format: method|pathname
// Example: "GET|/users/123"

This groups all requests to the same endpoint together, ideal for rate limiting where you want to protect an endpoint from overload regardless of specific parameters.

Using Built-in Serializers

typescript
import { endpointSerializer, requestSerializer } from '@logosdx/fetch';

// Use endpoint serializer for cache (group by endpoint)
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    cachePolicy: {
        serializer: endpointSerializer,  // All /users/123?page=1 and /users/123?page=2 share cache
        ttl: 60000
    }
});

// Use request serializer for rate limiting (per unique request)
const api2 = new FetchEngine({
    baseUrl: 'https://api.example.com',
    rateLimitPolicy: {
        serializer: requestSerializer,  // Each unique request gets its own bucket
        maxCalls: 100,
        windowMs: 60000
    }
});

Custom Serializers

Create custom serializers when the built-ins don't match your needs:

typescript
// User-scoped rate limiting
const userSerializer = (ctx: RequestKeyOptions) => {
    return `user:${ctx.state?.userId ?? 'anonymous'}|${ctx.method}|${ctx.url.pathname}`;
};

const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    rateLimitPolicy: {
        serializer: userSerializer,  // Each user gets their own rate limit bucket
        maxCalls: 100,
        windowMs: 60000
    }
});

// Tenant-scoped caching
const tenantSerializer = (ctx: RequestKeyOptions) => {
    const tenant = ctx.headers?.['X-Tenant-ID'] ?? 'default';
    return `${tenant}|${ctx.method}|${ctx.url.pathname}${ctx.url.search}`;
};

const multiTenantApi = new FetchEngine({
    baseUrl: 'https://api.example.com',
    cachePolicy: {
        serializer: tenantSerializer,  // Each tenant has separate cache
        ttl: 60000
    }
});

// Ignore certain params for caching
const ignoreTimestampSerializer = (ctx: RequestKeyOptions) => {
    const url = new URL(ctx.url);
    url.searchParams.delete('_t');  // Remove timestamp param
    url.searchParams.delete('nocache');
    return `${ctx.method}|${url.pathname}${url.search}`;
};

Serializer Signature

typescript
type RequestSerializer<S = unknown, H = unknown, P = unknown> = (
    ctx: RequestKeyOptions<S, H, P>
) => string;

interface RequestKeyOptions<S = unknown, H = unknown, P = unknown> {

    method: string;           // HTTP method (uppercase)
    path: string;             // Original path from request
    url: URL;                 // Full URL object (includes pathname, search, etc.)
    payload?: unknown;        // Request body (if any)
    headers?: H;              // Request headers
    params?: P;               // URL parameters
    state?: S;                // Instance state
}

Per-Rule Serializers

Override serializers for specific routes:

typescript
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    cachePolicy: {
        enabled: true,
        ttl: 60000,
        rules: [
            // GraphQL: cache by operation name only
            {
                is: '/graphql',
                serializer: (ctx) => `graphql:${ctx.payload?.operationName ?? 'unknown'}`
            },

            // Search: ignore pagination for cache
            {
                startsWith: '/search',
                serializer: (ctx) => {
                    const url = new URL(ctx.url);
                    url.searchParams.delete('page');
                    url.searchParams.delete('limit');
                    return `search:${url.search}`;
                }
            },

            // User profile: cache per user
            {
                match: /^\/users\/\d+$/,
                serializer: (ctx) => `user:${ctx.url.pathname}`
            }
        ]
    }
});

Policy Architecture

FetchEngine's resilience policies (deduplication, caching, rate limiting) share a common architecture that enables consistent behavior and efficient configuration resolution.

Three-Method Pattern

All policies implement the same three-method pattern:

┌─────────────────────────────────────────────────────────────┐
│                    ResiliencePolicy                          │
├─────────────────────────────────────────────────────────────┤
│  init(config)     Parse config → Initialize state  (O(1))   │
│  resolve(...)     Memoized lookup + dynamic checks (O(1)*)  │
│  compute(...)     Rule matching (O(n) first time only)      │
└─────────────────────────────────────────────────────────────┘
      * O(1) amortized due to memoization
  1. init: Called during FetchEngine construction. Parses configuration, validates rules, and sets up internal state.

  2. resolve: Called for every request. Returns the effective policy configuration by combining memoized rule matching with dynamic skip callbacks.

  3. compute: Called once per unique method+path combination. Performs O(n) rule matching and caches the result.

Configuration Resolution

When a request is made, each policy resolves its configuration in order:

Request → Policy.resolve(method, path, context)

                    ├── Check memoized cache (O(1))
                    │   └── Cache miss? → compute() → cache result

                    ├── Check dynamic skip callback
                    │   └── Skip? → return null

                    └── Return merged rule (policy defaults + matched rule)

Rule Matching Priority

Rules are evaluated in declaration order. The first matching rule wins:

typescript
rules: [
    { is: '/users', ttl: 30000 },           // Checked first (exact match)
    { startsWith: '/users', ttl: 60000 },   // Checked second
    { match: /^\/users/, ttl: 120000 }      // Checked third
]
// Request to '/users' matches first rule (30s TTL)
// Request to '/users/123' matches second rule (60s TTL)

Policy Execution Order

FetchEngine uses a 3-phase hook pipeline powered by @logosdx/hooks:

beforeRequest (run — priority order):

    ├── -30: Cache Check ──────────────────┐
    │        └── Hit? Return cached        │
    │                                      │
    ├── -20: Rate Limit (guard) ───────────┤
    │        └── Wait or reject            │
    │                                      │
    └──  0:  User hooks ──────────────────┘

execute (pipe — onion middleware):

    ├── -30: Dedupe ───────────────────────┐
    │        └── In-flight? Join it        │
    │                                      │
    ├── -20: Retry ────────────────────────┤
    │        └── Wrap with retry logic     │
    │                                      │
    └── core: Network Request ─────────────┘

afterRequest (run — priority order):

    ├── -10: Cache Store ──────────────────┐
    │        └── Store response            │
    │                                      │
    └──  0:  User hooks ──────────────────┘

Key implications:

  • Cache checks run first — cached responses return immediately without consuming rate limit tokens
  • Rate limiting only runs on cache misses — it protects the upstream API, not local cache reads
  • Deduplication and retry wrap the actual network call via pipe middleware
  • Only the request initiator consumes a rate limit token; joiners share the result

Memoization Strategy

Rule matching results are cached by method:path key:

typescript
// First request to GET /users/123
resolve('GET', '/users/123', ctx)
compute() runs, caches result
    → rulesCache.set('GET:/users/123', resolvedRule)

// Subsequent requests to same endpoint
resolve('GET', '/users/123', ctx)
    → rulesCache.get('GET:/users/123') // O(1) hit
    → Check skip callback
    → Return cached rule

This means:

  • First request to each endpoint: O(n) rule matching
  • Subsequent requests: O(1) cache lookup
  • Skip callbacks always run (they depend on request-specific context)

Policy State

Each policy maintains its own internal state:

typescript
interface PolicyInternalState {

    enabled: boolean;                    // Global enable/disable
    methods: Set<string>;                // Applicable HTTP methods
    serializer: RequestSerializer;       // Key generation function
    rulesCache: Map<string, Rule | null>; // Memoized rule lookups
}

Extending Policies

While the built-in policies cover most use cases, the architecture is designed for extensibility. Each policy class extends ResiliencePolicy and implements:

  • getDefaultSerializer() - Returns the default key generation function
  • getDefaultMethods() - Returns which HTTP methods are enabled by default
  • mergeRuleWithDefaults(rule) - Merges matched rules with policy defaults

This shared base ensures consistent configuration handling across all resilience features.