From 96ba22882b1369d3da219d0c38889634761b85b6 Mon Sep 17 00:00:00 2001 From: Bhuvanesh S Date: Sat, 20 Jun 2026 22:17:27 +0530 Subject: [PATCH] feat(pwa): implement offline fallback and api resilience strategies --- app/offline/page.tsx | 15 + app/sw.ts | 31 +- components/pwa/OfflineFallback.tsx | 45 +++ lib/cache.ts | 427 ++++++++++++++++-------- lib/github.msw.test.ts | 6 +- lib/github.test.ts | 19 +- lib/github.ts | 299 ++++++++++++++--- package-lock.json | 43 +-- src/utils/__tests__/graphqlSync.test.ts | 8 +- tests/cache-advanced.test.ts | 123 +++++++ tests/github-recovery.test.ts | 149 +++++++++ tests/pwa/offline-page.test.tsx | 40 +++ 12 files changed, 968 insertions(+), 237 deletions(-) create mode 100644 app/offline/page.tsx create mode 100644 components/pwa/OfflineFallback.tsx create mode 100644 tests/cache-advanced.test.ts create mode 100644 tests/github-recovery.test.ts create mode 100644 tests/pwa/offline-page.test.tsx diff --git a/app/offline/page.tsx b/app/offline/page.tsx new file mode 100644 index 000000000..8ddf9d934 --- /dev/null +++ b/app/offline/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import OfflineFallback from '@/components/pwa/OfflineFallback'; + +export const metadata: Metadata = { + title: 'Offline | CommitPulse', + description: 'Connection lost. Please check your internet connection.', + robots: { + index: false, + follow: false, + }, +}; + +export default function OfflinePage() { + return ; +} diff --git a/app/sw.ts b/app/sw.ts index 34c5c77fd..ebf8a60fc 100644 --- a/app/sw.ts +++ b/app/sw.ts @@ -1,6 +1,6 @@ import { defaultCache } from '@serwist/next/worker'; import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'; -import { Serwist } from 'serwist'; +import { Serwist, NetworkFirst } from 'serwist'; // Extend the ServiceWorkerGlobalScope with Serwist's injected manifest declare global { @@ -22,7 +22,34 @@ const serwist = new Serwist({ // Prefetch responses while the browser handles navigation navigationPreload: true, - runtimeCaching: defaultCache, + runtimeCaching: [ + { + matcher({ request }) { + return request.mode === 'navigate'; + }, + handler: new NetworkFirst({ + cacheName: 'pages', + plugins: [ + { + async handlerDidError() { + return caches.match('/offline'); + }, + }, + ], + }), + }, + ...defaultCache, + ], + fallbacks: { + entries: [ + { + url: '/offline', + matcher({ request }) { + return request.mode === 'navigate'; + }, + }, + ], + }, }); serwist.addEventListeners(); diff --git a/components/pwa/OfflineFallback.tsx b/components/pwa/OfflineFallback.tsx new file mode 100644 index 000000000..881d23dd5 --- /dev/null +++ b/components/pwa/OfflineFallback.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from 'react'; +import { WifiOff, RefreshCw } from 'lucide-react'; +import { useTranslation } from '@/context/TranslationContext'; + +export default function OfflineFallback() { + const { t } = useTranslation(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRetry = () => { + setIsRefreshing(true); + // Attempt reload + window.location.reload(); + }; + + return ( +
+
+
+ +
+ +

+ {t('offline.title', { defaultValue: 'Connection Lost' })} +

+ +

+ {t('offline.description', { + defaultValue: + 'You are currently offline. Check your internet connection and try refreshing the page.', + })} +

+ + +
+ ); +} diff --git a/lib/cache.ts b/lib/cache.ts index b6bec0dd6..5e42c3195 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -2,6 +2,24 @@ import { randomUUID } from 'crypto'; import { brotliCompressSync, brotliDecompressSync } from 'zlib'; import logger from '@/lib/logger'; +export const CACHE_VERSION = 'v1'; + +export interface CacheEnvelope { + v: string; + t: number; // Write timestamp + ttl: number; // Nominal TTL in milliseconds + d: T | Buffer; +} + +export interface CacheStats { + hits: number; + misses: number; + writes: number; + evictions: number; + swrRefreshes: number; + size: number; +} + /** * Configuration options for the distributed mutex lock used by {@link DistributedCache.getOrSet}. */ @@ -38,9 +56,10 @@ export interface LockConfig { * Represents a cached item with its expiration timestamp. */ type CacheItem = { - value: T; - expiresAt: number; + value: CacheEnvelope; + expiresAt: number; // Physical cache eviction time }; + /** * A Simple in-memory TTL(Time To Live) cache. * @@ -50,12 +69,18 @@ type CacheItem = { * @typeParam T - Type of values stored in the cache. */ export class TTLCache { - //private store = new Map>(); - - private store = new Map>(); - + private store = new Map>(); private cleanupInterval: ReturnType | null = null; private readonly maxSize?: number; + + private stats: Omit = { + hits: 0, + misses: 0, + writes: 0, + evictions: 0, + swrRefreshes: 0, + }; + private static assertValidKey(key: unknown): asserts key is string { if (typeof key !== 'string') { throw new TypeError('Cache key must be a string'); @@ -65,6 +90,7 @@ export class TTLCache { throw new TypeError('Cache key cannot be empty'); } } + /** * Creates a new TTL cache instance. * @@ -134,58 +160,110 @@ export class TTLCache { return stored; } - /** - * Retrieves a value from the cache. - * - * Returns 'null' if the key does not exist or if the entry has expired. - * - * @param key - Cache key. - * @returns The cached value or 'null'. - * - * @example - * const user = cache.get("user:1"); - */ - get(key: string): T | null { - //TTLCache.assertValidKey(key); - if (key === null || key === undefined) { - throw new TypeError('Cache key must be a string'); + private wrap(value: T | Buffer, ttl: number): CacheEnvelope { + return { + v: CACHE_VERSION, + t: Date.now(), + ttl, + d: value, + }; + } + + private unwrap(item: unknown): CacheEnvelope | null { + if (item && typeof item === 'object' && 'v' in item && 'd' in item && 't' in item) { + const env = item as CacheEnvelope; + if (env.v === CACHE_VERSION) { + return env; + } + return null; // Version mismatch + } + // Backward compatibility for legacy cached entries (treat as v1 wrapper on the fly) + if (item !== null && item !== undefined) { + return { + v: CACHE_VERSION, + t: Date.now() - 60 * 1000, + ttl: 60 * 1000, + d: item as T | Buffer, + }; } + return null; + } - if (typeof key !== 'string') { + getStats(): CacheStats { + return { + ...this.stats, + size: this.store.size, + }; + } + + incrementSwrRefreshes(): void { + this.stats.swrRefreshes++; + } + + invalidatePattern(pattern: RegExp | string): number { + const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; + let count = 0; + for (const key of this.store.keys()) { + if (regex.test(key)) { + this.store.delete(key); + count++; + } + } + return count; + } + + getWithMetadata(key: string): { value: T; expiresAt: number; writtenAt: number } | null { + if (key === null || key === undefined || typeof key !== 'string') { throw new TypeError('Cache key must be a string'); } const hit = this.store.get(key); - if (!hit) return null; + if (!hit) { + this.stats.misses++; + return null; + } if (Date.now() > hit.expiresAt) { this.store.delete(key); + this.stats.misses++; return null; } - return this.decompress(hit.value); + const unwrapped = this.unwrap(hit.value); + if (!unwrapped) { + this.store.delete(key); + this.stats.misses++; + return null; + } + + this.stats.hits++; + return { + value: this.decompress(unwrapped.d), + expiresAt: unwrapped.t + unwrapped.ttl, // return nominal expiration + writtenAt: unwrapped.t, + }; } /** - * Checks whether a key exists in the cache and has not expired. - * - * Unlike `get()`, this does not return the value. - * - * @param key - Cache key. - * @returns `true` if the key exists and is still valid, `false` otherwise. + * Retrieves a value from the cache. * - * @example - * if (cache.has("user:1")) { - * // safe to call get() - * } + * Returns 'null' if the key does not exist or if the entry has expired. */ - has(key: string): boolean { - //TTLCache.assertValidKey(key); - if (key === null || key === undefined) { - throw new TypeError('Cache key must be a string'); + get(key: string): T | null { + const meta = this.getWithMetadata(key); + if (!meta) return null; + // Standard get should return null if past nominal expiration + if (Date.now() > meta.expiresAt) { + return null; } + return meta.value; + } - if (typeof key !== 'string') { + /** + * Checks whether a key exists in the cache and has not expired. + */ + has(key: string): boolean { + if (key === null || key === undefined || typeof key !== 'string') { throw new TypeError('Cache key must be a string'); } @@ -197,45 +275,30 @@ export class TTLCache { return false; } + const unwrapped = this.unwrap(hit.value); + if (!unwrapped) { + this.store.delete(key); + return false; + } + + // Standard check has() should be false if past nominal expiration + if (Date.now() > unwrapped.t + unwrapped.ttl) { + return false; + } + return true; } + /** * Removes a single entry from the cache. - * - * Does nothing if the key does not exist. - * - * @param key - Cache key to remove. - * @returns `true` if the key existed and was deleted, `false` otherwise. - * - * @example - * cache.delete("user:1"); */ delete(key: string): boolean { TTLCache.assertValidKey(key); - return this.store.delete(key); } - /** - * Stores a value in the cache with a TTL. - * - * If the cache reaches its maximum capacity, the oldest item - * may be removed to make room for new entries. - * - * @param key - Cache key. - * @param value - Value to cache. - * @param ttlMs - Time to live in milliseconds. - * @returns void - * - * @example - * cache.set("user:1", userData, 5000); - */ /** * Updates the value of an existing, non-expired cache entry without resetting its TTL. - * - * @param key - Cache key. - * @param value - New value to store. - * @returns `true` if the entry existed and was updated, `false` if missing or expired. */ update(key: string, value: T): boolean { const hit = this.store.get(key); @@ -249,12 +312,18 @@ export class TTLCache { return false; } - hit.value = this.compress(value); + const unwrapped = this.unwrap(hit.value); + if (!unwrapped) { + this.store.delete(key); + return false; + } + + unwrapped.d = this.compress(value); + unwrapped.t = Date.now(); return true; } - set(key: string, value: T, ttlMs: number): void { - //TTLCache.assertValidKey(key); + set(key: string, value: T, ttlMs: number, swrMs: number = 0): void { if (typeof key !== 'string' || key.trim().length === 0) { throw new TypeError('Cache key cannot be empty'); } @@ -273,22 +342,19 @@ export class TTLCache { const oldestKey = this.store.keys().next().value as string | undefined; if (oldestKey !== undefined) { this.store.delete(oldestKey); + this.stats.evictions++; } } } + const compressed = this.compress(value); + const envelope = this.wrap(compressed, ttlMs); + this.store.delete(key); - this.store.set(key, { value: this.compress(value), expiresAt: Date.now() + ttlMs }); + this.store.set(key, { value: envelope, expiresAt: Date.now() + ttlMs + swrMs }); + this.stats.writes++; } - /** - * Removes all entries from the cache. - * - * @returns void - * - * @example - * cache.clear(); - */ clear(): void { this.store.clear(); } @@ -320,6 +386,14 @@ export class DistributedCache { private redisToken: string = ''; private localLocks = new Map>(); + private stats: Omit = { + hits: 0, + misses: 0, + writes: 0, + evictions: 0, + swrRefreshes: 0, + }; + constructor(maxSize?: number, cleanupIntervalMs?: number) { this.localCache = new TTLCache(maxSize, cleanupIntervalMs); const url = process.env.KV_REST_API_URL || process.env.UPSTASH_REDIS_REST_URL; @@ -331,17 +405,36 @@ export class DistributedCache { } } - async get(key: string, localTtlMs: number = 5 * 60 * 1000): Promise { - if (!this.useRedis) { - return this.localCache.get(key); - } + getStats(): CacheStats { + const local = this.localCache.getStats(); + return { + hits: this.stats.hits + local.hits, + misses: this.stats.misses + local.misses, + writes: this.stats.writes + local.writes, + evictions: this.stats.evictions + local.evictions, + swrRefreshes: this.stats.swrRefreshes + local.swrRefreshes, + size: local.size, + }; + } + + invalidatePattern(pattern: RegExp | string): number { + return this.localCache.invalidatePattern(pattern); + } - // Check local L1 cache first for fast in-instance lookups - const localHit = this.localCache.get(key); + async getWithMetadata( + key: string, + localTtlMs: number = 5 * 60 * 1000 + ): Promise<{ value: T; expiresAt: number; writtenAt: number } | null> { + // Check local L1 cache first + const localHit = this.localCache.getWithMetadata(key); if (localHit !== null) { return localHit; } + if (!this.useRedis) { + return null; + } + try { const res = await fetch(`${this.redisUrl}/`, { method: 'POST', @@ -358,40 +451,91 @@ export class DistributedCache { const data = await res.json(); if (!data || data.result === undefined || data.result === null) { + this.stats.misses++; return null; } - const parsed = JSON.parse(data.result) as T; - // Backfill local cache so subsequent requests in this instance are instant - this.localCache.set(key, parsed, localTtlMs); - return parsed; + const rawResult = JSON.parse(data.result); + if ( + rawResult && + typeof rawResult === 'object' && + 'v' in rawResult && + 'd' in rawResult && + 't' in rawResult && + 'ttl' in rawResult + ) { + const envelope = rawResult as CacheEnvelope; + if (envelope.v === CACHE_VERSION) { + const expiresAt = envelope.t + envelope.ttl; + this.localCache.set(key, envelope.d as T, envelope.ttl); + this.stats.hits++; + return { + value: envelope.d as T, + expiresAt, + writtenAt: envelope.t, + }; + } else { + // Version mismatch + this.stats.misses++; + return null; + } + } else { + // Backward compatibility for unversioned legacy items + const parsed = rawResult as T; + const expiresAt = Date.now() + localTtlMs; + this.localCache.set(key, parsed, localTtlMs); + this.stats.hits++; + return { + value: parsed, + expiresAt, + writtenAt: Date.now() - 60 * 1000, + }; + } } catch (err) { logger.error('Cache GET failed', { component: 'DistributedCache', key, error: err, }); - return this.localCache.get(key); + return this.localCache.getWithMetadata(key); } } - async set(key: string, value: T, ttlMs: number): Promise { + async get(key: string, localTtlMs: number = 5 * 60 * 1000): Promise { + const meta = await this.getWithMetadata(key, localTtlMs); + if (!meta) return null; + if (Date.now() > meta.expiresAt) { + return null; + } + return meta.value; + } + + async set(key: string, value: T, ttlMs: number, swrMs: number = 0): Promise { // Always update local cache - this.localCache.set(key, value, ttlMs); + this.localCache.set(key, value, ttlMs, swrMs); + this.stats.writes++; if (!this.useRedis) { return; } try { - const ttlSec = Math.max(1, Math.ceil(ttlMs / 1000)); + const envelope: CacheEnvelope = { + v: CACHE_VERSION, + t: Date.now(), + ttl: ttlMs, + d: value, + }; + + const physicalTtl = ttlMs + swrMs; + const ttlSec = Math.max(1, Math.ceil(physicalTtl / 1000)); const res = await fetch(`${this.redisUrl}/`, { method: 'POST', headers: { Authorization: `Bearer ${this.redisToken}`, 'Content-Type': 'application/json', }, - body: JSON.stringify(['SET', key, JSON.stringify(value), 'EX', ttlSec]), + body: JSON.stringify(['SET', key, JSON.stringify(envelope), 'EX', ttlSec]), }); if (!res.ok) { @@ -460,13 +604,20 @@ export class DistributedCache { } try { + const envelope: CacheEnvelope = { + v: CACHE_VERSION, + t: Date.now(), + ttl: 60 * 1000, // Guess default TTL + d: value, + }; + const res = await fetch(`${this.redisUrl}/`, { method: 'POST', headers: { Authorization: `Bearer ${this.redisToken}`, 'Content-Type': 'application/json', }, - body: JSON.stringify(['SET', key, JSON.stringify(value), 'KEEPTTL', 'XX']), + body: JSON.stringify(['SET', key, JSON.stringify(envelope), 'KEEPTTL', 'XX']), }); if (!res.ok) { @@ -477,6 +628,7 @@ export class DistributedCache { if (updated) { this.localCache.update(key, value); + this.stats.writes++; } else { // Redis no longer has the key, so the L1 value is stale. this.localCache.delete(key); @@ -497,17 +649,8 @@ export class DistributedCache { this.localCache.clear(); } - /** - * Atomically increments a numeric counter stored under `key` and returns the new value. - * - * When Redis is available, uses EVAL + Lua script for true atomicity. - * Falls back to the local TTLCache for non-Redis deployments (dev/test). - * - * @param key - Cache key holding a numeric counter. - * @param ttlMs - Time-to-live in milliseconds. Only applied when the key is first created (count == 1). - * @returns The incremented counter value. - */ async incr(key: string, ttlMs: number): Promise { + this.stats.writes++; if (!this.useRedis) { const current = (this.localCache.get(key) as unknown as number) || 0; const next = current + 1; @@ -581,34 +724,68 @@ return c`; * @param ttlMs - Cache expiration time in milliseconds. * @param shouldFetch - Optional predicate that forces refresh even on cache hits. * @param lockConfig - Optional distributed lock tuning. + * @param swrMs - Optional stale-while-revalidate duration in milliseconds. */ async getOrSet( key: string, loadFn: (cached: T | null) => Promise, ttlMs: number, shouldFetch?: (cached: T) => boolean, - lockConfig?: LockConfig + lockConfig?: LockConfig, + swrMs?: number ): Promise { - // Join an existing in-flight request before any async operation to avoid - // concurrent loadFn execution for the same key. + // Join an existing in-flight request before any async operation const existing = this.localLocks.get(key); if (existing) return existing; - // Attempt to retrieve an existing value before triggering a refresh. - const cached = await this.get(key, ttlMs); + // Retrieve cached item with metadata + const cachedMeta = await this.getWithMetadata(key, ttlMs); + + if (cachedMeta !== null) { + const forceFetch = shouldFetch && shouldFetch(cachedMeta.value); + if (!forceFetch) { + if (Date.now() < cachedMeta.expiresAt) { + return cachedMeta.value; + } - if (cached !== null && (!shouldFetch || !shouldFetch(cached))) { - return cached; + // Check if inside SWR window + if (swrMs && Date.now() < cachedMeta.expiresAt + swrMs) { + this.stats.swrRefreshes++; + this.localCache.incrementSwrRefreshes(); + // Trigger asynchronous background refresh + this.executeAndLockBg(key, loadFn, ttlMs, cachedMeta.value, lockConfig, swrMs); + // Return stale cached value immediately + return cachedMeta.value; + } + } } - // Double-check local locks after the await in case another call interleaved. const pendingLocal = this.localLocks.get(key); if (pendingLocal) return pendingLocal; + const promise = this.executeAndLockBg( + key, + loadFn, + ttlMs, + cachedMeta ? cachedMeta.value : null, + lockConfig, + swrMs + ); + return promise; + } + + private async executeAndLockBg( + key: string, + loadFn: (cached: T | null) => Promise, + ttlMs: number, + cached: T | null, + lockConfig?: LockConfig, + swrMs: number = 0 + ): Promise { const executeAndLock = async () => { if (!this.useRedis) { const data = await loadFn(cached); - await this.set(key, data, ttlMs); + await this.set(key, data, ttlMs, swrMs); return data; } @@ -623,8 +800,6 @@ return c`; const start = Date.now(); let attempt = 0; - // Only DEL the lock if the stored token still matches ours, preventing - // accidental deletion of a lock acquired by another instance after ours expired. const luaRelease = ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) @@ -664,8 +839,6 @@ return c`; let acquired = false; try { - // NX: acquire only if lock doesn't already exist. - // PX: auto-expire lock to avoid deadlocks. const lockRes = await fetch(`${this.redisUrl}/`, { method: 'POST', headers: { @@ -682,14 +855,13 @@ return c`; throw new Error(`Redis lock HTTP error: ${lockRes.status}`); } } catch (err) { - // Redis network error during locking. Fallback to direct execution. logger.error('Cache lock failed', { component: 'DistributedCache', key, error: err, }); const fallbackData = await loadFn(cached); - await this.set(key, fallbackData, ttlMs); + await this.set(key, fallbackData, ttlMs, swrMs); return fallbackData; } @@ -697,18 +869,12 @@ return c`; let extensionTimer: ReturnType | null = null; if (enableLockExtension) { - // Heartbeat fires at 60% of lockTtlMs so there is always time before expiry. - // When lockTtlMs is small (<1667ms), clamp to lockTtlMs/2 but with at least - // 100ms of headroom so the heartbeat always fires before the lock expires. const rawInterval = Math.floor(lockTtlMs * 0.6); const minInterval = Math.min(1000, Math.max(100, lockTtlMs - 100)); const extensionInterval = Math.max(minInterval, rawInterval); extensionTimer = setInterval(async () => { try { - // Atomically extend only if we still own the lock (token matches). - // Using SET XX without a token check would let us extend a lock that - // another instance acquired after ours expired — do not use SET XX alone. const luaExtend = ` if redis.call("GET", KEYS[1]) == ARGV[1] then redis.call("PEXPIRE", KEYS[1], ARGV[2]) @@ -730,7 +896,7 @@ return c`; ]), }); } catch { - // Silently ignore extension failures — the lock will expire naturally. + // Ignore extension failures } }, extensionInterval); if (typeof extensionTimer === 'object' && typeof extensionTimer.unref === 'function') { @@ -740,7 +906,7 @@ return c`; try { const freshData = await loadFn(cached); - await this.set(key, freshData, ttlMs); + await this.set(key, freshData, ttlMs, swrMs); return freshData; } finally { if (extensionTimer) clearInterval(extensionTimer); @@ -748,8 +914,6 @@ return c`; } } - // Exponential backoff with jitter to prevent thundering herd - // when multiple instances contend for the same lock. const baseBackoff = Math.min(BASE_POLL_MS * 2 ** attempt, MAX_POLL_MS); const jitter = 0.5 + Math.random() * 0.5; const backoffMs = Math.round(baseBackoff * jitter); @@ -757,14 +921,13 @@ return c`; attempt++; const doubleCheck = await this.get(key, ttlMs); - if (doubleCheck !== null && (!shouldFetch || !shouldFetch(doubleCheck))) { + if (doubleCheck !== null) { return doubleCheck; } } - // Timed out waiting for lock. Fallback to direct execution. const finalFallback = await loadFn(cached); - await this.set(key, finalFallback, ttlMs); + await this.set(key, finalFallback, ttlMs, swrMs); return finalFallback; }; diff --git a/lib/github.msw.test.ts b/lib/github.msw.test.ts index 39923728e..2fd4c11f3 100644 --- a/lib/github.msw.test.ts +++ b/lib/github.msw.test.ts @@ -304,7 +304,7 @@ describe('MSW: error handling', () => { await expect(fetchUserProfile('octocat')).rejects.toThrow(); }); - it('handles network errors gracefully', async () => { + it('handles network errors gracefully by falling back to mock profile', async () => { globalThis.fetch = (() => { throw new TypeError('Failed to fetch'); }) as typeof fetch; @@ -312,6 +312,8 @@ describe('MSW: error handling', () => { process.env.GITHUB_PAT = 'test-token'; delete process.env.GITHUB_TOKEN; clearGitHubApiCacheForTests(); - await expect(fetchUserProfile('octocat')).rejects.toThrow(); + const result = await fetchUserProfile('octocat'); + expect(result.isOfflineFallback).toBe(true); + expect(result.login).toBe('octocat'); }); }); diff --git a/lib/github.test.ts b/lib/github.test.ts index d99b73b9b..278a083a5 100644 --- a/lib/github.test.ts +++ b/lib/github.test.ts @@ -283,10 +283,12 @@ describe('fetchGitHubContributions', () => { ); }); - it('throws when fetch itself rejects due to a network failure', async () => { + it('falls back to empty calendar when fetch itself rejects due to a network failure', async () => { vi.mocked(fetch).mockRejectedValue(new Error('Failed to fetch')); - await expect(fetchGitHubContributions('octocat')).rejects.toThrow('Failed to fetch'); + const result = await fetchGitHubContributions('octocat'); + expect(result.calendar.totalContributions).toBe(0); + expect(result.isOfflineFallback).toBe(true); }); it('throws the first GraphQL error when the API returns an errors array', async () => { @@ -352,15 +354,16 @@ describe('fetchGitHubContributions', () => { expect(fetch).toHaveBeenCalledTimes(2); }); - it('throws after exhausting all retries on repeated body-level RATE_LIMITED errors', async () => { + it('falls back to empty calendar after exhausting all retries on repeated body-level RATE_LIMITED errors', async () => { vi.mocked(fetch).mockResolvedValue( mockResponse({ errors: [{ type: 'RATE_LIMITED', message: 'API rate limit exceeded' }] }) ); const promise = fetchGitHubContributions('octocat'); - const assertion = expect(promise).rejects.toThrow('API Rate Limit Exceeded'); await vi.advanceTimersByTimeAsync(3500); - await assertion; + const result = await promise; + expect(result.calendar.totalContributions).toBe(0); + expect(result.isOfflineFallback).toBe(true); expect(fetch).toHaveBeenCalledTimes(4); }); }); @@ -790,15 +793,15 @@ describe('fetchContributedRepos', () => { await assertion; }); - it('throws on a rate-limited GraphQL 200 response instead of returning []', async () => { + it('falls back to [] on a rate-limited GraphQL 200 response', async () => { vi.mocked(fetch).mockResolvedValue( mockResponse({ errors: [{ type: 'RATE_LIMITED', message: 'API rate limit exceeded' }] }) ); const promise = fetchContributedRepos('octocat'); - const assertion = expect(promise).rejects.toThrow('API Rate Limit Exceeded'); await vi.advanceTimersByTimeAsync(3500); - await assertion; + const result = await promise; + expect(result).toEqual([]); }); it('does not cache the failure: a later call refetches and can succeed', async () => { diff --git a/lib/github.ts b/lib/github.ts index 67d3cefb6..814e09981 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -31,6 +31,48 @@ interface GitHubRepo { const MAX_RETRIES = 3; const BASE_DELAY_MS = 500; const MAX_RETRY_DELAY_MS = 5000; + +export function getJitteredBackoff(attempt: number): number { + const base = BASE_DELAY_MS * Math.pow(2, attempt); + const jitter = 0.5 + Math.random() * 0.5; + return Math.round(base * jitter); +} + +export function shouldFallbackOnError(err: unknown): boolean { + if (!err) return false; + const msg = err instanceof Error ? err.message : String(err); + + if ( + msg.includes('not found') || + msg.includes('Not Found') || + msg.includes('401') || + msg.includes('Unauthorized') || + msg.includes('token') || + msg.includes('PAT') || + msg.includes('Authorization') || + msg.includes('status 500') || + msg.includes('error: 500') || + msg.includes('Bad credentials') + ) { + return false; + } + + if ( + msg.includes('fetch') || + msg.includes('timeout') || + msg.includes('timed out') || + msg.includes('AbortError') || + msg.includes('rate limit') || + msg.includes('RATE_LIMITED') || + msg.includes('Rate Limit') || + err instanceof RateLimitError + ) { + return true; + } + + return false; +} + const GRAPHQL_TIMEOUT_MS = 8000; // 8s for GraphQL endpoint const REST_TIMEOUT_MS = 5000; // 5s for REST endpoints const ORG_MEMBER_LIMIT = 100; @@ -152,7 +194,7 @@ export async function fetchWithRetry( } throw fetchError; } - const delay = BASE_DELAY_MS * Math.pow(2, attempt); + const delay = getJitteredBackoff(attempt); await new Promise((resolve) => setTimeout(resolve, delay)); return fetchWithRetry(url, options, attempt + 1, timeoutMs, userToken); } @@ -196,7 +238,7 @@ export async function fetchWithRetry( } // Retry immediately with the next token if available if (attempt < MAX_RETRIES && tokens.length > 1) { - const delay = BASE_DELAY_MS * Math.pow(2, attempt); + const delay = getJitteredBackoff(attempt); await new Promise((resolve) => setTimeout(resolve, delay)); return fetchWithRetry(url, options, attempt + 1, timeoutMs, userToken); } @@ -227,7 +269,7 @@ export async function fetchWithRetry( if (attempt >= MAX_RETRIES) return res; - let delay = BASE_DELAY_MS * Math.pow(2, attempt); + let delay = getJitteredBackoff(attempt); if (retryAfter) { const parsed = parseInt(retryAfter, 10); if (!Number.isNaN(parsed) && String(parsed) === retryAfter) { @@ -257,7 +299,7 @@ export async function fetchWithRetry( const shouldRetry = res.status >= 500; if (!shouldRetry || attempt >= MAX_RETRIES) return res; - const delay = BASE_DELAY_MS * Math.pow(2, attempt); + const delay = getJitteredBackoff(attempt); await new Promise((resolve) => setTimeout(resolve, delay)); return fetchWithRetry(url, options, attempt + 1, timeoutMs, userToken); } @@ -337,9 +379,7 @@ async function fetchGraphQLWithRetry( if (!isBodyRateLimited) return res; - const delay = BASE_DELAY_MS * Math.pow(2, attempt); - if (delay > MAX_RETRY_DELAY_MS) return res; - + const delay = getJitteredBackoff(attempt); await new Promise((resolve) => setTimeout(resolve, delay)); return fetchGraphQLWithRetry(url, options, attempt + 1, timeoutMs, userToken); } @@ -467,6 +507,7 @@ interface GitHubUserProfile { location: string | null; type?: string; plan?: { name?: string } | null; + isOfflineFallback?: boolean; } function sanitizeUserProfile(profile: GitHubUserProfile): GitHubUserProfile { @@ -663,6 +704,21 @@ export function getCircuitTelemetry() { const FETCH_TIMEOUT_MS = 4000; const activeContributionsPromises = new Map>(); +export function getMockContributions(): ExtendedContributionData { + return { + calendar: { + totalContributions: 0, + weeks: [], + lastSyncedAt: new Date().toISOString(), + }, + repoContributions: [], + totalPRs: 0, + totalIssues: 0, + totalReviews: 0, + isOfflineFallback: true, + }; +} + export async function fetchGitHubContributions( username: string, options: FetchOptions = {} @@ -733,7 +789,26 @@ export async function fetchGitHubContributions( if (options.signal) { if (options.bypassCache || options.forceRefresh) { - return await loadWithTimeout(); + try { + return await loadWithTimeout(); + } catch (err: unknown) { + if (shouldFallbackOnError(err)) { + const staleData = await contributionsCache.get(key); + if (staleData) { + logger.warn('GitHub API fetch failed, falling back to stale cache', { + component: 'GitHub API', + username, + error: err, + }); + return { + ...staleData, + isOfflineFallback: true, + }; + } + return getMockContributions(username); + } + throw err; + } } const cached = await contributionsCache.get(key); if (cached !== null && !shouldFetch(cached)) { @@ -742,17 +817,20 @@ export async function fetchGitHubContributions( try { return await loadWithTimeout(); } catch (err: unknown) { - const staleData = await contributionsCache.get(key); - if (staleData) { - logger.warn('GitHub API fetch failed, falling back to stale cache', { - component: 'GitHub API', - username, - error: err, - }); - return { - ...staleData, - isOfflineFallback: true, - }; + if (shouldFallbackOnError(err)) { + const staleData = await contributionsCache.get(key); + if (staleData) { + logger.warn('GitHub API fetch failed, falling back to stale cache', { + component: 'GitHub API', + username, + error: err, + }); + return { + ...staleData, + isOfflineFallback: true, + }; + } + return getMockContributions(username); } throw err; } @@ -766,16 +844,19 @@ export async function fetchGitHubContributions( } return result; } catch (err: unknown) { - const staleData = await contributionsCache.get(key); - if (staleData) { - console.warn( - `[GitHub API] Fetch failed or timed out for "${username}", falling back to stale cache:`, - err - ); - return { - ...staleData, - isOfflineFallback: true, - }; + if (shouldFallbackOnError(err)) { + const staleData = await contributionsCache.get(key); + if (staleData) { + console.warn( + `[GitHub API] Fetch failed or timed out for "${username}", falling back to stale cache:`, + err + ); + return { + ...staleData, + isOfflineFallback: true, + }; + } + return getMockContributions(username); } throw err; } @@ -784,17 +865,20 @@ export async function fetchGitHubContributions( try { return await contributionsCache.getOrSet(key, coalescedLoad, LONG_CACHE_TTL, shouldFetch); } catch (err: unknown) { - const staleData = await contributionsCache.get(key); - if (staleData) { - logger.warn('GitHub API fetch failed, falling back to stale cache', { - component: 'GitHub API', - username, - error: err, - }); - return { - ...staleData, - isOfflineFallback: true, - }; + if (shouldFallbackOnError(err)) { + const staleData = await contributionsCache.get(key); + if (staleData) { + logger.warn('GitHub API fetch failed, falling back to stale cache', { + component: 'GitHub API', + username, + error: err, + }); + return { + ...staleData, + isOfflineFallback: true, + }; + } + return getMockContributions(username); } throw err; } @@ -958,6 +1042,23 @@ async function fetchContributionsUncached( }; } +export function getMockProfile(username: string): GitHubUserProfile { + return { + login: username, + name: username, + avatar_url: `https://github.com/${username}.png`, + public_repos: 0, + followers: 0, + following: 0, + created_at: new Date().toISOString(), + bio: 'Profile currently unavailable (offline fallback)', + location: null, + type: 'User', + plan: null, + isOfflineFallback: true, + }; +} + export async function fetchUserProfile( username: string, options: FetchOptions = {} @@ -969,8 +1070,41 @@ export async function fetchUserProfile( return fetchProfileUncached(encodedUsername, key, options); }; - if (options.bypassCache || options.forceRefresh) return load(); - return profileCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS); + if (options.bypassCache || options.forceRefresh) { + try { + return await load(); + } catch (err) { + if (shouldFallbackOnError(err)) { + const stale = await profileCache.get(key); + if (stale) { + logger.warn('GitHub API profile fetch failed, returning stale cache data', { + username, + error: err, + }); + return { ...stale, isOfflineFallback: true }; + } + return getMockProfile(username); + } + throw err; + } + } + + try { + return await profileCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS); + } catch (err) { + if (shouldFallbackOnError(err)) { + const stale = await profileCache.get(key); + if (stale) { + logger.warn('GitHub API profile fetch failed, returning stale cache data', { + username, + error: err, + }); + return { ...stale, isOfflineFallback: true }; + } + return getMockProfile(username); + } + throw err; + } } async function fetchProfileUncached( @@ -1020,8 +1154,41 @@ export async function fetchUserRepos( return fetchReposUncached(encodedUsername, key, options); }; - if (options.bypassCache || options.forceRefresh) return load(); - return reposCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS); + if (options.bypassCache || options.forceRefresh) { + try { + return await load(); + } catch (err) { + if (shouldFallbackOnError(err)) { + const stale = await reposCache.get(key); + if (stale) { + logger.warn('GitHub API repos fetch failed, returning stale cache data', { + username, + error: err, + }); + return stale; + } + return []; + } + throw err; + } + } + + try { + return await reposCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS); + } catch (err) { + if (shouldFallbackOnError(err)) { + const stale = await reposCache.get(key); + if (stale) { + logger.warn('GitHub API repos fetch failed, returning stale cache data', { + username, + error: err, + }); + return stale; + } + return []; + } + throw err; + } } async function fetchReposUncached( @@ -1439,13 +1606,45 @@ export async function fetchContributedRepos( return data?.data?.user?.repositoriesContributedTo?.nodes || []; }; - if (options.bypassCache) return load(); - if (options.forceRefresh) { - const fresh = await load(); - await contributedReposCache.set(key, fresh, GITHUB_CACHE_TTL_MS); - return fresh; + if (options.bypassCache || options.forceRefresh) { + try { + const fresh = await load(); + if (options.forceRefresh) { + await contributedReposCache.set(key, fresh, GITHUB_CACHE_TTL_MS); + } + return fresh; + } catch (err) { + if (shouldFallbackOnError(err)) { + const stale = await contributedReposCache.get(key); + if (stale) { + logger.warn('GitHub API contributed repos fetch failed, returning stale cache data', { + username, + error: err, + }); + return stale; + } + return []; + } + throw err; + } + } + + try { + return await contributedReposCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS); + } catch (err) { + if (shouldFallbackOnError(err)) { + const stale = await contributedReposCache.get(key); + if (stale) { + logger.warn('GitHub API contributed repos fetch failed, returning stale cache data', { + username, + error: err, + }); + return stale; + } + return []; + } + throw err; } - return contributedReposCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS); } export interface DeveloperScoreInput { diff --git a/package-lock.json b/package-lock.json index 947c7bb3f..8f2a0a1e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -224,7 +224,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -502,7 +501,6 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -616,7 +614,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -665,7 +662,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -746,7 +742,6 @@ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -758,7 +753,6 @@ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -3410,7 +3404,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3709,7 +3702,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3720,7 +3712,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3797,7 +3788,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -4464,7 +4454,6 @@ "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", @@ -4594,7 +4583,6 @@ "integrity": "sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.1.8", "fflate": "^0.8.2", @@ -4706,7 +4694,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5206,7 +5193,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5644,8 +5630,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3-array": { "version": "3.2.4", @@ -5800,7 +5785,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -6458,7 +6442,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6660,7 +6643,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7624,8 +7606,7 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license.", - "peer": true + "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/has-bigints": { "version": "1.1.0", @@ -7863,7 +7844,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -9873,7 +9853,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", @@ -10541,7 +10520,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -10584,7 +10562,6 @@ "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -10605,7 +10582,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -10797,7 +10773,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10807,7 +10782,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10871,8 +10845,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-kapsule": { "version": "2.5.7", @@ -10907,7 +10880,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -10995,8 +10967,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12570,7 +12541,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12708,7 +12678,6 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -12848,7 +12817,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13066,7 +13034,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -13158,7 +13125,6 @@ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", @@ -13553,7 +13519,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/utils/__tests__/graphqlSync.test.ts b/src/utils/__tests__/graphqlSync.test.ts index f58d1a992..8a7bdf789 100644 --- a/src/utils/__tests__/graphqlSync.test.ts +++ b/src/utils/__tests__/graphqlSync.test.ts @@ -48,9 +48,9 @@ describe('GraphQL Syncing Utility Integration Tests', () => { }), }); - // Verifies your utility safely surfaces network errors instead of an unhandled crash - await expect( - fetchGitHubContributions('attardekhushi78-cpu', { bypassCache: true }) - ).rejects.toThrow(); + // Verifies your utility gracefully falls back to empty calendar instead of an unhandled crash + const result = await fetchGitHubContributions('attardekhushi78-cpu', { bypassCache: true }); + expect(result.calendar.totalContributions).toBe(0); + expect(result.isOfflineFallback).toBe(true); }); }); diff --git a/tests/cache-advanced.test.ts b/tests/cache-advanced.test.ts new file mode 100644 index 000000000..f028dff87 --- /dev/null +++ b/tests/cache-advanced.test.ts @@ -0,0 +1,123 @@ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TTLCache, DistributedCache } from '@/lib/cache'; + +describe('Advanced Cache Management (Phase 2)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('Cache Versioning', () => { + it('invalidates key on cache version mismatch', () => { + const cache = new TTLCache(); + cache.set('user', 'octocat', 10_000); + + // Mutate stored envelope version to mock version mismatch + + const internalStore = (cache as unknown as { store: Map }) + .store; + const entry = internalStore.get('user'); + entry.value.v = 'v0'; // older mismatching version + + expect(cache.get('user')).toBeNull(); + expect(cache.has('user')).toBe(false); + cache.destroy(); + }); + + it('retains backward compatibility for unversioned legacy items', () => { + const cache = new TTLCache(); + + const internalStore = ( + cache as unknown as { store: Map } + ).store; + // Inject unversioned legacy item directly + internalStore.set('legacy', { + value: 'legacy-data', + expiresAt: Date.now() + 10_000, + }); + + expect(cache.get('legacy')).toBe('legacy-data'); + cache.destroy(); + }); + }); + + describe('Pattern-based Cleanup', () => { + it('deletes keys matching a pattern while keeping others', () => { + const cache = new TTLCache(); + cache.set('user:123', 'john', 10_000); + cache.set('user:456', 'alice', 10_000); + cache.set('stats:789', 'data', 10_000); + + const deletedCount = cache.invalidatePattern(/^user:/); + expect(deletedCount).toBe(2); + expect(cache.get('user:123')).toBeNull(); + expect(cache.get('user:456')).toBeNull(); + expect(cache.get('stats:789')).toBe('data'); + cache.destroy(); + }); + }); + + describe('Stale-While-Revalidate (SWR)', () => { + it('returns stale cache immediately and triggers background fetch in SWR window', async () => { + const cache = new DistributedCache(); + let loadCount = 0; + const loadFn = async () => { + loadCount++; + return `fresh-data-${loadCount}`; + }; + + // Set initial value with TTL 5s and SWR window 10s + await cache.set('swr-test', 'initial-data', 5000, 10000); + + // Advance time by 6s (stale, but within SWR window of 10s) + vi.advanceTimersByTime(6000); + + // Call getOrSet with swrMs = 10000 + const result = await cache.getOrSet('swr-test', loadFn, 5000, undefined, undefined, 10000); + + // Should return the stale value immediately + expect(result).toBe('initial-data'); + expect(loadCount).toBe(1); // background loadFn triggered + + // Resolve background execution + + const bgPromise = ( + cache as unknown as { localLocks: Map> } + ).localLocks.get('swr-test'); + if (bgPromise) await bgPromise; + + // Retrieve again: should now have the fresh value from background loadFn + const updatedResult = await cache.get('swr-test'); + expect(updatedResult).toBe('fresh-data-1'); + cache.destroy(); + }); + }); + + describe('Cache Instrumentation & Stats', () => { + it('accurately counts hits, misses, writes, and evictions', () => { + const cache = new TTLCache(2); // maxSize = 2 + + // Write stats + cache.set('a', 1, 10_000); + cache.set('b', 2, 10_000); + expect(cache.getStats().writes).toBe(2); + + // Hit stats + cache.get('a'); + expect(cache.getStats().hits).toBe(1); + + // Miss stats + cache.get('missing'); + expect(cache.getStats().misses).toBe(1); + + // Eviction stats + cache.set('c', 3, 10_000); // evicts 'a' or 'b' + expect(cache.getStats().evictions).toBe(1); + cache.destroy(); + }); + }); +}); diff --git a/tests/github-recovery.test.ts b/tests/github-recovery.test.ts new file mode 100644 index 000000000..0a5bf0ad3 --- /dev/null +++ b/tests/github-recovery.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + fetchGitHubContributions, + fetchUserProfile, + fetchUserRepos, + getJitteredBackoff, + clearGitHubApiCacheForTests, +} from '../lib/github'; + +function mockResponse(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +describe('GitHub API Failure Recovery (Phase 3)', () => { + beforeEach(() => { + clearGitHubApiCacheForTests(); + vi.spyOn(global, 'fetch'); + process.env.GITHUB_PAT = 'test-token'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + clearGitHubApiCacheForTests(); + }); + + describe('Jittered Backoff Calculations', () => { + it('verifies that getJitteredBackoff returns values within the correct bounds', () => { + // Attempt 0: base delay = 500ms. Bounds: [250, 500] + for (let i = 0; i < 100; i++) { + const delay = getJitteredBackoff(0); + expect(delay).toBeGreaterThanOrEqual(250); + expect(delay).toBeLessThanOrEqual(500); + } + + // Attempt 1: base delay = 1000ms. Bounds: [500, 1000] + for (let i = 0; i < 100; i++) { + const delay = getJitteredBackoff(1); + expect(delay).toBeGreaterThanOrEqual(500); + expect(delay).toBeLessThanOrEqual(1000); + } + }); + }); + + describe('fetchUserProfile Fallback', () => { + it('falls back to stale cache data on network failure', async () => { + // 1. Populate cache with a successful fetch first + vi.mocked(fetch).mockResolvedValueOnce( + mockResponse({ + login: 'octocat', + name: 'The Octocat', + avatar_url: 'https://github.com/octocat.png', + public_repos: 8, + followers: 20, + following: 9, + created_at: '2011-01-20T09:00:00Z', + }) + ); + const firstResult = await fetchUserProfile('octocat'); + expect(firstResult.login).toBe('octocat'); + + // 2. Mock a network failure for the second fetch + vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch')); + + // 3. Force refresh or bypass cache to trigger error path, should get stale data with isOfflineFallback + const result = await fetchUserProfile('octocat', { forceRefresh: true }); + expect(result.login).toBe('octocat'); + expect(result.isOfflineFallback).toBe(true); + }); + + it('returns a mock placeholder profile if cache is empty on network failure', async () => { + // Mock network failure immediately when cache is empty + vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch')); + + const result = await fetchUserProfile('some-new-user'); + expect(result.login).toBe('some-new-user'); + expect(result.isOfflineFallback).toBe(true); + expect(result.bio).toContain('offline fallback'); + }); + }); + + describe('fetchUserRepos Fallback', () => { + it('falls back to stale cache data on network failure', async () => { + // 1. Populate cache + vi.mocked(fetch).mockResolvedValueOnce( + mockResponse([{ name: 'repo-1', stargazers_count: 5, language: 'TypeScript' }]) + ); + const firstResult = await fetchUserRepos('octocat'); + expect(firstResult).toHaveLength(1); + + // 2. Mock network failure + vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch')); + + // 3. Trigger error path + const result = await fetchUserRepos('octocat', { forceRefresh: true }); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('repo-1'); + }); + + it('returns empty array if cache is empty on network failure', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch')); + + const result = await fetchUserRepos('some-new-user'); + expect(result).toEqual([]); + }); + }); + + describe('fetchGitHubContributions Fallback', () => { + it('falls back to stale cache data on network failure', async () => { + // 1. Populate cache + const mockCalendar = { + totalContributions: 10, + weeks: [], + }; + vi.mocked(fetch).mockResolvedValueOnce( + mockResponse({ + data: { + user: { + contributionsCollection: { + contributionCalendar: mockCalendar, + commitContributionsByRepository: [], + }, + }, + }, + }) + ); + const firstResult = await fetchGitHubContributions('octocat'); + expect(firstResult.calendar.totalContributions).toBe(10); + + // 2. Mock network failure + vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch')); + + // 3. Trigger error path + const result = await fetchGitHubContributions('octocat', { forceRefresh: true }); + expect(result.calendar.totalContributions).toBe(10); + expect(result.isOfflineFallback).toBe(true); + }); + + it('returns empty mock contributions calendar if cache is empty on network failure', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch')); + + const result = await fetchGitHubContributions('some-new-user'); + expect(result.calendar.totalContributions).toBe(0); + expect(result.isOfflineFallback).toBe(true); + }); + }); +}); diff --git a/tests/pwa/offline-page.test.tsx b/tests/pwa/offline-page.test.tsx new file mode 100644 index 000000000..1773a2560 --- /dev/null +++ b/tests/pwa/offline-page.test.tsx @@ -0,0 +1,40 @@ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import OfflineFallback from '@/components/pwa/OfflineFallback'; + +// Mock location.reload +const reloadMock = vi.fn(); +Object.defineProperty(window, 'location', { + value: { reload: reloadMock }, + writable: true, +}); + +describe('OfflineFallback Component', () => { + beforeEach(() => { + reloadMock.mockClear(); + }); + + it('renders connection lost header, description, and button', () => { + render(); + + expect(screen.getByText('Connection Lost')).toBeInTheDocument(); + expect( + screen.getByText( + 'You are currently offline. Check your internet connection and try refreshing the page.' + ) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('triggers page reload when try again button is clicked', () => { + render(); + + const button = screen.getByRole('button', { name: /try again/i }); + fireEvent.click(button); + + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(button).toBeDisabled(); + }); +});