Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
48 changes: 48 additions & 0 deletions src/services/api/axios.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────────────────────────

/**
Expand Down Expand Up @@ -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')) {
Expand Down
10 changes: 7 additions & 3 deletions src/services/socket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`);

Expand Down
Loading