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
400 changes: 381 additions & 19 deletions backend/src/config/database.ts

Large diffs are not rendered by default.

31 changes: 15 additions & 16 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ Sentry.init({
});
import cors from 'cors';
import { tokenBucketRateLimit } from './middleware/rate-limit.js';
import compression from 'compression';
import { compressionMiddleware, getCompressionMetrics } from './middleware/compression.js';
import { poolMetrics } from './config/database.js';
import { config } from './config.js';
import { verificationRouter } from './routes/verification.js';
import { invoiceRouter } from './routes/invoice.js';
Expand Down Expand Up @@ -102,6 +103,7 @@ import { SecurityMiddleware, SecurityMonitor } from './middleware/security.js';
import { sanitizeInput, contentSecurityPolicy } from './middleware/sanitize.js';
import { signaturesRouter } from './routes/signatures.js';
import { createSandboxRouter } from './routes/sandbox.js';
import { circuitBreakerRouter } from './routes/circuit-breaker.js';
import SandboxManager from './services/sandbox.js';
import MockPaymentProcessor from './services/mock-payments.js';
import TestDataSeeder from './services/test-data-seeder.js';
Expand Down Expand Up @@ -186,21 +188,10 @@ app.use(express.json());
app.use(express.text({ type: ['text/csv', 'text/plain'] }));

