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
35 changes: 35 additions & 0 deletions backend/src/common/circuit-breaker/circuit-breaker.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
Injectable,
NestMiddleware,
ServiceUnavailableException,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { CircuitBreaker } from './circuit-breaker';
import { CircuitBreakerOptions } from './circuit-breaker.options';

@Injectable()
export class CircuitBreakerMiddleware implements NestMiddleware {
private readonly breaker: CircuitBreaker;

constructor(options: CircuitBreakerOptions = {}) {
this.breaker = new CircuitBreaker(options);
}

use(req: Request, res: Response, next: NextFunction): void {
if (!this.breaker.allowRequest()) {
throw new ServiceUnavailableException('Circuit breaker is OPEN');
}

const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
if (res.statusCode >= 500) {
this.breaker.recordFailure();
} else {
this.breaker.recordSuccess();
}
return originalJson(body);
};

next();
}
}
8 changes: 8 additions & 0 deletions backend/src/common/circuit-breaker/circuit-breaker.options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface CircuitBreakerOptions {
/** Number of consecutive failures before opening the circuit. Default: 5 */
failureThreshold?: number;
/** Milliseconds to wait in OPEN state before moving to HALF_OPEN. Default: 60000 */
timeout?: number;
/** Milliseconds between retry attempts in HALF_OPEN state. Default: 5000 */
halfOpenRetryInterval?: number;
}
104 changes: 104 additions & 0 deletions backend/src/common/circuit-breaker/circuit-breaker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { CircuitBreaker, CircuitState } from './circuit-breaker';

describe('CircuitBreaker', () => {
let cb: CircuitBreaker;

beforeEach(() => {
cb = new CircuitBreaker({ failureThreshold: 3, timeout: 1000, halfOpenRetryInterval: 200 });
});

// ── CLOSED state ──────────────────────────────────────────────────────────

it('starts in CLOSED state', () => {
expect(cb.getState()).toBe(CircuitState.CLOSED);
});

it('allows requests in CLOSED state', () => {
expect(cb.allowRequest()).toBe(true);
});

it('stays CLOSED below failure threshold', () => {
cb.recordFailure();
cb.recordFailure();
expect(cb.getState()).toBe(CircuitState.CLOSED);
});

// ── CLOSED → OPEN transition ──────────────────────────────────────────────

it('opens after reaching failure threshold', () => {
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.getState()).toBe(CircuitState.OPEN);
});

it('blocks requests in OPEN state', () => {
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.allowRequest()).toBe(false);
});

// ── OPEN → HALF_OPEN transition ───────────────────────────────────────────

it('transitions to HALF_OPEN after timeout', async () => {
cb = new CircuitBreaker({ failureThreshold: 1, timeout: 50, halfOpenRetryInterval: 10 });
cb.recordFailure();
expect(cb.getState()).toBe(CircuitState.OPEN);

await new Promise((r) => setTimeout(r, 60));
expect(cb.allowRequest()).toBe(true);
expect(cb.getState()).toBe(CircuitState.HALF_OPEN);
});

// ── HALF_OPEN → CLOSED transition ────────────────────────────────────────

it('closes after success in HALF_OPEN state', async () => {
cb = new CircuitBreaker({ failureThreshold: 1, timeout: 50, halfOpenRetryInterval: 10 });
cb.recordFailure();
await new Promise((r) => setTimeout(r, 60));
cb.allowRequest(); // probe → HALF_OPEN
cb.recordSuccess();
expect(cb.getState()).toBe(CircuitState.CLOSED);
});

// ── HALF_OPEN → OPEN transition ───────────────────────────────────────────

it('re-opens on failure in HALF_OPEN state', async () => {
cb = new CircuitBreaker({ failureThreshold: 1, timeout: 50, halfOpenRetryInterval: 10 });
cb.recordFailure();
await new Promise((r) => setTimeout(r, 60));
cb.allowRequest(); // probe → HALF_OPEN
cb.recordFailure();
expect(cb.getState()).toBe(CircuitState.OPEN);
});

