diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 86d7c354..5786a73a 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -3,7 +3,7 @@ import type { Course, PaginatedResponse, User, Topic } from '@/types/api'; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://teachlink.app'; -export const revalidate = 3600; // regenerate every hour +export const dynamic = 'force-dynamic'; const STATIC_ROUTES: MetadataRoute.Sitemap = [ { diff --git a/src/services/__tests__/ethersService.test.ts b/src/services/__tests__/ethersService.test.ts new file mode 100644 index 00000000..816591d9 --- /dev/null +++ b/src/services/__tests__/ethersService.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('ethersService', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('lazy-loads ethers successfully through the circuit breaker', async () => { + const { getEthers } = await import('../ethersService'); + + const [first, second] = await Promise.all([getEthers(), getEthers()]); + + expect(first).toBe(second); + expect(first).toHaveProperty('Wallet'); + expect(first).toHaveProperty('Contract'); + }); +}); diff --git a/src/services/ethersService.ts b/src/services/ethersService.ts index e0342c80..b15b170b 100644 --- a/src/services/ethersService.ts +++ b/src/services/ethersService.ts @@ -3,13 +3,27 @@ * This module dynamically imports ethers only when needed, reducing initial bundle size */ +import { CircuitBreaker } from '@/utils/circuitBreaker'; + type EthersModule = typeof import('ethers'); +const ethersCircuitBreaker = new CircuitBreaker({ + failureThreshold: 1, + successThreshold: 1, + timeout: 60000, + monitoringPeriod: 10000, + maxConcurrentRequests: 10, + maxConcurrentHalfOpenProbes: 1, +}); + let ethersPromise: Promise | null = null; const loadEthers = (): Promise => { if (!ethersPromise) { - ethersPromise = import('ethers'); + ethersPromise = import('ethers').catch((error: unknown) => { + ethersPromise = null; + throw error; + }); } return ethersPromise; }; @@ -18,8 +32,10 @@ const loadEthers = (): Promise => { * Get ethers library (lazy-loaded) */ export const getEthers = async (): Promise => { - const ethersModule = await loadEthers(); - return ethersModule.ethers; + return ethersCircuitBreaker.execute(async () => { + const ethersModule = await loadEthers(); + return ethersModule.ethers; + }); }; /** diff --git a/src/utils/__tests__/circuitBreaker.test.ts b/src/utils/__tests__/circuitBreaker.test.ts index 14e6bd98..ab4f1bd1 100644 --- a/src/utils/__tests__/circuitBreaker.test.ts +++ b/src/utils/__tests__/circuitBreaker.test.ts @@ -17,6 +17,7 @@ describe('CircuitBreaker', () => { timeout: 1000, monitoringPeriod: 5000, maxConcurrentRequests: 5, + maxConcurrentHalfOpenProbes: 2, }; circuitBreaker = new CircuitBreaker(config); }); @@ -176,6 +177,60 @@ describe('CircuitBreaker', () => { expect(circuitBreaker.getState()).toBe('CLOSED'); }); + it('should limit concurrent HALF_OPEN probes', async () => { + vi.useFakeTimers(); + + const probeConfig = { + ...config, + timeout: 100, + successThreshold: 3, + maxConcurrentHalfOpenProbes: 2, + }; + const breaker = new CircuitBreaker(probeConfig); + const failOp = vi.fn().mockRejectedValue(new Error('test error')); + + for (let i = 0; i < probeConfig.failureThreshold; i++) { + await expect(breaker.execute(failOp)).rejects.toThrow(); + } + + expect(breaker.getState()).toBe('OPEN'); + + await vi.advanceTimersByTimeAsync(probeConfig.timeout + 1); + + let releaseFirstProbe!: () => void; + let releaseSecondProbe!: () => void; + const probe = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstProbe = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSecondProbe = resolve; + }), + ); + + const firstProbe = breaker.execute(probe); + const secondProbe = breaker.execute(probe); + const rejectedProbe = breaker.execute(probe); + + expect(breaker.getState()).toBe('HALF_OPEN'); + expect(probe).toHaveBeenCalledTimes(2); + await expect(rejectedProbe).rejects.toThrow('Maximum concurrent half-open probes reached'); + + releaseFirstProbe(); + releaseSecondProbe(); + await Promise.all([firstProbe, secondProbe]); + + expect(breaker.getState()).toBe('HALF_OPEN'); + + vi.useRealTimers(); + }); + it('should reopen circuit on failure in HALF_OPEN', async () => { const operation = vi.fn().mockRejectedValue(new Error('test error')); @@ -320,6 +375,7 @@ describe('CircuitBreaker', () => { timeout: 30000, monitoringPeriod: 20000, maxConcurrentRequests: 20, + maxConcurrentHalfOpenProbes: 3, }; const cb = createToastCircuitBreaker(customConfig); expect(cb).toBeInstanceOf(CircuitBreaker); diff --git a/src/utils/circuitBreaker.ts b/src/utils/circuitBreaker.ts index b96f7ccc..9cddb325 100644 --- a/src/utils/circuitBreaker.ts +++ b/src/utils/circuitBreaker.ts @@ -18,6 +18,7 @@ export interface CircuitBreakerConfig { timeout: number; // Time in ms before attempting recovery monitoringPeriod: number; // Time window for failure counting maxConcurrentRequests: number; // Maximum concurrent toast operations + maxConcurrentHalfOpenProbes: number; // Maximum concurrent recovery probes } export interface CircuitBreakerMetrics { @@ -37,6 +38,7 @@ const DEFAULT_CONFIG: CircuitBreakerConfig = { timeout: 60000, // 1 minute monitoringPeriod: 10000, // 10 seconds maxConcurrentRequests: 10, + maxConcurrentHalfOpenProbes: 1, }; export class CircuitBreaker { @@ -49,6 +51,7 @@ export class CircuitBreaker { private totalFailures: number = 0; private totalSuccesses: number = 0; private activeRequests: number = 0; + private activeHalfOpenProbes: number = 0; private failureHistory: number[] = []; constructor(private config: CircuitBreakerConfig = DEFAULT_CONFIG) {} @@ -81,7 +84,20 @@ export class CircuitBreaker { throw new Error('Maximum concurrent requests reached'); } + const isHalfOpenProbe = this.state === 'HALF_OPEN'; + + if (isHalfOpenProbe && this.activeHalfOpenProbes >= this.config.maxConcurrentHalfOpenProbes) { + this.totalFailures++; + if (fallback) { + return fallback(); + } + throw new Error('Maximum concurrent half-open probes reached'); + } + this.activeRequests++; + if (isHalfOpenProbe) { + this.activeHalfOpenProbes++; + } try { const result = await operation(); @@ -95,6 +111,9 @@ export class CircuitBreaker { throw error; } finally { this.activeRequests--; + if (isHalfOpenProbe) { + this.activeHalfOpenProbes--; + } } }