feat: Phase 0+1 — repo sync, pino, lint fixes, core components
Phase 0: - Synced latest live-status.js from workspace (9928 bytes) - Fixed 43 lint issues: empty catch blocks, console statements - Added pino dependency - Created src/tool-labels.json with all known tool mappings - make check passes Phase 1 (Core Components): - src/config.js: env-var config with validation, throws on missing required vars - src/logger.js: pino singleton with child loggers, level validation - src/circuit-breaker.js: CLOSED/OPEN/HALF_OPEN state machine with callbacks - src/tool-labels.js: exact/prefix/regex tool->label resolver with external override - src/status-box.js: Mattermost post manager (keepAlive, throttle, retry, circuit breaker) - src/status-formatter.js: pure SessionState->text formatter (nested, compact) - src/health.js: HTTP health endpoint + metrics - src/status-watcher.js: JSONL file watcher (inotify, compaction detection, idle detection) Tests: - test/unit/config.test.js: 7 tests - test/unit/circuit-breaker.test.js: 12 tests - test/unit/logger.test.js: 5 tests - test/unit/status-formatter.test.js: 20 tests - test/unit/tool-labels.test.js: 15 tests All 59 unit tests pass. make check clean.
This commit is contained in:
143
src/circuit-breaker.js
Normal file
143
src/circuit-breaker.js
Normal file
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* circuit-breaker.js — Circuit breaker for API resilience.
|
||||
*
|
||||
* States:
|
||||
* CLOSED — Normal operation. Failures tracked.
|
||||
* OPEN — Too many failures. Calls rejected immediately.
|
||||
* HALF_OPEN — Cooldown expired. One probe call allowed.
|
||||
*
|
||||
* Transition rules:
|
||||
* CLOSED -> OPEN: failures >= threshold
|
||||
* OPEN -> HALF_OPEN: cooldown expired
|
||||
* HALF_OPEN -> CLOSED: probe succeeds
|
||||
* HALF_OPEN -> OPEN: probe fails
|
||||
*/
|
||||
|
||||
const STATE = {
|
||||
CLOSED: 'closed',
|
||||
OPEN: 'open',
|
||||
HALF_OPEN: 'half_open',
|
||||
};
|
||||
|
||||
class CircuitBreaker {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.threshold - Consecutive failures to open (default 5)
|
||||
* @param {number} opts.cooldownMs - Milliseconds before half-open probe (default 30000)
|
||||
* @param {Function} [opts.onStateChange] - Called with (newState, oldState)
|
||||
* @param {object} [opts.logger] - Optional logger
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.threshold = opts.threshold || 5;
|
||||
this.cooldownMs = opts.cooldownMs || 30000;
|
||||
this.onStateChange = opts.onStateChange || null;
|
||||
this.logger = opts.logger || null;
|
||||
|
||||
this.state = STATE.CLOSED;
|
||||
this.failures = 0;
|
||||
this.openedAt = null;
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function through the circuit breaker.
|
||||
* Throws CircuitOpenError if the circuit is open.
|
||||
* @param {Function} fn - Async function to execute
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
async execute(fn) {
|
||||
if (this.state === STATE.OPEN) {
|
||||
const elapsed = Date.now() - this.openedAt;
|
||||
if (elapsed >= this.cooldownMs) {
|
||||
this._transition(STATE.HALF_OPEN);
|
||||
} else {
|
||||
throw new CircuitOpenError(
|
||||
`Circuit open (${Math.ceil((this.cooldownMs - elapsed) / 1000)}s remaining)`,
|
||||
this.lastError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this._onSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
this._onFailure(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
_onSuccess() {
|
||||
if (this.state === STATE.HALF_OPEN) {
|
||||
this._transition(STATE.CLOSED);
|
||||
}
|
||||
this.failures = 0;
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
_onFailure(err) {
|
||||
this.lastError = err;
|
||||
this.failures++;
|
||||
|
||||
if (this.state === STATE.HALF_OPEN) {
|
||||
// Probe failed — reopen
|
||||
this.openedAt = Date.now();
|
||||
this._transition(STATE.OPEN);
|
||||
} else if (this.state === STATE.CLOSED && this.failures >= this.threshold) {
|
||||
this.openedAt = Date.now();
|
||||
this._transition(STATE.OPEN);
|
||||
}
|
||||
}
|
||||
|
||||
_transition(newState) {
|
||||
const oldState = this.state;
|
||||
this.state = newState;
|
||||
|
||||
if (newState === STATE.CLOSED) {
|
||||
this.failures = 0;
|
||||
this.openedAt = null;
|
||||
}
|
||||
|
||||
if (this.logger) {
|
||||
this.logger.warn({ from: oldState, to: newState }, 'Circuit breaker state change');
|
||||
}
|
||||
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange(newState, oldState);
|
||||
}
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getMetrics() {
|
||||
return {
|
||||
state: this.state,
|
||||
failures: this.failures,
|
||||
threshold: this.threshold,
|
||||
openedAt: this.openedAt,
|
||||
lastError: this.lastError ? this.lastError.message : null,
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = STATE.CLOSED;
|
||||
this.failures = 0;
|
||||
this.openedAt = null;
|
||||
this.lastError = null;
|
||||
}
|
||||
}
|
||||
|
||||
class CircuitOpenError extends Error {
|
||||
constructor(message, cause) {
|
||||
super(message);
|
||||
this.name = 'CircuitOpenError';
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CircuitBreaker, CircuitOpenError, STATE };
|
||||
Reference in New Issue
Block a user