@vita-mojo/service-cache-adapter
v0.0.7-VMOS-13194-8ec3dee-1077-rc.0
Published
A two-layer caching adapter for HTTP requests built on [cacheable](https://www.npmjs.com/package/cacheable) and [iovalkey](https://www.npmjs.com/package/iovalkey). Provides axios request caching with multiple staleness strategies, automatic Redis circuit-
Keywords
Readme
service-cache-adapter
A two-layer caching adapter for HTTP requests built on cacheable and iovalkey. Provides axios request caching with multiple staleness strategies, automatic Redis circuit-breaking, and pattern-based cache invalidation.
Architecture
┌──────────────────────────────────────────┐
│ AxiosCacheService │
│ createCachedAxios() / attachCaching() │
└─────────────────┬────────────────────────┘
│
┌─────────────────▼────────────────────────┐
│ CacheInterceptor │
│ Axios request/response interceptors │
│ Short-circuits via custom config.adapter│
└─────────────────┬────────────────────────┘
│
┌─────────────────▼────────────────────────┐
│ CacheStrategyExecutor │
│ STALE_ON_ERROR │ FORCE_STALE │ SWU │
│ fetchWithTimeout (Promise.race) │
└─────────────────┬────────────────────────┘
│
┌─────────────────▼────────────────────────┐
│ CacheBackend │
│ get() → getWithTimeout (Promise.race) │
│ set() → nonBlocking: true │
└──────┬─────────────────────────┬─────────┘
│ │
┌────────────▼──────────┐ ┌───────────▼──────────┐
│ L1: CacheableMemory │ │ L2: Redis (iovalkey)│
│ In-process, ~1min │ │ Shared, TTL + 30d │
│ LRU eviction (1000) │ │ Circuit breaker │
└───────────────────────┘ └──────────────────────┘Request flow
- Axios request enters the request interceptor
- CacheKeyGenerator builds a deterministic key from namespace, method, URL, body, and relevant headers
- CacheStrategyExecutor checks L1 (memory), then L2 (Redis) via CacheBackend
- On cache hit (fresh): the interceptor sets a custom
config.adapterthat resolves immediately with cached data — no HTTP call is made - On cache miss/stale: the origin fetcher executes the actual HTTP request, and the result is cached
- Response interceptor adds
x-cache-status,x-cache-date, andx-cache-ageheaders
TTL model
- defaultTTL (default: 5m) — time after which an entry is considered stale
- minRetentionTTL (default: 30d) — how long stale entries remain in Redis for fallback
- Effective Redis TTL =
defaultTTL + minRetentionTTL— entries stay in Redis for the full retention window - Memory TTL (default: 1m) — short-lived L1 for hot data, LRU-evicted at 1000 items
Non-blocking guarantees
- Writes (
set):nonBlocking: trueusesPromise.race()internally — returns after L1 write, L2 fires in background - Reads (
get): L1 hit returns instantly. L1 miss awaits L2, bounded byoperationTimeout(default: 800ms) viaPromise.race - Deletes: same as writes —
Promise.race(), L1 returns first
Cache strategies
| Strategy | Behaviour |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| STALE_ON_ERROR (default) | Return fresh cache immediately. If stale, fetch origin. On 5xx/network/timeout error, fall back to stale data. 4xx errors propagate |
| FORCE_STALE | Always return cached data (fresh or stale). Only fetches origin on first miss |
| STALE_WHILE_UPDATE | Return stale data immediately, refresh in background (fire-and-forget) |
Redis resilience
Circuit breaker
When the Redis client emits a connection error:
- Suspend: disconnect the Redis client, detach L2 from cacheable. All operations fall back to L1 only.
- Cooldown: wait 5 minutes.
- Resume: create a fresh Redis client and re-attach L2.
Operation timeout
All cacheable.get() calls are wrapped in a Promise.race with operationTimeout (default: 800ms). If Redis is slow but reachable, reads fall through as cache misses instead of blocking requests.
Redis Cluster support
Enable cluster mode for AWS MemoryDB or ElastiCache Cluster by setting VM_CACHE_ADAPTER_REDIS_CLUSTER=true (or redis.cluster: true in code config).
In cluster mode:
- The main cache client uses iovalkey
Cluster, which automatically handles slot routing andMOVED/ASKredirections. - Reads are scaled to replica nodes (
scaleReads: 'slave'). - Cache invalidation creates standalone Redis clients per master node (since
scanStreamis not available on the Cluster client). Scan clients are initialized lazily on the firstinvalidateByPatterncall to ensure the cluster topology has been discovered. - Invalidation uses
expires=0(logical expiry) instead ofDELETE/UNLINKto avoidCROSSSLOTerrors. Thecacheable/keyvlayer treats entries withexpires=0as expired on next read.
Installation
npm install @vita-mojo/service-cache-adapterUsage
NestJS module
import { CacheAdapterModule } from '@vita-mojo/service-cache-adapter';
@Module({
imports: [
CacheAdapterModule.register({
namespacePrefix: 'my-service',
}),
],
})
export class AppModule {}With custom config overrides:
CacheAdapterModule.registerWithConfig(
{
defaultTTL: ms('10m'),
redis: { host: 'redis.internal', port: 6379, tls: true },
},
{
namespacePrefix: 'my-service',
enableInvalidationEndpoint: true,
invalidationToken: process.env.CACHE_INVALIDATION_TOKEN,
}
);With async configuration (for DI-based config):
CacheAdapterModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
defaultTTL: configService.get('CACHE_TTL'),
defaultTimeout: 10_000,
minRetentionTTL: ms('30d'),
defaultStrategy: CacheStrategy.STALE_ON_ERROR,
redis: {
host: configService.get('REDIS_HOST'),
port: configService.get('REDIS_PORT'),
cluster: configService.get('REDIS_CLUSTER') === 'true',
tls: true,
},
}),
inject: [ConfigService],
enableInvalidationEndpoint: true,
invalidationToken: process.env.CACHE_INVALIDATION_TOKEN,
});Creating cached axios instances
import { AxiosCacheService } from '@vita-mojo/service-cache-adapter';
@Injectable()
export class CatalogService {
private readonly http: AxiosInstance;
constructor(private readonly axiosCacheService: AxiosCacheService) {
this.http = axiosCacheService.createCachedAxios({
baseURL: 'https://api.catalog.internal',
});
}
async getMenu(tenantUUID: string) {
const response = await this.http.get('menu', {
cache: {
namespace: 'catalog',
strategy: CacheStrategy.STALE_ON_ERROR,
ttl: ms('10m'),
},
});
return response.data;
}
}Or attach caching to an existing axios instance:
axiosCacheService.attachCaching(existingAxiosInstance);Per-request cache options
const response = await this.http.get('/endpoint', {
cache: {
namespace: 'my-namespace', // Required: cache key namespace
strategy: CacheStrategy.FORCE_STALE, // Override default strategy
ttl: 60_000, // Override TTL (ms)
timeout: 3000, // Override origin fetch timeout (ms)
bypassCache: false, // Skip caching entirely
relevantHeaders: ['authorization', 'tenant'], // Headers included in cache key
},
});Standalone (non-NestJS)
import { createCacheAdapter } from '@vita-mojo/service-cache-adapter';
const cache = createCacheAdapter({
defaultTTL: ms('10m'),
redis: { host: 'localhost', port: 6379 },
});
const client = cache.axiosCacheService.createCachedAxios({
baseURL: 'https://api.example.com',
});Cache invalidation
Programmatic:
import { CacheInvalidationService } from '@vita-mojo/service-cache-adapter';
// Invalidate by pattern
await cacheInvalidationService.invalidateByPattern('catalog:GET:*');
// Invalidate by namespace (shorthand for 'namespace:*')
await cacheInvalidationService.invalidateByNamespace('catalog');REST endpoint (when enableInvalidationEndpoint: true):
DELETE /cache/invalidate?pattern=catalog:GET:*
Authorization: Bearer <invalidationToken>Cache key format
{namespacePrefix}:{namespace}:{METHOD}:{url_hash}:{body_hash}:{headers_hash}- url_hash: SHA256 of normalized URL (trailing slash removed, query params sorted)
- body_hash: SHA256 of JSON-stringified body, or
nobodyif no body - headers_hash: SHA256 of sorted relevant headers (optional)
Default relevant headers: authorization, accept, content-type, tenant, store, menu, x-requested-from, locale.
Response headers
| Header | Description |
| ---------------- | ------------------------------------------------ |
| x-cache-status | HIT, MISS, or STALE |
| x-cache-date | ISO timestamp when the entry was originally cached |
| x-cache-age | Age of the cached entry in seconds |
Configuration
Environment variables
| Variable | Default | Description |
| ------------------------------------------ | ---------------- | -------------------------------------- |
| VM_CACHE_ADAPTER_DEFAULT_TIMEOUT | 10s | Origin fetch timeout |
| VM_CACHE_ADAPTER_DEFAULT_STRATEGY | stale-on-error | Default cache strategy |
| VM_CACHE_ADAPTER_DEFAULT_TTL | 5m | Time before entry is considered stale |
| VM_CACHE_ADAPTER_MIN_RETENTION_TTL | 30d | How long stale entries remain in Redis |
| VM_CACHE_ADAPTER_NAMESPACE | cache | Default cache namespace |
| VM_CACHE_ADAPTER_MEMORY_TTL | 1m | L1 memory cache TTL |
| VM_CACHE_ADAPTER_MEMORY_LRU_SIZE | 1000 | Max items in L1 before LRU eviction |
| VM_CACHE_ADAPTER_REDIS_HOST | localhost | Redis host |
| VM_CACHE_ADAPTER_REDIS_PORT | 6379 | Redis port |
| VM_CACHE_ADAPTER_REDIS_USERNAME | — | Redis username |
| VM_CACHE_ADAPTER_REDIS_PASSWORD | — | Redis password |
| VM_CACHE_ADAPTER_REDIS_DB | — | Redis database index |
| VM_CACHE_ADAPTER_REDIS_TLS | false | Enable TLS (required for AWS MemoryDB) |
| VM_CACHE_ADAPTER_REDIS_KEY_PREFIX | cache: | Key prefix for Redis |
| VM_CACHE_ADAPTER_REDIS_OPERATION_TIMEOUT | 800 | Redis connect/command timeout in ms |
| VM_CACHE_ADAPTER_REDIS_CLUSTER | false | Enable Redis Cluster mode |
Building
nx build service-cache-adapterTesting
nx test service-cache-adapter