use apcore::context::Context;
use apcore::errors::ErrorCode;
use apcore::middleware::circuit_breaker::{CircuitBreakerMiddleware, CircuitBreakerState};
use apcore::middleware::Middleware;
use apcore::ModuleError;
use async_trait::async_trait;
fn indicates_unhealthy_dependency(code: ErrorCode) -> bool {
match code {
ErrorCode::SchemaValidationError
| ErrorCode::SchemaUnionNoMatch
| ErrorCode::SchemaUnionAmbiguous
| ErrorCode::SchemaMaxDepthExceeded
| ErrorCode::GeneralInvalidInput
| ErrorCode::ModuleNotFound
| ErrorCode::InvalidModuleId => false,
ErrorCode::ACLDenied
| ErrorCode::ApprovalDenied
| ErrorCode::ApprovalTimeout
| ErrorCode::ApprovalPending
| ErrorCode::ExecutionCancelled
| ErrorCode::CallDepthExceeded
| ErrorCode::CircularCall
| ErrorCode::CallFrequencyExceeded => false,
_ => true,
}
}
#[derive(Debug)]
pub struct HealthOnlyCircuitBreaker {
inner: CircuitBreakerMiddleware,
}
impl HealthOnlyCircuitBreaker {
pub fn new(inner: CircuitBreakerMiddleware) -> Self {
Self { inner }
}
pub fn with_defaults() -> Self {
Self::new(CircuitBreakerMiddleware::builder().build())
}
pub fn state(&self, module_id: &str, caller_id: &str) -> CircuitBreakerState {
self.inner.state(module_id, caller_id)
}
}
#[async_trait]
impl Middleware for HealthOnlyCircuitBreaker {
fn name(&self) -> &'static str {
"circuit_breaker"
}
fn priority(&self) -> u16 {
self.inner.priority()
}
async fn before(
&self,
module_id: &str,
inputs: serde_json::Value,
ctx: &Context<serde_json::Value>,
) -> Result<Option<serde_json::Value>, ModuleError> {
self.inner.before(module_id, inputs, ctx).await
}
async fn after(
&self,
module_id: &str,
inputs: serde_json::Value,
output: serde_json::Value,
ctx: &Context<serde_json::Value>,
) -> Result<Option<serde_json::Value>, ModuleError> {
self.inner.after(module_id, inputs, output, ctx).await
}
async fn on_error(
&self,
module_id: &str,
inputs: serde_json::Value,
error: &ModuleError,
ctx: &Context<serde_json::Value>,
) -> Result<Option<serde_json::Value>, ModuleError> {
if !indicates_unhealthy_dependency(error.code) {
tracing::debug!(
module_id,
error_code = ?error.code,
"Not counting caller-fault error against the circuit breaker"
);
self.release_probe_slot(module_id, ctx);
return Ok(None);
}
self.inner.on_error(module_id, inputs, error, ctx).await
}
}
impl HealthOnlyCircuitBreaker {
fn release_probe_slot(&self, module_id: &str, ctx: &Context<serde_json::Value>) {
let caller_id = ctx.caller_id.clone().unwrap_or_default();
if self.inner.state(module_id, &caller_id) != CircuitBreakerState::HalfOpen {
return;
}
tracing::debug!(
module_id,
caller_id = %caller_id,
"Releasing the half-open probe slot held by a suppressed caller-fault error"
);
self.inner
.force_state(module_id, &caller_id, CircuitBreakerState::HalfOpen, None);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn context() -> Context<serde_json::Value> {
Context::anonymous()
}
async fn report_errors(breaker: &HealthOnlyCircuitBreaker, code: ErrorCode, count: usize) {
let ctx = context();
for _ in 0..count {
let error = ModuleError::new(code, "boom".to_string());
breaker
.on_error("cli.ls", json!({}), &error, &ctx)
.await
.expect("on_error never fails");
}
}
#[tokio::test]
async fn test_validation_errors_do_not_open_the_circuit() {
let breaker = HealthOnlyCircuitBreaker::with_defaults();
report_errors(&breaker, ErrorCode::GeneralInvalidInput, 10).await;
assert_eq!(breaker.state("cli.ls", ""), CircuitBreakerState::Closed);
assert!(breaker
.before("cli.ls", json!({}), &context())
.await
.is_ok());
}
#[tokio::test]
async fn test_suppressed_error_releases_the_half_open_probe_slot() {
let breaker = HealthOnlyCircuitBreaker::new(
CircuitBreakerMiddleware::builder()
.recovery_window_ms(60_000) .build(),
);
report_errors(&breaker, ErrorCode::ModuleTimeout, 10).await;
assert_eq!(breaker.state("cli.ls", ""), CircuitBreakerState::Open);
breaker
.inner
.force_state("cli.ls", "", CircuitBreakerState::HalfOpen, None);
breaker
.before("cli.ls", json!({}), &context())
.await
.expect("half-open admits one probe");
let refusal = ModuleError::new(ErrorCode::GeneralInvalidInput, "bad args".to_string());
breaker
.on_error("cli.ls", json!({}), &refusal, &context())
.await
.expect("on_error never fails");
breaker
.before("cli.ls", json!({}), &context())
.await
.expect(
"a suppressed caller-fault error must hand the probe slot back, \
otherwise it disables the module for every other caller",
);
}
#[tokio::test]
async fn test_releasing_the_probe_slot_does_not_erase_the_failure_history() {
let breaker = HealthOnlyCircuitBreaker::with_defaults();
report_errors(&breaker, ErrorCode::ModuleTimeout, 10).await;
breaker
.inner
.force_state("cli.ls", "", CircuitBreakerState::HalfOpen, None);
breaker
.before("cli.ls", json!({}), &context())
.await
.expect("half-open admits one probe");
let refusal = ModuleError::new(ErrorCode::GeneralInvalidInput, "bad args".to_string());
breaker
.on_error("cli.ls", json!({}), &refusal, &context())
.await
.expect("on_error never fails");
assert_eq!(
breaker.state("cli.ls", ""),
CircuitBreakerState::HalfOpen,
"suppression must not close a circuit that real failures opened"
);
}
#[tokio::test]
async fn test_a_real_failure_during_a_probe_still_reopens_the_circuit() {
let breaker = HealthOnlyCircuitBreaker::with_defaults();
report_errors(&breaker, ErrorCode::ModuleTimeout, 10).await;
breaker
.inner
.force_state("cli.ls", "", CircuitBreakerState::HalfOpen, None);
breaker
.before("cli.ls", json!({}), &context())
.await
.expect("half-open admits one probe");
report_errors(&breaker, ErrorCode::ModuleTimeout, 1).await;
assert_eq!(breaker.state("cli.ls", ""), CircuitBreakerState::Open);
assert_eq!(
breaker
.before("cli.ls", json!({}), &context())
.await
.expect_err("an open circuit rejects")
.code,
ErrorCode::CircuitBreakerOpen
);
}
#[tokio::test]
async fn test_schema_validation_errors_do_not_open_the_circuit() {
let breaker = HealthOnlyCircuitBreaker::with_defaults();
report_errors(&breaker, ErrorCode::SchemaValidationError, 10).await;
assert_eq!(breaker.state("cli.ls", ""), CircuitBreakerState::Closed);
}
#[tokio::test]
async fn test_acl_denials_do_not_open_the_circuit() {
let breaker = HealthOnlyCircuitBreaker::with_defaults();
report_errors(&breaker, ErrorCode::ACLDenied, 10).await;
assert_eq!(breaker.state("cli.ls", ""), CircuitBreakerState::Closed);
}
#[tokio::test]
async fn test_timeouts_still_open_the_circuit() {
let breaker = HealthOnlyCircuitBreaker::with_defaults();
report_errors(&breaker, ErrorCode::ModuleTimeout, 10).await;
assert_eq!(breaker.state("cli.ls", ""), CircuitBreakerState::Open);
let rejected = breaker.before("cli.ls", json!({}), &context()).await;
assert_eq!(
rejected.expect_err("an open circuit rejects").code,
ErrorCode::CircuitBreakerOpen
);
}
#[tokio::test]
async fn test_execution_errors_still_open_the_circuit() {
let breaker = HealthOnlyCircuitBreaker::with_defaults();
report_errors(&breaker, ErrorCode::ModuleExecuteError, 10).await;
assert_eq!(breaker.state("cli.ls", ""), CircuitBreakerState::Open);
}
#[test]
fn test_unknown_error_codes_default_to_unhealthy() {
assert!(indicates_unhealthy_dependency(ErrorCode::ModuleLoadError));
assert!(indicates_unhealthy_dependency(
ErrorCode::GeneralInternalError
));
assert!(!indicates_unhealthy_dependency(
ErrorCode::GeneralInvalidInput
));
}
}