// ── Configurable options ──────────────────────────────────────────────────

it('respects custom failureThreshold', () => {
const custom = new CircuitBreaker({ failureThreshold: 10 });
for (let i = 0; i < 9; i++) custom.recordFailure();
expect(custom.getState()).toBe(CircuitState.CLOSED);
custom.recordFailure();
expect(custom.getState()).toBe(CircuitState.OPEN);
});

it('resets failure count on success', () => {
cb.recordFailure();
cb.recordFailure();
cb.recordSuccess();
expect(cb.getState()).toBe(CircuitState.CLOSED);
// Threshold still requires 3 failures from scratch
cb.recordFailure();
cb.recordFailure();
expect(cb.getState()).toBe(CircuitState.CLOSED);
});

it('throttles probes in HALF_OPEN by halfOpenRetryInterval', async () => {
cb = new CircuitBreaker({ failureThreshold: 1, timeout: 50, halfOpenRetryInterval: 500 });
cb.recordFailure();
await new Promise((r) => setTimeout(r, 60));
expect(cb.allowRequest()).toBe(true); // first probe allowed
expect(cb.allowRequest()).toBe(false); // too soon for second probe
});
});
64 changes: 64 additions & 0 deletions backend/src/common/circuit-breaker/circuit-breaker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
export enum CircuitState {
CLOSED = 'CLOSED',
OPEN = 'OPEN',
HALF_OPEN = 'HALF_OPEN',
}

import { CircuitBreakerOptions } from './circuit-breaker.options';

export class CircuitBreaker {
private state = CircuitState.CLOSED;
private failures = 0;
private openedAt = 0;
private lastHalfOpenAttempt = 0;

private readonly failureThreshold: number;
private readonly timeout: number;
private readonly halfOpenRetryInterval: number;

constructor(options: CircuitBreakerOptions = {}) {
this.failureThreshold = options.failureThreshold ?? 5;
this.timeout = options.timeout ?? 60_000;
this.halfOpenRetryInterval = options.halfOpenRetryInterval ?? 5_000;
}

getState(): CircuitState {
return this.state;
}

/** Returns true if the request should be allowed through. */
allowRequest(): boolean {
const now = Date.now();

if (this.state === CircuitState.CLOSED) return true;

if (this.state === CircuitState.OPEN) {
if (now - this.openedAt >= this.timeout) {
this.state = CircuitState.HALF_OPEN;
this.lastHalfOpenAttempt = now;
return true;
}
return false;
}

// HALF_OPEN: allow one probe per retry interval
if (now - this.lastHalfOpenAttempt >= this.halfOpenRetryInterval) {
this.lastHalfOpenAttempt = now;
return true;
}
return false;
}

recordSuccess(): void {
this.failures = 0;
this.state = CircuitState.CLOSED;
}

recordFailure(): void {
this.failures += 1;
if (this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold) {
this.state = CircuitState.OPEN;
this.openedAt = Date.now();
}
}
}
5 changes: 3 additions & 2 deletions backend/src/common/common.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { PaginationProvider } from './pagination/provider/pagination-provider';
import { CorrelationIdMiddleware } from './middleware/correlation-id.middleware';
import { GeolocationMiddleware } from './middleware/geolocation.middleware';
import { RedisModule } from '../redis/redis.module';
import { CircuitBreakerMiddleware } from './circuit-breaker/circuit-breaker.middleware';

@Module({
imports: [RedisModule],
providers: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware],
exports: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware],
providers: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware, CircuitBreakerMiddleware],
exports: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware, CircuitBreakerMiddleware],
})
export class CommonModule {}

Expand Down
3 changes: 3 additions & 0 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ name = "contract"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
soroban-sdk = "23"

Expand Down
Loading
Loading