{"record":{"id":"9f5e3a2e33b16bc7","repo":"koala73/worldmonitor","slug":"cyber-threats-unavailable","errorCode":null,"errorMessage":"Cyber threats unavailable","messagePattern":"Cyber threats unavailable","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/services/cyber/index.ts","lineNumber":112,"sourceCode":"    breaker.recordSuccess({ threats: hydrated.threats }, AVAILABLE_CACHE_KEY);\n    return hydrated.threats.map(toCyberThreat);\n  }\n\n  const limit = clampInt(options.limit, DEFAULT_LIMIT, 1, MAX_LIMIT);\n  const days = clampInt(options.days, DEFAULT_DAYS, 1, MAX_DAYS);\n  const now = Date.now();\n\n  const resp = await breaker.execute(async () => {\n    const response = await client.listCyberThreats({\n      start: now - days * 24 * 60 * 60 * 1000,\n      end: now,\n      pageSize: limit,\n      cursor: '',\n      type: 'CYBER_THREAT_TYPE_UNSPECIFIED',\n      source: 'CYBER_THREAT_SOURCE_UNSPECIFIED',\n      minSeverity: 'CRITICALITY_LEVEL_UNSPECIFIED',\n    });\n    if (!isCyberThreatSnapshot(response)) throw new Error('Cyber threats unavailable');\n    return response;\n  }, emptyFallback, { cacheKey: AVAILABLE_CACHE_KEY, shouldCache: isCyberThreatSnapshot });\n\n  if (breaker.getDataState().mode === 'unavailable') throw new Error('Cyber threats unavailable');\n  return resp.threats.map(toCyberThreat);\n}\n","sourceCodeStart":94,"sourceCodeEnd":119,"githubUrl":"https://github.com/koala73/worldmonitor/blob/e586b8b4b80f595aa7ece295eec10d76f2921240/src/services/cyber/index.ts#L94-L119","documentation":"fetchCyberThreats throws 'Cyber threats unavailable' inside the breaker execute callback when the ListCyberThreats RPC response fails the isCyberThreatSnapshot structural check (src/services/cyber/index.ts:112). The snapshot guard validates the response actually contains a threats collection shaped per the shared contract; a default/empty proto response or malformed payload is rejected so the breaker records a failure and serves the empty fallback.","triggerScenarios":"client.listCyberThreats resolves with a response lacking a valid threats array (e.g. empty default proto message), or the ensureHydrated('cyberThreats') bootstrap snapshot was invalid so execution fell through to the RPC and the RPC also returned a non-snapshot payload.","commonSituations":"Cyber data worker not running or its Redis/CDN snapshot empty on a fresh deployment; the CDN-shielded per-key bootstrap fetch (cyberThreats) fails hydration and the RPC endpoint also returns an empty page; proto regeneration drift making the response fail the shared snapshot type guard; wrong RPC base URL in the environment.","solutions":["Confirm the cyber threats worker produces a valid snapshot and that hydrating 'cyberThreats' succeeds (check the bootstrap key and CDN URL).","Call listCyberThreats manually against getRpcBaseUrl() with the same request (pageSize, start/end window) and inspect whether threats array is populated.","Regenerate clients from proto (make generate) and re-verify isCyberThreatSnapshot in shared/cyber-threat-snapshot matches the current response shape.","Check breaker state: after repeated failures it may open and short-circuit to emptyFallback — reset/reload after the producer is fixed.","Verify the cyber layer enable gate: fetchCyberThreats is on the on-demand path; ensure the panel actually passed the gate and hydration was attempted with the correct key."],"exampleFix":"// before\nconst response = await client.listCyberThreats({ ... });\nif (!isCyberThreatSnapshot(response)) throw new Error('Cyber threats unavailable');\nreturn response;\n// after\nconst response = await client.listCyberThreats({ ... });\nif (!isCyberThreatSnapshot(response)) {\n  console.warn('[cyber] non-snapshot response:', response);\n  throw new Error('Cyber threats unavailable');\n}\nreturn response;","handlingStrategy":"try-catch","validationCode":"const hydrated = await ensureHydrated('cyberThreats');\nif (!isCyberThreatSnapshot(hydrated)) {\n  console.warn('cyber bootstrap snapshot invalid; RPC path may also fail');\n}","typeGuard":"// reuse the shared contract guard\nimport { isCyberThreatSnapshot } from '@/shared/cyber-threat-snapshot';\nconst ok = isCyberThreatSnapshot(response); // boolean narrowing to ListCyberThreatsResponse","tryCatchPattern":"try {\n  const threats = await fetchCyberThreats({ limit: 500 });\n  renderThreats(threats);\n} catch (err) {\n  if (err.message === 'Cyber threats unavailable') renderCyberLayerOffline();\n  else throw err;\n}","preventionTips":["Keep the cyberThreats bootstrap key fresh on the CDN so hydration succeeds and the RPC path is rarely needed","Regenerate proto clients after schema changes to keep isCyberThreatSnapshot passing","Health-check the cyber worker before enabling the cyber layer in the UI","Log which snapshot predicate fails so producer regressions surface quickly"],"tags":["rpc","validation","bootstrap","grpc"],"backgroundTag":"unexpected-api-response-shape","analyzedSha":"e586b8b4b80f595aa7ece295eec10d76f2921240","analyzedAt":"2026-09-22T01:50:49.965Z","contentChangedAt":"2026-09-22T01:50:49.965Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}