@pioneer-platform/pioneer-cache 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ /*
3
+ PortfolioCache - Portfolio/Charts cache implementation
4
+
5
+ Extends BaseCache with portfolio-specific logic.
6
+ Designed for NON-BLOCKING, instant returns.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.PortfolioCache = void 0;
10
+ const base_cache_1 = require("../core/base-cache");
11
+ const log = require('@pioneer-platform/loggerdog')();
12
+ /**
13
+ * PortfolioCache - Caches portfolio/chart data
14
+ *
15
+ * CRITICAL: This cache is NON-BLOCKING by design
16
+ * - Returns empty arrays immediately on cache miss
17
+ * - Never blocks waiting for blockchain APIs
18
+ * - Background jobs populate cache for next request
19
+ */
20
+ class PortfolioCache extends base_cache_1.BaseCache {
21
+ constructor(redis, balanceModule, marketsModule, config) {
22
+ const defaultConfig = {
23
+ name: 'portfolio',
24
+ keyPrefix: 'portfolio_v2:',
25
+ ttl: 0, // Ignored when enableTTL: false
26
+ staleThreshold: 5 * 60 * 1000, // 5 minutes - triggers background refresh
27
+ enableTTL: false, // NEVER EXPIRE - data persists forever, show stale data instantly
28
+ queueName: 'cache-refresh',
29
+ enableQueue: true,
30
+ maxRetries: 3,
31
+ retryDelay: 5000,
32
+ blockOnMiss: false, // CRITICAL: NEVER WAIT! Return empty arrays instantly
33
+ enableLegacyFallback: false, // No legacy portfolio cache format
34
+ defaultValue: {
35
+ pubkeys: [],
36
+ charts: [],
37
+ totalValueUsd: 0,
38
+ timestamp: Date.now()
39
+ },
40
+ useSyncFallback: false, // CRITICAL: NEVER use synchronous fallback - always return instantly
41
+ maxConcurrentJobs: 3, // Limit concurrent portfolio refreshes
42
+ apiTimeout: 30000, // 30s timeout for full portfolio fetch
43
+ logCacheHits: true,
44
+ logCacheMisses: true,
45
+ logRefreshJobs: true
46
+ };
47
+ super(redis, { ...defaultConfig, ...config });
48
+ this.balanceModule = balanceModule;
49
+ this.marketsModule = marketsModule;
50
+ }
51
+ /**
52
+ * Build Redis key for portfolio data
53
+ *
54
+ * Key strategy: Hash all pubkeys+caips to create a stable identifier
55
+ * Format: portfolio_v2:hash(pubkeys)
56
+ *
57
+ * This allows caching the same portfolio regardless of pubkey order
58
+ */
59
+ buildKey(params) {
60
+ const { pubkeys } = params;
61
+ if (!pubkeys || !Array.isArray(pubkeys) || pubkeys.length === 0) {
62
+ throw new Error('PortfolioCache.buildKey: pubkeys array required');
63
+ }
64
+ // Sort pubkeys to create stable hash regardless of order
65
+ const sorted = [...pubkeys].sort((a, b) => {
66
+ const aKey = `${a.caip}:${a.pubkey}`;
67
+ const bKey = `${b.caip}:${b.pubkey}`;
68
+ return aKey.localeCompare(bKey);
69
+ });
70
+ // Create a simple hash from sorted pubkeys
71
+ const keyString = sorted.map(p => `${p.caip}:${p.pubkey}`).join('|');
72
+ const hash = this.simpleHash(keyString);
73
+ return `${this.config.keyPrefix}${hash}`;
74
+ }
75
+ /**
76
+ * Simple hash function for cache keys
77
+ * Not cryptographic - just needs to be stable and collision-resistant
78
+ */
79
+ simpleHash(str) {
80
+ let hash = 0;
81
+ for (let i = 0; i < str.length; i++) {
82
+ const char = str.charCodeAt(i);
83
+ hash = ((hash << 5) - hash) + char;
84
+ hash = hash & hash; // Convert to 32-bit integer
85
+ }
86
+ return Math.abs(hash).toString(36);
87
+ }
88
+ /**
89
+ * Fetch portfolio from blockchain APIs
90
+ *
91
+ * This is the SLOW operation that happens in the background
92
+ * It fetches balances for all pubkeys and enriches with pricing
93
+ */
94
+ async fetchFromSource(params) {
95
+ const tag = this.TAG + 'fetchFromSource | ';
96
+ const startTime = Date.now();
97
+ try {
98
+ const { pubkeys } = params;
99
+ log.info(tag, `Fetching portfolio for ${pubkeys.length} pubkeys`);
100
+ const charts = [];
101
+ // Fetch balances for all pubkeys in parallel
102
+ const balancePromises = pubkeys.map(async (item) => {
103
+ try {
104
+ // Extract networkId from CAIP
105
+ const networkId = item.caip.split('/')[0];
106
+ // Fetch balance
107
+ const asset = { caip: item.caip };
108
+ const owner = { pubkey: item.pubkey };
109
+ const balanceInfo = await this.balanceModule.getBalance(asset, owner);
110
+ if (!balanceInfo || !balanceInfo.balance) {
111
+ log.debug(tag, `No balance for ${item.caip}/${item.pubkey.substring(0, 10)}...`);
112
+ return null;
113
+ }
114
+ // Skip zero balances
115
+ const balanceNum = parseFloat(balanceInfo.balance);
116
+ if (isNaN(balanceNum) || balanceNum === 0) {
117
+ return null;
118
+ }
119
+ // Get asset metadata
120
+ const assetData = require('@pioneer-platform/pioneer-discovery').assetData;
121
+ const assetInfo = assetData[item.caip.toUpperCase()] || assetData[item.caip.toLowerCase()] || {};
122
+ // Get price
123
+ let priceUsd = 0;
124
+ try {
125
+ priceUsd = await this.marketsModule.getAssetPriceByCaip(item.caip);
126
+ if (isNaN(priceUsd) || priceUsd < 0) {
127
+ priceUsd = 0;
128
+ }
129
+ }
130
+ catch (priceError) {
131
+ log.warn(tag, `Error fetching price for ${item.caip}:`, priceError);
132
+ }
133
+ const valueUsd = balanceNum * priceUsd;
134
+ const chartData = {
135
+ caip: item.caip,
136
+ pubkey: item.pubkey,
137
+ networkId,
138
+ symbol: assetInfo.symbol || 'UNKNOWN',
139
+ name: assetInfo.name || 'Unknown Asset',
140
+ balance: balanceInfo.balance,
141
+ priceUsd,
142
+ valueUsd,
143
+ icon: assetInfo.icon || '',
144
+ type: assetInfo.type || 'native',
145
+ decimal: assetInfo.decimal
146
+ };
147
+ return chartData;
148
+ }
149
+ catch (error) {
150
+ log.error(tag, `Error fetching balance for ${item.caip}/${item.pubkey}:`, error);
151
+ return null;
152
+ }
153
+ });
154
+ const results = await Promise.all(balancePromises);
155
+ // Filter out nulls and calculate total
156
+ const validCharts = results.filter((c) => c !== null);
157
+ const totalValueUsd = validCharts.reduce((sum, c) => sum + c.valueUsd, 0);
158
+ const fetchTime = Date.now() - startTime;
159
+ log.info(tag, `✅ Fetched portfolio: ${validCharts.length} assets, $${totalValueUsd.toFixed(2)} in ${fetchTime}ms`);
160
+ return {
161
+ pubkeys,
162
+ charts: validCharts,
163
+ totalValueUsd,
164
+ timestamp: Date.now()
165
+ };
166
+ }
167
+ catch (error) {
168
+ log.error(tag, 'Error fetching portfolio:', error);
169
+ throw error;
170
+ }
171
+ }
172
+ /**
173
+ * No legacy cache format for portfolios
174
+ */
175
+ async getLegacyCached(params) {
176
+ return null;
177
+ }
178
+ /**
179
+ * Get portfolio for a set of pubkeys
180
+ * Convenience method that wraps base get()
181
+ *
182
+ * RETURNS INSTANTLY - either cached data or empty arrays
183
+ */
184
+ async getPortfolio(pubkeys, waitForFresh) {
185
+ const result = await this.get({ pubkeys }, waitForFresh);
186
+ return result.value || this.config.defaultValue;
187
+ }
188
+ }
189
+ exports.PortfolioCache = PortfolioCache;
@@ -20,7 +20,8 @@ export declare class PriceCache extends BaseCache<PriceData> {
20
20
  */
21
21
  protected buildKey(params: Record<string, any>): string;
22
22
  /**
23
- * Fetch price from markets API
23
+ * Fetch price from markets API using CAIP-first approach
24
+ * FIX #7: Graceful handling of zero prices to prevent cache disruption
24
25
  */
25
26
  protected fetchFromSource(params: Record<string, any>): Promise<PriceData>;
26
27
  /**
@@ -17,21 +17,22 @@ class PriceCache extends base_cache_1.BaseCache {
17
17
  const defaultConfig = {
18
18
  name: 'price',
19
19
  keyPrefix: 'price_v2:',
20
- ttl: 60 * 60 * 1000, // 1 hour
21
- staleThreshold: 30 * 60 * 1000, // 30 minutes (refresh after 30min)
22
- enableTTL: true,
23
- queueName: 'price-refresh-v2',
20
+ ttl: 0, // Ignored when enableTTL: false
21
+ staleThreshold: 30 * 60 * 1000, // 30 minutes - triggers background refresh
22
+ enableTTL: false, // NEVER EXPIRE - data persists forever, show stale prices instantly
23
+ queueName: 'cache-refresh',
24
24
  enableQueue: true,
25
25
  maxRetries: 3,
26
26
  retryDelay: 5000,
27
- blockOnMiss: false, // Return immediately with $0 (prices less critical)
27
+ blockOnMiss: false, // CRITICAL: NEVER WAIT! Return $0 instantly on cache miss, refresh async in background
28
28
  enableLegacyFallback: true,
29
29
  defaultValue: {
30
30
  caip: '',
31
31
  price: 0
32
32
  },
33
+ useSyncFallback: false, // CRITICAL: NEVER use synchronous fallback - always return instantly with $0
33
34
  maxConcurrentJobs: 5,
34
- apiTimeout: 5000,
35
+ apiTimeout: 2000, // Reduced from 5000ms for faster failures
35
36
  logCacheHits: false,
36
37
  logCacheMisses: true,
37
38
  logRefreshJobs: true
@@ -52,49 +53,53 @@ class PriceCache extends base_cache_1.BaseCache {
52
53
  return `${this.config.keyPrefix}${normalizedCaip}`;
53
54
  }
54
55
  /**
55
- * Fetch price from markets API
56
+ * Fetch price from markets API using CAIP-first approach
57
+ * FIX #7: Graceful handling of zero prices to prevent cache disruption
56
58
  */
57
59
  async fetchFromSource(params) {
58
60
  const tag = this.TAG + 'fetchFromSource | ';
59
61
  try {
60
62
  const { caip } = params;
61
- // Map CAIP to symbol for markets API
62
- const assetData = require('@pioneer-platform/pioneer-discovery').assetData;
63
- const asset = assetData[caip.toUpperCase()] || assetData[caip.toLowerCase()];
64
- if (!asset || !asset.symbol) {
65
- log.warn(tag, `No asset mapping found for ${caip}`);
66
- return {
67
- caip,
68
- price: 0
69
- };
70
- }
71
- // Get price from markets module
72
- const symbol = asset.symbol.toLowerCase();
73
- const priceResult = await this.markets.getPrice(symbol);
74
- // Handle different response formats
75
- let price = 0;
76
- if (typeof priceResult === 'object' && priceResult.price) {
77
- price = parseFloat(priceResult.price);
78
- }
79
- else if (typeof priceResult === 'number') {
80
- price = priceResult;
81
- }
63
+ // Use CAIP-first API (no symbol conversion needed!)
64
+ // This directly queries the markets module with CAIP identifiers
65
+ const price = await this.markets.getAssetPriceByCaip(caip);
66
+ // FIX #7: Gracefully handle zero prices without throwing
67
+ // This prevents disrupting batch operations during API rate limits
82
68
  if (isNaN(price) || price <= 0) {
83
- log.warn(tag, `Invalid price for ${caip} (${symbol}): ${priceResult}`);
84
- return {
85
- caip,
86
- price: 0
87
- };
69
+ log.warn(tag, `Price fetch returned $${price} for ${caip} (likely API timeout or rate limit) - returning stale cache if available`);
70
+ // Try to get stale cached value instead of failing
71
+ const key = this.buildKey(params);
72
+ const cachedValue = await this.getCached(key);
73
+ if (cachedValue && cachedValue.value.price > 0) {
74
+ log.info(tag, `Returning stale cached price for ${caip}: $${cachedValue.value.price}`);
75
+ return cachedValue.value;
76
+ }
77
+ // Try legacy cache as fallback
78
+ const legacyValue = await this.getLegacyCached(params);
79
+ if (legacyValue && legacyValue.price > 0) {
80
+ log.info(tag, `Returning legacy cached price for ${caip}: $${legacyValue.price}`);
81
+ return legacyValue;
82
+ }
83
+ // Last resort: return zero price but don't cache it
84
+ log.warn(tag, `No cached price available for ${caip}, returning zero`);
85
+ throw new Error(`No valid price available for ${caip}`);
88
86
  }
89
- log.debug(tag, `Fetched price for ${caip} (${symbol}): $${price}`);
87
+ log.debug(tag, `Fetched price for ${caip}: $${price}`);
90
88
  return {
91
89
  caip,
92
90
  price,
93
- source: 'markets'
91
+ source: 'markets-caip'
94
92
  };
95
93
  }
96
94
  catch (error) {
97
- log.error(tag, 'Error fetching price:', error);
95
+ // Log as warning instead of error for expected API issues
96
+ const errorMsg = error instanceof Error ? error.message : String(error);
97
+ if (errorMsg.includes('rate limit') || errorMsg.includes('timeout') || errorMsg.includes('No valid price')) {
98
+ log.warn(tag, `Expected API issue: ${errorMsg}`);
99
+ }
100
+ else {
101
+ log.error(tag, `Unexpected error fetching price:`, error);
102
+ }
98
103
  throw error;
99
104
  }
100
105
  }
@@ -14,6 +14,7 @@ export interface CacheConfig {
14
14
  blockOnMiss: boolean;
15
15
  enableLegacyFallback: boolean;
16
16
  defaultValue: any;
17
+ useSyncFallback?: boolean;
17
18
  maxConcurrentJobs: number;
18
19
  apiTimeout: number;
19
20
  logCacheHits: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pioneer-platform/pioneer-cache",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Unified caching system for Pioneer platform with Redis backend",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -20,9 +20,9 @@
20
20
  "author": "Pioneer Platform",
21
21
  "license": "MIT",
22
22
  "dependencies": {
23
- "@pioneer-platform/loggerdog": "workspace:*",
24
- "@pioneer-platform/redis-queue": "workspace:*",
25
- "@pioneer-platform/default-redis": "workspace:*"
23
+ "@pioneer-platform/loggerdog": "^8.0.0",
24
+ "@pioneer-platform/redis-queue": "^8.0.0",
25
+ "@pioneer-platform/default-redis": "^8.0.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^20.0.0",
@@ -31,6 +31,10 @@ export abstract class BaseCache<T> {
31
31
  private cachedStatsTimestamp: number = 0;
32
32
  private readonly STATS_CACHE_TTL = 30000; // 30 seconds
33
33
 
34
+ // FIX #6: Request deduplication to prevent thundering herd
35
+ // Tracks in-flight network requests to prevent duplicate API calls
36
+ private pendingFetches: Map<string, Promise<T>> = new Map();
37
+
34
38
  constructor(redis: any, config: CacheConfig) {
35
39
  this.redis = redis;
36
40
  this.config = config;
@@ -73,10 +77,14 @@ export abstract class BaseCache<T> {
73
77
  const startTime = Date.now();
74
78
 
75
79
  try {
80
+ const t1 = Date.now();
76
81
  const key = this.buildKey(params);
82
+ log.info(tag, `⏱️ buildKey took ${Date.now() - t1}ms`);
77
83
 
78
84
  // Step 1: Try new cache format
85
+ const t2 = Date.now();
79
86
  const cachedValue = await this.getCached(key);
87
+ log.info(tag, `⏱️ getCached took ${Date.now() - t2}ms`);
80
88
 
81
89
  if (cachedValue) {
82
90
  const age = Date.now() - cachedValue.timestamp;
@@ -102,7 +110,9 @@ export abstract class BaseCache<T> {
102
110
 
103
111
  // Step 2: Try legacy cache fallback
104
112
  if (this.config.enableLegacyFallback) {
113
+ const t3 = Date.now();
105
114
  const legacyValue = await this.getLegacyCached(params);
115
+ log.info(tag, `⏱️ getLegacyCached took ${Date.now() - t3}ms`);
106
116
  if (legacyValue) {
107
117
  const responseTime = Date.now() - startTime;
108
118
  log.info(tag, `Legacy cache hit: ${key} (${responseTime}ms)`);
@@ -141,7 +151,10 @@ export abstract class BaseCache<T> {
141
151
  }
142
152
 
143
153
  // Non-blocking: trigger async refresh and return default
154
+ const t4 = Date.now();
144
155
  this.triggerAsyncRefresh(params, 'high');
156
+ log.info(tag, `⏱️ triggerAsyncRefresh took ${Date.now() - t4}ms`);
157
+ log.info(tag, `⏱️ Returning default value after ${Date.now() - startTime}ms TOTAL`);
145
158
 
146
159
  return {
147
160
  success: true,
@@ -168,22 +181,37 @@ export abstract class BaseCache<T> {
168
181
  */
169
182
  protected async getCached(key: string): Promise<CachedValue<T> | null> {
170
183
  const tag = this.TAG + 'getCached | ';
184
+ const t0 = Date.now();
171
185
 
172
186
  try {
173
- const cached = await this.redis.get(key);
187
+ // Redis timeout for cache reads
188
+ // Increased from 100ms to 2000ms - 100ms was too aggressive and caused false cache misses
189
+ // Increased from 2000ms to 10000ms - IPv4/IPv6 DNS resolution can cause delays
190
+ const timeoutMs = 10000;
191
+ const cached = await Promise.race([
192
+ this.redis.get(key),
193
+ new Promise<null>((resolve) => setTimeout(() => {
194
+ log.warn(tag, `⏱️ Redis timeout after ${timeoutMs}ms, returning cache miss`);
195
+ resolve(null);
196
+ }, timeoutMs))
197
+ ]);
198
+
174
199
  if (!cached) {
200
+ log.debug(tag, `Cache miss: ${key}`);
175
201
  return null;
176
202
  }
177
203
 
178
204
  const parsed: CachedValue<T> = JSON.parse(cached);
179
205
 
180
- // Validate structure
181
- if (!parsed.value || typeof parsed.timestamp !== 'number') {
206
+ // Validate structure - Check for undefined/null, NOT falsy values!
207
+ // CRITICAL: Balance "0", empty arrays [], and empty objects {} are VALID!
208
+ if (parsed.value === undefined || parsed.value === null || typeof parsed.timestamp !== 'number') {
182
209
  log.warn(tag, `Invalid cache structure for ${key}, removing`);
183
210
  await this.redis.del(key);
184
211
  return null;
185
212
  }
186
213
 
214
+ log.debug(tag, `Cache hit: ${key}`);
187
215
  return parsed;
188
216
 
189
217
  } catch (error) {
@@ -208,19 +236,33 @@ export abstract class BaseCache<T> {
208
236
  metadata
209
237
  };
210
238
 
239
+ // DIAGNOSTIC: Log Redis connection details
240
+ const redisConstructor = this.redis.constructor?.name || 'unknown';
241
+ const redisOptions = this.redis.options || {};
242
+
211
243
  // FIX #2: Always set TTL (unless explicitly disabled)
212
244
  if (this.config.enableTTL) {
213
245
  const ttlSeconds = Math.floor(this.config.ttl / 1000);
246
+
247
+ // PERF: Reduced logging for production performance
248
+ log.debug(tag, `📝 Writing to Redis: ${key} [${JSON.stringify(cachedValue).length} bytes, TTL: ${ttlSeconds}s]`);
249
+
250
+ // Write
214
251
  await this.redis.set(key, JSON.stringify(cachedValue), 'EX', ttlSeconds);
215
- log.info(tag, `Updated cache: ${key} [TTL: ${ttlSeconds}s]`);
252
+
253
+ // PERF: Verification disabled for production performance
254
+ // Only enable for debugging cache issues
255
+ log.debug(tag, `✅ Updated cache: ${key} [TTL: ${ttlSeconds}s]`);
216
256
  } else {
217
257
  // Permanent caching (for transactions)
218
258
  await this.redis.set(key, JSON.stringify(cachedValue));
219
- log.info(tag, `Updated cache: ${key} [PERMANENT]`);
259
+
260
+ // PERF: Verification disabled for production performance
261
+ log.debug(tag, `✅ Updated cache: ${key} [PERMANENT]`);
220
262
  }
221
263
 
222
264
  } catch (error) {
223
- log.error(tag, `Error updating cache for ${key}:`, error);
265
+ log.error(tag, `❌ Error updating cache for ${key}:`, error);
224
266
  throw error;
225
267
  }
226
268
  }
@@ -245,8 +287,13 @@ export abstract class BaseCache<T> {
245
287
  log.error(tag, `❌ QUEUE NOT INITIALIZED! Cannot refresh ${key}`);
246
288
  log.error(tag, `Background refresh is BROKEN - cache will NOT update!`);
247
289
 
248
- // FIX #4: Synchronous fallback for high-priority
249
- if (priority === 'high') {
290
+ // FIX #4: Synchronous fallback for high-priority (only if useSyncFallback is enabled)
291
+ // Default: use sync fallback only for blocking caches (blockOnMiss=true)
292
+ const shouldUseSyncFallback = this.config.useSyncFallback !== undefined
293
+ ? this.config.useSyncFallback
294
+ : this.config.blockOnMiss;
295
+
296
+ if (priority === 'high' && shouldUseSyncFallback) {
250
297
  log.warn(tag, `Using synchronous fallback for high-priority refresh`);
251
298
  setImmediate(async () => {
252
299
  try {
@@ -290,31 +337,82 @@ export abstract class BaseCache<T> {
290
337
  /**
291
338
  * Fetch fresh data and update cache
292
339
  * FIX #1 & #4: Used for blocking requests and fallback
340
+ * FIX #6: Request deduplication to prevent thundering herd
341
+ * FIX #8: Return stale cache on fetch failures
293
342
  */
294
343
  async fetchFresh(params: Record<string, any>): Promise<T> {
295
344
  const tag = this.TAG + 'fetchFresh | ';
296
345
  const startTime = Date.now();
346
+ const key = this.buildKey(params);
347
+
348
+ // FIX #6: Check if there's already a pending fetch for this key
349
+ const existingFetch = this.pendingFetches.get(key);
350
+ if (existingFetch) {
351
+ // For non-blocking caches, return default value immediately instead of waiting
352
+ if (!this.config.blockOnMiss) {
353
+ log.debug(tag, `Non-blocking cache: returning default value while fetch in progress: ${key}`);
354
+ return this.config.defaultValue;
355
+ }
297
356
 
298
- try {
299
- const key = this.buildKey(params);
300
- log.info(tag, `Fetching fresh data: ${key}`);
357
+ // For blocking caches, coalesce to prevent thundering herd
358
+ log.debug(tag, `Coalescing request for: ${key} (fetch already in progress)`);
359
+ return existingFetch;
360
+ }
361
+
362
+ // Create the fetch promise
363
+ const fetchPromise = (async () => {
364
+ try {
365
+ log.info(tag, `Fetching fresh data: ${key}`);
301
366
 
302
- // Call subclass-specific fetch implementation
303
- const value = await this.fetchFromSource(params);
367
+ // Call subclass-specific fetch implementation
368
+ const value = await this.fetchFromSource(params);
304
369
 
305
- // Update cache
306
- await this.updateCache(key, value);
370
+ // Update cache
371
+ await this.updateCache(key, value);
307
372
 
308
- const fetchTime = Date.now() - startTime;
309
- log.info(tag, `✅ Fetched fresh data in ${fetchTime}ms: ${key}`);
373
+ const fetchTime = Date.now() - startTime;
374
+ log.info(tag, `✅ Fetched fresh data in ${fetchTime}ms: ${key}`);
310
375
 
311
- return value;
376
+ return value;
312
377
 
313
- } catch (error) {
314
- const fetchTime = Date.now() - startTime;
315
- log.error(tag, `Failed to fetch fresh data after ${fetchTime}ms:`, error);
316
- return this.config.defaultValue;
317
- }
378
+ } catch (error) {
379
+ const fetchTime = Date.now() - startTime;
380
+ const errorMsg = error instanceof Error ? error.message : String(error);
381
+
382
+ // FIX #8: Try to return stale cache on fetch failures
383
+ const cachedValue = await this.getCached(key);
384
+ if (cachedValue) {
385
+ log.warn(tag, `Fetch failed after ${fetchTime}ms, returning stale cache: ${key}`);
386
+ return cachedValue.value;
387
+ }
388
+
389
+ // Try legacy cache as last resort
390
+ if (this.config.enableLegacyFallback) {
391
+ const legacyValue = await this.getLegacyCached(params);
392
+ if (legacyValue) {
393
+ log.warn(tag, `Fetch failed after ${fetchTime}ms, returning legacy cache: ${key}`);
394
+ return legacyValue;
395
+ }
396
+ }
397
+
398
+ // Log as warning for expected issues, error for unexpected
399
+ if (errorMsg.includes('rate limit') || errorMsg.includes('timeout') || errorMsg.includes('No valid')) {
400
+ log.warn(tag, `Expected fetch failure after ${fetchTime}ms: ${errorMsg}`);
401
+ } else {
402
+ log.error(tag, `Unexpected fetch failure after ${fetchTime}ms:`, error);
403
+ }
404
+
405
+ return this.config.defaultValue;
406
+ } finally {
407
+ // Clean up pending fetch
408
+ this.pendingFetches.delete(key);
409
+ }
410
+ })();
411
+
412
+ // Store the promise so concurrent requests can reuse it
413
+ this.pendingFetches.set(key, fetchPromise);
414
+
415
+ return fetchPromise;
318
416
  }
319
417
 
320
418
  /**