Closes #489
Adds a production-grade circuit breaker (KycCircuit) in front of the KYC provider adapter that gracefully handles vendor degradation. When the KYC vendor fails consecutively, the circuit trips OPEN and serves cached non-negative decisions (if available and fresh). Half-open probes are single-flight to prevent thundering herd.
| File | Purpose |
|---|---|
src/services/kyc/kycCircuitBreaker.ts |
KycCircuit class — implements KycProvider as a decorator wrapping any provider with circuit breaker logic |
src/services/kyc/__tests__/kycCircuitBreaker.test.ts |
27 tests covering all states, edge cases, security invariants |
| File | Change |
|---|---|
src/config/env.ts |
Added KYC_CIRCUIT_TRIP_ERRORS, KYC_CIRCUIT_CACHE_TTL_MS, KYC_CIRCUIT_HALF_OPEN_MS env var schemas |
CLOSED ──(tripErrorCount consecutive failures)──▶ OPEN
▲ │
│ (halfOpenAfterMs elapsed) │
│ │ │
│ ▼ │
│ HALF_OPEN ──(probe fails)──────▶
│ │
└──(probe succeeds)──────┘
- Never cache
rejecteddecisions — a denied KYC result is never served as a cached positive fallback - Degraded-mode audit trail — every cached fallback is recorded as a
SECURITY_VIOLATIONaudit event withaction: kyc.circuit.degraded_fallback - Single-flight half-open probes —
tryEnterHalfOpen()atomically checks and setshalfOpenInFlightto prevent thundering herd - Bounded cache TTL — default 5-minute TTL, absolute 15-minute max eviction
- Circuit trip audit — tripping and probe failures emit separate audit events
kyc.circuit.state— gauge (0=CLOSED, 1=HALF_OPEN, 2=OPEN)kyc.circuit.failure— counter (per provider, per state)
| Variable | Default | Description |
|---|---|---|
KYC_CIRCUIT_TRIP_ERRORS |
3 |
Consecutive failures before circuit trips |
KYC_CIRCUIT_CACHE_TTL_MS |
300000 (5 min) |
How long a cached decision is considered fresh |
KYC_CIRCUIT_HALF_OPEN_MS |
30000 (30 s) |
Wait time before attempting a half-open probe |
const circuit = new KycCircuit(
underlyingProvider, // Any KycProvider implementation
securityAuditRepository, // SecurityAuditRepository
{ tripErrorCount: 5 }, // Optional config overrides
);
// Use circuit anywhere a KycProvider is expected:
const result = await circuit.initiateCheck(investorId, applicantInfo);- 60 KYC-related tests pass (27 new circuit breaker + 33 existing)
- No regressions in existing KYC router or risk-tier service tests
- Edge cases covered: expired cache, rejected cache blacklist, concurrent half-open probes, error propagation
The circuit breaker decorator is stateless with respect to the caller and can be safely wrapped around any KycProvider instance. Audit events are fire-and-forget with .catch(() => {}) — audit failures never block the KYC flow. Cache entries are keyed by investor ID and silently evicted after the max TTL.