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
2 changes: 1 addition & 1 deletion src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
17 changes: 17 additions & 0 deletions src/services/__tests__/ethersService.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
22 changes: 19 additions & 3 deletions src/services/ethersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EthersModule> | null = null;

const loadEthers = (): Promise<EthersModule> => {
if (!ethersPromise) {
ethersPromise = import('ethers');
ethersPromise = import('ethers').catch((error: unknown) => {
ethersPromise = null;
throw error;
});
}
return ethersPromise;
};
Expand All @@ -18,8 +32,10 @@ const loadEthers = (): Promise<EthersModule> => {
* Get ethers library (lazy-loaded)
*/
export const getEthers = async (): Promise<EthersModule['ethers']> => {
const ethersModule = await loadEthers();
return ethersModule.ethers;
return ethersCircuitBreaker.execute(async () => {
const ethersModule = await loadEthers();
return ethersModule.ethers;
});
};

/**
Expand Down
56 changes: 56 additions & 0 deletions src/utils/__tests__/circuitBreaker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('CircuitBreaker', () => {
timeout: 1000,
monitoringPeriod: 5000,
maxConcurrentRequests: 5,
maxConcurrentHalfOpenProbes: 2,
};
circuitBreaker = new CircuitBreaker(config);
});
Expand Down Expand Up @@ -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<void>((resolve) => {
releaseFirstProbe = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise<void>((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'));

Expand Down Expand Up @@ -320,6 +375,7 @@ describe('CircuitBreaker', () => {
timeout: 30000,
monitoringPeriod: 20000,
maxConcurrentRequests: 20,
maxConcurrentHalfOpenProbes: 3,
};
const cb = createToastCircuitBreaker(customConfig);
expect(cb).toBeInstanceOf(CircuitBreaker);
Expand Down
19 changes: 19 additions & 0 deletions src/utils/circuitBreaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -37,6 +38,7 @@ const DEFAULT_CONFIG: CircuitBreakerConfig = {
timeout: 60000, // 1 minute
monitoringPeriod: 10000, // 10 seconds
maxConcurrentRequests: 10,
maxConcurrentHalfOpenProbes: 1,
};

export class CircuitBreaker {
Expand All @@ -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) {}
Expand Down Expand Up @@ -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();
Expand All @@ -95,6 +111,9 @@ export class CircuitBreaker {
throw error;
} finally {
this.activeRequests--;
if (isHalfOpenProbe) {
this.activeHalfOpenProbes--;
}
}
}

Expand Down
Loading