app.use(
compression({
threshold: config.compression.threshold,
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
const contentType = res.getHeader('Content-Type');
if (typeof contentType === 'string' && contentType.includes('application/json')) {
return true;
}
if (Array.isArray(contentType) && contentType.some((ct) => ct.includes('application/json'))) {
return true;
}
return compression.filter(req, res);
},
compressionMiddleware({
brotliLevel: 5,
gzipLevel: 6,
minSizeBytes: 1024,
})
);

Expand Down Expand Up @@ -280,6 +271,14 @@ apiV1Router.use('/nfc', nfcRouter);
// Cache management
apiV1Router.use('/cache', cacheRouter);

apiV1Router.use('/circuit-breaker', circuitBreakerRouter);
apiV1Router.get('/compression/metrics', (_req, res) => {
res.json(getCompressionMetrics());
});
apiV1Router.get('/pool/metrics', (_req, res) => {
res.json(poolMetrics.snapshot());
});

app.use('/api/v1', ipAllowlistMiddleware(), apiV1Router);

app.use('/api/v1/notifications', notificationsRouter);
Expand Down
253 changes: 201 additions & 52 deletions backend/src/middleware/circuit-breaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ interface CircuitBreakerConfig {
successThreshold: number;
timeoutMs: number;
halfOpenMaxCalls: number;
requestTimeoutMs: number;
}

interface CircuitBreakerMetrics {
totalCalls: number;
successfulCalls: number;
failedCalls: number;
timeoutCalls: number;
rejectedCalls: number;
lastFailureAt?: number;
lastSuccessAt?: number;
openedAt?: number;
halfOpenAttempts: number;
}

interface CircuitBreakerState {
Expand All @@ -16,93 +29,135 @@ interface CircuitBreakerState {
halfOpenCalls: number;
lastFailureAt?: number;
openedAt?: number;
metrics: CircuitBreakerMetrics;
}

interface CircuitBreakerEntry {
config: CircuitBreakerConfig;
state: CircuitBreakerState;
}

const DEFAULT_CONFIG: CircuitBreakerConfig = {
failureThreshold: 5,
successThreshold: 2,
timeoutMs: 60_000,
halfOpenMaxCalls: 3,
requestTimeoutMs: 10_000,
};

const circuits = new Map<string, CircuitBreakerState>();
const circuits = new Map<string, CircuitBreakerEntry>();

function getOrCreate(name: string): CircuitBreakerState {
function getOrCreate(name: string, configOverride?: Partial<CircuitBreakerConfig>): CircuitBreakerEntry {
const existing = circuits.get(name);
if (existing) return existing;
const state: CircuitBreakerState = { state: 'closed', failures: 0, successes: 0, halfOpenCalls: 0 };
circuits.set(name, state);
return state;
}

function onSuccess(name: string, config: CircuitBreakerConfig): void {
const cb = getOrCreate(name);
if (cb.state === 'half_open') {
cb.successes += 1;
if (cb.successes >= config.successThreshold) {
cb.state = 'closed';
cb.failures = 0;
cb.successes = 0;
cb.halfOpenCalls = 0;
const config = { ...DEFAULT_CONFIG, ...configOverride };
const state: CircuitBreakerState = {
state: 'closed',
failures: 0,
successes: 0,
halfOpenCalls: 0,
metrics: {
totalCalls: 0,
successfulCalls: 0,
failedCalls: 0,
timeoutCalls: 0,
rejectedCalls: 0,
halfOpenAttempts: 0,
},
};
const entry: CircuitBreakerEntry = { config, state };
circuits.set(name, entry);
return entry;
}

function onSuccess(name: string): void {
const entry = circuits.get(name);
if (!entry) return;
const { state, config } = entry;

state.metrics.totalCalls++;
state.metrics.successfulCalls++;
state.metrics.lastSuccessAt = Date.now();

if (state.state === 'half_open') {
state.successes += 1;
if (state.successes >= config.successThreshold) {
state.state = 'closed';
state.failures = 0;
state.successes = 0;
state.halfOpenCalls = 0;
}
} else if (cb.state === 'closed') {
cb.failures = Math.max(0, cb.failures - 1);
} else if (state.state === 'closed') {
state.failures = Math.max(0, state.failures - 1);
}
circuits.set(name, cb);
}

function onFailure(name: string, config: CircuitBreakerConfig): void {
const cb = getOrCreate(name);
cb.failures += 1;
cb.lastFailureAt = Date.now();
function onFailure(name: string): void {
const entry = circuits.get(name);
if (!entry) return;
const { state, config } = entry;

state.failures += 1;
state.lastFailureAt = Date.now();
state.metrics.totalCalls++;
state.metrics.failedCalls++;

if (cb.state === 'half_open' || cb.failures >= config.failureThreshold) {
cb.state = 'open';
cb.openedAt = Date.now();
cb.halfOpenCalls = 0;
cb.successes = 0;
if (state.state === 'half_open' || state.failures >= config.failureThreshold) {
state.state = 'open';
state.openedAt = Date.now();
state.metrics.openedAt = Date.now();
state.halfOpenCalls = 0;
state.successes = 0;
}
}

circuits.set(name, cb);
function onTimeout(name: string): void {
const entry = circuits.get(name);
if (!entry) return;
entry.state.metrics.timeoutCalls++;
onFailure(name);
}

function shouldAllow(name: string, config: CircuitBreakerConfig): boolean {
const cb = getOrCreate(name);
function shouldAllow(name: string): boolean {
const entry = circuits.get(name);
if (!entry) return true;
const { state, config } = entry;

if (cb.state === 'closed') return true;
if (state.state === 'closed') return true;

if (cb.state === 'open') {
const elapsed = Date.now() - (cb.openedAt ?? 0);
if (state.state === 'open') {
const elapsed = Date.now() - (state.openedAt ?? 0);
if (elapsed >= config.timeoutMs) {
cb.state = 'half_open';
cb.successes = 0;
cb.halfOpenCalls = 0;
circuits.set(name, cb);
state.state = 'half_open';
state.successes = 0;
state.halfOpenCalls = 0;
state.metrics.halfOpenAttempts++;
return true;
}
state.metrics.rejectedCalls++;
return false;
}

// half_open: allow limited calls
if (cb.halfOpenCalls < config.halfOpenMaxCalls) {
cb.halfOpenCalls += 1;
circuits.set(name, cb);
if (state.halfOpenCalls < config.halfOpenMaxCalls) {
state.halfOpenCalls += 1;
return true;
}

state.metrics.rejectedCalls++;
return false;
}

export function circuitBreaker(name: string, config: Partial<CircuitBreakerConfig> = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config };
getOrCreate(name, config);

return (req: Request, res: Response, next: NextFunction): void => {
if (!shouldAllow(name, cfg)) {
if (!shouldAllow(name)) {
res.status(503).json({
error: {
code: 'CIRCUIT_OPEN',
message: `Service ${name} is temporarily unavailable. Circuit breaker is open.`,
status: 503,
retryAfterMs: getRetryAfterMs(name),
},
});
return;
Expand All @@ -111,9 +166,9 @@ export function circuitBreaker(name: string, config: Partial<CircuitBreakerConfi
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
if (res.statusCode >= 500) {
onFailure(name, cfg);
onFailure(name);
} else {
onSuccess(name, cfg);
onSuccess(name);
}
return originalJson(body);
};
Expand All @@ -122,14 +177,108 @@ export function circuitBreaker(name: string, config: Partial<CircuitBreakerConfi
};
}

export function getCircuitState(name: string): CircuitBreakerState & { name: string } {
return { name, ...getOrCreate(name) };
function getRetryAfterMs(name: string): number {
const entry = circuits.get(name);
if (!entry || !entry.state.openedAt) return entry?.config.timeoutMs ?? DEFAULT_CONFIG.timeoutMs;
const elapsed = Date.now() - entry.state.openedAt;
return Math.max(0, entry.config.timeoutMs - elapsed);
}

export function getCircuitState(name: string) {
const entry = circuits.get(name);
if (!entry) return null;
return {
name,
state: entry.state.state,
failures: entry.state.failures,
successes: entry.state.successes,
halfOpenCalls: entry.state.halfOpenCalls,
lastFailureAt: entry.state.lastFailureAt,
openedAt: entry.state.openedAt,
config: entry.config,
metrics: entry.state.metrics,
};
}

export function getAllCircuits() {
return Array.from(circuits.entries()).map(([name, entry]) => ({
name,
state: entry.state.state,
failures: entry.state.failures,
successes: entry.state.successes,
halfOpenCalls: entry.state.halfOpenCalls,
lastFailureAt: entry.state.lastFailureAt,
openedAt: entry.state.openedAt,
config: entry.config,
metrics: entry.state.metrics,
}));
}

export function getAllCircuits(): Array<CircuitBreakerState & { name: string }> {
return Array.from(circuits.entries()).map(([name, state]) => ({ name, ...state }));
export function resetCircuit(name: string): boolean {
const entry = circuits.get(name);
if (!entry) return false;
entry.state = {
state: 'closed',
failures: 0,
successes: 0,
halfOpenCalls: 0,
metrics: {
totalCalls: 0,
successfulCalls: 0,
failedCalls: 0,
timeoutCalls: 0,
rejectedCalls: 0,
halfOpenAttempts: 0,
},
};
return true;
}

export function resetCircuit(name: string): void {
circuits.set(name, { state: 'closed', failures: 0, successes: 0, halfOpenCalls: 0 });
export async function withCircuitBreaker<T>(
name: string,
fn: () => Promise<T>,
fallback?: () => Promise<T>,
configOverride?: Partial<CircuitBreakerConfig>,
): Promise<T> {
const entry = getOrCreate(name, configOverride);
const { state, config } = entry;

if (!shouldAllow(name)) {
if (fallback) {
return fallback();
}
throw new CircuitBreakerError(name, `Circuit breaker is open for ${name}`);
}

const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
onTimeout(name);
reject(new CircuitBreakerError(name, `Request to ${name} timed out after ${config.requestTimeoutMs}ms`, true));
}, config.requestTimeoutMs);
});

try {
const result = await Promise.race([fn(), timeoutPromise]);
onSuccess(name);
return result;
} catch (error) {
if (error instanceof CircuitBreakerError) throw error;
onFailure(name);
if (fallback) {
return fallback();
}
throw error;
}
}

export class CircuitBreakerError extends Error {
serviceName: string;
isTimeout: boolean;

constructor(serviceName: string, message: string, isTimeout = false) {
super(message);
this.name = 'CircuitBreakerError';
this.serviceName = serviceName;
this.isTimeout = isTimeout;
}
}
Loading
Loading