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.