{"record":{"id":"f1e56c03f7c4a2c9","repo":"koala73/worldmonitor","slug":"crypto-gap-cache-unavailable","errorCode":null,"errorMessage":"Crypto gap cache unavailable","messagePattern":"Crypto gap cache unavailable","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"server/worldmonitor/market/v1/list-crypto-quotes.ts","lineNumber":250,"sourceCode":"\n  let provider: 'seed' | 'upstream' | 'mixed' | 'degraded' =\n    gapIds.length === 0 ? 'seed' : (seedHits.length > 0 ? 'mixed' : 'upstream');\n\n  if (gapIds.length > 0 && !seedUnavailable) {\n    // Bounded upstream fetch, Redis-cached per sorted gap set. cacheFetcherErrors\n    // is false so provider failures rethrow and never write a negative cache.\n    // An empty provider result is treated as a negative (120s NEG_SENTINEL) via\n    // a `null` fetcher return, never as a positive 600s `{}` cache entry.\n    const cacheKey = `market:crypto:gap:v2:${await sha256Hex([...gapIds].sort().join(','))}`;\n    try {\n      const cached = await cachedFetchJson<Record<string, CryptoQuote>>(\n        cacheKey,\n        GAP_CACHE_TTL,\n        async () => {\n          // cachedFetchJson treats read errors as misses. Recheck inside its\n          // coalesced fetcher so an unreadable cache cannot trigger paid work.\n          if ((await readCachedJson(cacheKey)).status === 'error') {\n            throw new Error('Crypto gap cache unavailable');\n          }\n          const got = await fetchGapQuotes(gapIds);\n          return got.size > 0 ? Object.fromEntries(got) : null;\n        },\n        120,\n        { timeoutMs: 15_000, cacheFetcherErrors: false },\n      );\n      if (cached) {\n        for (const [id, quote] of Object.entries(cached)) resolved.set(id, quote);\n      }\n    } catch (err) {\n      // sentry-coverage-ok: a provider/upstream failure degrades to explicit unresolvedIds + the 3s local backoff; never a hidden drop or poisoned cache.\n      console.warn('[crypto-quotes] upstream gap fetch failed:', (err as Error).message);\n    }\n\n    // Any gap id the provider could not resolve gets a relay attempt (separate\n    // egress IP). Relay results are applied per-request and not Redis-cached.\n    const stillMissing = gapIds.filter((id) => !resolved.has(id));","sourceCodeStart":232,"sourceCodeEnd":268,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/server/worldmonitor/market/v1/list-crypto-quotes.ts#L232-L268","documentation":"Inside the coalesced cache fetcher, the code re-reads the gap-quotes cache key and, if readCachedJson reports status 'error' (the cache backend itself is unreadable), throws 'Crypto gap cache unavailable'. This deliberately aborts the fetcher so an unhealthy cache cannot trigger paid third-party quote fetches (cachedFetchJson treats read errors as misses, which would otherwise cause paid API work).","triggerScenarios":"listCryptoQuotes requests gap quotes while the Redis/Upstash cache read for cacheKey fails — connection error, auth failure, or timeout on the cache backend — inside the GAP_CACHE_TTL fetcher.","commonSituations":"Upstash Redis outage or credentials rotated/revoked, VPC/firewall blocking the cache endpoint, cache client misconfigured in the environment, or transient cache network blips coinciding with high crypto-quote demand.","solutions":["Check the cache backend (Upstash Redis) health and credentials (REST token/URL) in the environment","Retry the request; if the cache read recovers, the fetcher proceeds normally","If cache reads keep failing, the intent is to fail closed rather than spend paid quote API calls — restore cache connectivity first","Monitor readCachedJson error rates and alert on cache-backend failures to catch this before users do"],"exampleFix":"// before\nif ((await readCachedJson(cacheKey)).status === 'error') {\n  throw new Error('Crypto gap cache unavailable');\n}\n// after\nconst cacheRead = await readCachedJson(cacheKey);\nif (cacheRead.status === 'error') {\n  console.error('[list-crypto-quotes] cache read failed for', cacheKey, cacheRead.error);\n  throw new Error('Crypto gap cache unavailable');\n}","handlingStrategy":"retry","validationCode":"// check cache reachability before issuing quote requests\nconst healthy = await readCachedJson('health:ping').then(r => r.status !== 'error').catch(() => false);\nif (!healthy) console.warn('quote cache backend unhealthy');","typeGuard":"function isCacheHit(r: { status: string; value?: unknown }): r is { status: 'ok'; value: unknown } {\n  return r.status === 'ok';\n}","tryCatchPattern":"try {\n  const quotes = await listCryptoQuotes(ids);\n} catch (e) {\n  if (e.message === 'Crypto gap cache unavailable') {\n    await backoffRetry(() => listCryptoQuotes(ids), { attempts: 3, baseMs: 1000 });\n  } else throw e;\n}","preventionTips":["Monitor Redis/Upstash health and auth token expiry proactively","Alert on readCachedJson error-rate spikes","Understand this is a deliberate fail-closed guard against paid API spend — fix the cache, don't bypass it"],"tags":["cache","redis","fail-closed"],"backgroundTag":"cache-read-failed","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-09-15T16:44:39.439Z","contentChangedAt":"2026-09-15T16:44:39.439Z","schemaVersion":2},"datasetVersion":"2026-09-15T18:17:12.389Z"}