diff --git a/eslint.config.js b/eslint.config.js index f86a3c1..3567f65 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,6 +28,21 @@ module.exports = defineConfig([ }, }, rules: { + // #809: Require a justification comment on every @ts-ignore or @ts-expect-error. + // @ts-ignore suppresses ALL type errors on the next line without verifying they + // still exist; it is easy to forget and leaves dead suppressions in the codebase. + // @ts-expect-error is safer (fails if the suppression is no longer needed) but + // still requires a minimum-length reason so reviewers can evaluate legitimacy. + '@typescript-eslint/ban-ts-comment': [ + 'error', + { + 'ts-ignore': 'allow-with-description', + 'ts-expect-error': 'allow-with-description', + 'ts-nocheck': true, + 'ts-check': false, + minimumDescriptionLength: 10, + }, + ], 'import/order': [ 'warn', { diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts index dd6c6d2..ff8d20d 100644 --- a/src/services/api/axios.config.ts +++ b/src/services/api/axios.config.ts @@ -106,6 +106,50 @@ function invalidateSuccessfulMutationCache(config: InternalAxiosRequestConfig): invalidateCacheForMutation(method, url); } +// ─── Batched cache statistics (#811) ────────────────────────────────────────── +// +// Computing cache hit-rate on every successful response adds JS-thread overhead +// proportional to request frequency. Instead, we keep two integer counters and +// flush aggregated stats to the logger on a 60-second interval, reducing +// per-response work to a single increment. + +interface CacheStats { + hits: number; + misses: number; +} + +const _cacheStats: CacheStats = { hits: 0, misses: 0 }; +const CACHE_STATS_INTERVAL_MS = 60_000; + +/** Call when the SWR cache serves a response without a network round-trip. */ +export function recordCacheHit(): void { + _cacheStats.hits++; +} + +/** Call when a full network request was needed (no usable cached value). */ +export function recordCacheMiss(): void { + _cacheStats.misses++; +} + +function flushCacheStats(): void { + const { hits, misses } = _cacheStats; + const total = hits + misses; + if (total === 0) return; + const hitRate = ((hits / total) * 100).toFixed(1); + appLogger.infoSync( + `[CacheStats] ${hitRate}% hit rate over last 60 s (${hits}/${total} requests served from cache)`, + { cacheHits: hits, cacheMisses: misses, hitRatePct: parseFloat(hitRate) } + ); + _cacheStats.hits = 0; + _cacheStats.misses = 0; +} + +/** Flush immediately (e.g. on app background). */ +export const flushCacheStatsNow = flushCacheStats; + +// Start the periodic flush loop once when the module loads. +setInterval(flushCacheStats, CACHE_STATS_INTERVAL_MS); + // ─── Rate Limit Backoff (Issue #141) ────────────────────────────────────── /** @@ -240,6 +284,10 @@ apiClient.interceptors.response.use( statusCode: response.status, }); invalidateSuccessfulMutationCache(cfg); + // #811: every response that reaches this interceptor was a network round-trip + // (SWR cache hits are returned before the request is dispatched and never arrive + // here). Increment the miss counter so the 60 s flush captures real network usage. + recordCacheMiss(); // Successful login clears the client-side lockout counter if (cfg.url?.includes('/auth/login')) { diff --git a/src/services/socket/index.ts b/src/services/socket/index.ts index 2475c22..4579983 100644 --- a/src/services/socket/index.ts +++ b/src/services/socket/index.ts @@ -153,9 +153,13 @@ class SocketService { if (this.isBackgrounded) return; this.clearReconnectTimer(); - const delay = BACKOFF_DELAYS[this.backoffIndex] ?? BACKOFF_DELAYS[BACKOFF_DELAYS.length - 1]; - const jitter = 0.9 + Math.random() * 0.2; - const actualDelay = Math.round(delay * jitter); + const ceiling = BACKOFF_DELAYS[this.backoffIndex] ?? BACKOFF_DELAYS[BACKOFF_DELAYS.length - 1]; + // #810: Full jitter — pick a random value between 0 and the full backoff ceiling + // instead of ±10 % around a fixed value. When a server restarts and many clients + // reconnect simultaneously, fixed jitter clusters all delays near the same point + // (thundering herd). Full jitter spreads reconnects uniformly over [0, ceiling], + // distributing server load across the entire window. + const actualDelay = Math.round(Math.random() * ceiling); appLogger.info(`Socket reconnecting in ${actualDelay}ms (backoff index: ${this.backoffIndex})`);