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.
144 lines
3.4 KiB
JavaScript
144 lines
3.4 KiB
JavaScript
'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 };
|