use core::fmt;
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::{Arc, Mutex, MutexGuard, RwLock};
use std::time::Duration;
use keel_core_api::policy::{
BreakerMode, BreakerPolicy, CacheScope, DurationMs, JournalLocation, NondeterminismResponse,
Policy, Rate, ResolvedPolicy, RetryPolicy,
};
use keel_core_api::{
AttemptResult, BreakerState, ENVELOPE_VERSION, ErrorClass, ErrorCode, KeelError, Outcome,
OutcomeError, Request,
};
use keel_journal::{
CacheKey as JournalCacheKey, CallObservation, CallResult, Clock, DiscoveryStore, Journal,
ObservedError,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::time::Instant;
use tracing::{Instrument, debug, warn};
use crate::events::{CacheStore, EventKind, EventSink, TraceRef};
use crate::journal_backend::{self, JournalBackend};
#[derive(Debug, Default)]
struct Breaker {
consecutive: u64,
outcomes: VecDeque<(Instant, bool)>,
open_until: Option<Instant>,
opens: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Admission {
Closed,
HalfOpen,
Rejected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BreakerTransition {
None,
Opened,
Closed,
}
impl Breaker {
fn admit(&self, now: Instant) -> Admission {
match self.open_until {
Some(until) if now < until => Admission::Rejected,
Some(_) => Admission::HalfOpen,
None => Admission::Closed,
}
}
fn state_at(&self, now: Instant) -> BreakerState {
match self.admit(now) {
Admission::Rejected => BreakerState::Open,
Admission::Closed | Admission::HalfOpen => BreakerState::Closed,
}
}
fn on_success(&mut self, now: Instant, config: &BreakerPolicy) -> BreakerTransition {
let closed_a_probe = self.open_until.is_some();
self.consecutive = 0;
self.open_until = None;
if closed_a_probe {
self.outcomes.clear();
return BreakerTransition::Closed;
}
if let BreakerMode::Rate { window, .. } = config.mode() {
self.observe(now, window, false);
}
BreakerTransition::None
}
fn on_terminal_failure(
&mut self,
now: Instant,
config: &BreakerPolicy,
admission: Admission,
) -> BreakerTransition {
let should_trip = if admission == Admission::HalfOpen {
true } else {
match config.mode() {
BreakerMode::Count { failures } => {
self.consecutive += 1;
self.consecutive >= failures.get()
}
BreakerMode::Rate {
window,
failure_rate,
min_calls,
} => {
self.observe(now, window, true);
self.window_rate_reached(failure_rate, min_calls)
}
}
};
if should_trip {
self.open_until = Some(now + Duration::from_millis(config.cooldown.0));
self.opens += 1;
self.consecutive = 0;
self.outcomes.clear();
BreakerTransition::Opened
} else {
BreakerTransition::None
}
}
fn observe(&mut self, now: Instant, window: DurationMs, failed: bool) {
let window = Duration::from_millis(window.0);
while let Some(&(at, _)) = self.outcomes.front() {
if now.duration_since(at) >= window {
self.outcomes.pop_front();
} else {
break;
}
}
self.outcomes.push_back((now, failed));
}
fn window_rate_reached(&self, failure_rate: f64, min_calls: core::num::NonZeroU32) -> bool {
let total = self.outcomes.len();
if (total as u64) < u64::from(min_calls.get()) {
return false;
}
let failed = self.outcomes.iter().filter(|&&(_, f)| f).count();
#[expect(
clippy::cast_precision_loss,
reason = "window counts are bounded by the calls observed within one \
breaker window — far below f64's 2^53 exact-integer range"
)]
let rate = failed as f64 / total as f64;
rate >= failure_rate
}
}
#[derive(Debug, Default)]
struct TokenBucket {
scaled_tokens: i128,
last_refill_ms: u64,
primed: bool,
}
impl TokenBucket {
fn plan_admit(&mut self, elapsed_ms: u64, rate: Rate) -> u64 {
let limit = i128::from(rate.limit.get());
let window = i128::from(rate.window_ms);
let capacity = limit * window; if !self.primed {
self.primed = true;
self.scaled_tokens = capacity;
self.last_refill_ms = elapsed_ms;
}
let elapsed = i128::from(elapsed_ms.saturating_sub(self.last_refill_ms));
self.last_refill_ms = self.last_refill_ms.max(elapsed_ms);
self.scaled_tokens = capacity.min(
self.scaled_tokens
.saturating_add(elapsed.saturating_mul(limit)),
);
self.scaled_tokens -= window;
if self.scaled_tokens >= 0 {
0
} else {
let deficit = -self.scaled_tokens;
u64::try_from((deficit + limit - 1) / limit).unwrap_or(u64::MAX)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct CacheKey {
target: String,
args_hash: String,
}
#[derive(Debug, Clone)]
struct CacheEntry {
expires_at: Instant,
payload: Value,
}
const CACHE_PAYLOAD_SCHEMA: &str = "keel.cache/v1";
#[derive(Serialize)]
struct CachePayloadRef<'a> {
schema: &'a str,
payload: &'a Value,
}
#[derive(Deserialize)]
struct CachePayloadOwned {
schema: String,
payload: Value,
}
fn encode_cache_payload(payload: &Value) -> Result<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec_named(&CachePayloadRef {
schema: CACHE_PAYLOAD_SCHEMA,
payload,
})
}
fn decode_cache_payload(bytes: &[u8]) -> Result<Value, String> {
let envelope: CachePayloadOwned =
rmp_serde::from_slice(bytes).map_err(|e| format!("messagepack decode failed: {e}"))?;
if envelope.schema != CACHE_PAYLOAD_SCHEMA {
return Err(format!(
"unrecognized cache payload schema {:?}",
envelope.schema
));
}
Ok(envelope.payload)
}
#[derive(Debug)]
enum CachePlan {
None,
Memory {
key: CacheKey,
},
Persistent {
key: JournalCacheKey,
ttl: DurationMs,
},
}
pub trait DiscoveryRecorder: Send + Sync {
fn record(&self, observation: &CallObservation) -> keel_journal::Result<()>;
}
impl<C: Clock> DiscoveryRecorder for DiscoveryStore<C> {
fn record(&self, observation: &CallObservation) -> keel_journal::Result<()> {
DiscoveryStore::record(self, observation)
}
}
#[derive(Debug, Default)]
struct TargetMetrics {
calls: u64,
attempts: u64,
retries: u64,
successes: u64,
failures: u64,
cache_hits: u64,
throttled: u64,
}
#[derive(Debug, Serialize)]
struct TargetReport {
attempts: u64,
breaker_opens: u64,
breaker_state: BreakerState,
cache_hits: u64,
calls: u64,
failures: u64,
retries: u64,
successes: u64,
throttled: u64,
}
#[derive(Debug, Serialize)]
struct Report<'a> {
v: u32,
clock_ms: u64,
targets: BTreeMap<&'a str, TargetReport>,
}
#[derive(Debug, Default)]
struct State {
trace_seq: u64,
breakers: HashMap<String, Breaker>,
rate_buckets: HashMap<String, TokenBucket>,
cache: HashMap<CacheKey, CacheEntry>,
metrics: BTreeMap<String, TargetMetrics>,
}
impl State {
fn metrics_for(&mut self, target: &str) -> &mut TargetMetrics {
self.metrics.entry(target.to_owned()).or_default()
}
fn breaker_state(&self, target: &str, now: Instant) -> BreakerState {
self.breakers
.get(target)
.map_or(BreakerState::Closed, |b| b.state_at(now))
}
}
#[derive(Debug)]
struct AttemptOutcome {
result: AttemptResult,
timed_out_by_layer: bool,
}
fn terminal_code(
retryable: bool,
attempt: u32,
max_attempts: u32,
idempotent: bool,
) -> Option<ErrorCode> {
if !retryable {
Some(ErrorCode::NonRetryableError)
} else if attempt == max_attempts {
Some(ErrorCode::AttemptsExhausted)
} else if !idempotent {
Some(ErrorCode::NonIdempotentNotRetried)
} else {
None
}
}
fn class_str(class: ErrorClass) -> &'static str {
match class {
ErrorClass::Conn => "conn",
ErrorClass::Timeout => "timeout",
ErrorClass::Http => "http",
ErrorClass::Cancelled => "cancelled",
ErrorClass::Other => "other",
}
}
fn breaker_str(state: BreakerState) -> &'static str {
match state {
BreakerState::Closed => "closed",
BreakerState::Open => "open",
BreakerState::HalfOpen => "half_open",
}
}
fn record_call_fields(span: &tracing::Span, out: &Outcome) {
span.record("trace_id", out.trace_id.as_str());
span.record("result", out.result.as_str());
if let Some(error) = out.error.as_ref() {
span.record("error_code", error.code.as_str());
}
span.record("attempts", out.attempts);
span.record("from_cache", out.from_cache);
span.record("throttled", out.throttled);
span.record("breaker", breaker_str(out.breaker));
}
fn emit_breaker_transition(target: &str, transition: BreakerTransition) {
match transition {
BreakerTransition::Opened => {
debug!(target = %target, transition = "opened", "breaker transition");
crate::metrics::record_breaker_transition(target, "opened");
}
BreakerTransition::Closed => {
debug!(target = %target, transition = "closed", "breaker transition");
crate::metrics::record_breaker_transition(target, "closed");
}
BreakerTransition::None => {}
}
}
fn warn_inert_breaker_knobs(policy: &Policy) {
let defaults = &policy.defaults;
let tables = defaults
.outbound
.iter()
.map(|t| (String::from("defaults.outbound"), t))
.chain(
defaults
.llm
.iter()
.map(|t| (String::from("defaults.llm"), t)),
)
.chain(
policy
.target
.iter()
.map(|(name, t)| (format!("target.\"{name}\""), t)),
);
for (path, table) in tables {
if table
.breaker
.as_ref()
.is_some_and(BreakerPolicy::has_inert_rate_knobs)
{
warn!(
"policy {path}.breaker sets `failures` (count mode) alongside rate-mode knobs \
(window/failure_rate/min_calls), which are inert in count mode. Remove \
`failures` to select rate mode."
);
}
}
}
fn with_trace_ref(message: String, trace: Option<&TraceRef>) -> String {
match trace {
Some(t) => {
let sep = if message.ends_with('.') { "" } else { "." };
format!("{message}{sep} trace: keel trace {t}")
}
None => message,
}
}
fn terminal_message(
code: ErrorCode,
request: &Request,
attempt: u32,
max_attempts: u32,
class: ErrorClass,
http_status: Option<u16>,
message: &str,
) -> String {
let detail = match http_status {
Some(status) => format!("{} {status}", class_str(class)),
None => class_str(class).to_owned(),
};
let text = match code {
ErrorCode::Timeout => format!(
"{} exceeded its policy timeout on attempt {attempt}/{max_attempts}. {message}",
request.op
),
ErrorCode::AttemptsExhausted => format!(
"{} failed {attempt}/{max_attempts} attempts (last: {detail}). {message}",
request.op
),
ErrorCode::NonIdempotentNotRetried => format!(
"{} failed ({detail}). Not retried: call is not idempotent — observed, not retried. {message}",
request.op
),
_ => format!(
"{} failed ({detail}); error class is not retryable per policy. {message}",
request.op
),
};
text.trim_end().to_owned()
}
struct TerminalAttemptCtx<'a> {
request: &'a Request,
attempt: u32,
max_attempts: u32,
trace: Option<&'a TraceRef>,
timed_out_by_layer: bool,
}
fn terminal_attempt_error(
ctx: &TerminalAttemptCtx<'_>,
code: ErrorCode,
class: ErrorClass,
http_status: Option<u16>,
message: &str,
original: Option<Value>,
) -> OutcomeError {
let code = if ctx.timed_out_by_layer && code != ErrorCode::NonIdempotentNotRetried {
ErrorCode::Timeout
} else {
code
};
OutcomeError {
code,
class,
http_status,
message: with_trace_ref(
terminal_message(
code,
ctx.request,
ctx.attempt,
ctx.max_attempts,
class,
http_status,
message,
),
ctx.trace,
),
original,
}
}
pub struct Engine {
started: Instant,
policy: RwLock<Policy>,
state: Mutex<State>,
journal: RwLock<JournalSlot>,
discovery: Option<Arc<dyn DiscoveryRecorder>>,
events: Option<EventSink>,
}
#[derive(Default)]
struct JournalSlot {
journal: Option<Arc<dyn Journal>>,
backend: Option<JournalBackend>,
}
impl fmt::Debug for Engine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Engine")
.field("policy", &self.policy)
.field("state", &self.state)
.field("journal_attached", &self.current_journal().is_some())
.field("discovery_attached", &self.discovery.is_some())
.field("events_attached", &self.events.is_some())
.finish_non_exhaustive()
}
}
impl Default for Engine {
fn default() -> Self {
Self::new()
}
}
impl Engine {
#[must_use]
pub fn new() -> Self {
Self {
started: Instant::now(),
policy: RwLock::new(Policy::default()),
state: Mutex::new(State::default()),
journal: RwLock::new(JournalSlot::default()),
discovery: None,
events: EventSink::from_env(),
}
}
pub fn attach_journal(&mut self, journal: impl Journal + 'static) -> &mut Self {
let slot = self.journal.get_mut().expect("journal lock poisoned");
slot.journal = Some(Arc::new(journal));
slot.backend = None;
self
}
#[must_use]
pub fn journal(&self) -> Option<Arc<dyn Journal>> {
self.current_journal()
}
fn current_journal(&self) -> Option<Arc<dyn Journal>> {
self.journal
.read()
.expect("journal lock poisoned")
.journal
.clone()
}
pub fn attach_discovery(&mut self, discovery: impl DiscoveryRecorder + 'static) -> &mut Self {
self.discovery = Some(Arc::new(discovery));
self
}
pub fn attach_events(&mut self, sink: EventSink) -> &mut Self {
self.events = Some(sink);
self
}
#[must_use]
pub fn events(&self) -> Option<&EventSink> {
self.events.as_ref()
}
fn emit_event(&self, kind: impl FnOnce() -> EventKind) {
if let Some(sink) = self.events.as_ref() {
sink.emit(self.elapsed_ms(), kind());
}
}
fn emit_cache(&self, out: &Outcome, target: &str, scope: CacheStore, hit: bool) {
self.emit_event(|| {
let call = out.trace_id.clone();
let target = target.to_owned();
if hit {
EventKind::CacheHit {
call,
target,
scope,
}
} else {
EventKind::CacheMiss {
call,
target,
scope,
}
}
});
}
pub fn configure(&self, policy_json: &Value) -> Result<(), KeelError> {
let policy: Policy =
serde_path_to_error::deserialize(policy_json).map_err(|e| KeelError {
code: ErrorCode::PolicyInvalid,
message: format!("policy invalid at {}: {}", e.path(), e.inner()),
})?;
if let Some(location) = &policy.journal {
self.apply_journal_location(location)?;
}
if let Some(telemetry) = &policy.telemetry
&& !telemetry.console
{
warn!(
"policy `telemetry.console = false` is validated but not yet wired: v0.1 always \
uses the default local summary. `telemetry.otlp_endpoint` IS honored by front \
ends built with the `otel` feature."
);
}
warn_inert_breaker_knobs(&policy);
*self.policy.write().expect("policy lock poisoned") = policy;
Ok(())
}
#[must_use]
pub fn telemetry_otlp_endpoint(&self) -> Option<String> {
self.policy
.read()
.expect("policy lock poisoned")
.telemetry
.as_ref()
.and_then(|t| t.otlp_endpoint.clone())
}
fn apply_journal_location(&self, location: &JournalLocation) -> Result<(), KeelError> {
let backend = JournalBackend::select(location);
{
let slot = self.journal.read().expect("journal lock poisoned");
if slot.backend.as_ref() == Some(&backend) {
return Ok(()); }
}
let journal = journal_backend::open(&backend)?;
if let JournalBackend::File(path) = &backend {
debug!(path = %path.display(), "journal selected by policy");
}
let mut slot = self.journal.write().expect("journal lock poisoned");
slot.journal = Some(journal);
slot.backend = Some(backend);
Ok(())
}
#[must_use]
pub fn idempotency_header(&self, target: &str) -> Option<String> {
self.policy
.read()
.expect("policy lock poisoned")
.resolve(target)
.idempotency
.map(|i| i.header)
}
#[must_use]
pub fn nondeterminism_response(&self) -> NondeterminismResponse {
self.policy
.read()
.expect("policy lock poisoned")
.flows
.as_ref()
.map_or(NondeterminismResponse::default(), |f| f.on_nondeterminism)
}
fn state(&self) -> MutexGuard<'_, State> {
self.state.lock().expect("state lock poisoned")
}
fn elapsed_ms(&self) -> u64 {
u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
}
pub async fn execute<F>(&self, request: &Request, mut effect: F) -> Outcome
where
F: AsyncFnMut(u32) -> AttemptResult,
{
let started = Instant::now();
let span = tracing::info_span!(
"keel.call",
target = %request.target,
op = %request.op,
trace_id = tracing::field::Empty,
result = tracing::field::Empty,
error_code = tracing::field::Empty,
attempts = tracing::field::Empty,
from_cache = tracing::field::Empty,
throttled = tracing::field::Empty,
breaker = tracing::field::Empty,
);
let out = self
.run_chain(request, &mut effect)
.instrument(span.clone())
.await;
record_call_fields(&span, &out);
self.observe(request, &out, started);
self.emit_event(|| EventKind::CallEnd {
call: out.trace_id.clone(),
target: request.target.clone(),
result: out.result.clone(),
code: out.error.as_ref().map(|e| e.code),
attempts: out.attempts,
});
out
}
async fn run_chain<F>(&self, request: &Request, effect: &mut F) -> Outcome
where
F: AsyncFnMut(u32) -> AttemptResult,
{
let target = request.target.as_str();
let mut out = self.begin_call(target);
let trace = self.events.as_ref().map(|sink| {
let seq = sink.emit(
self.elapsed_ms(),
EventKind::CallStart {
call: out.trace_id.clone(),
target: target.to_owned(),
op: request.op.clone(),
},
);
TraceRef {
run: sink.run_id().to_owned(),
seq,
}
});
if request.v != ENVELOPE_VERSION {
out.error = Some(OutcomeError {
code: ErrorCode::EnvelopeVersion,
class: ErrorClass::Other,
http_status: None,
message: format!("unsupported envelope version {}", request.v),
original: None,
});
self.state().metrics_for(target).failures += 1;
return out;
}
let resolved = self
.policy
.read()
.expect("policy lock poisoned")
.resolve(target);
let cache_plan = self.plan_cache(target, &resolved, request);
match &cache_plan {
CachePlan::Memory { key } => {
if self.serve_from_cache(key, &mut out) {
crate::metrics::record_cache_request(target, true);
self.emit_cache(&out, target, CacheStore::Memory, true);
return out;
}
crate::metrics::record_cache_request(target, false);
self.emit_cache(&out, target, CacheStore::Memory, false);
}
CachePlan::Persistent { key, .. } => {
if self.serve_from_persistent(target, key, &mut out) {
crate::metrics::record_cache_request(target, true);
self.emit_cache(&out, target, CacheStore::Persistent, true);
return out;
}
crate::metrics::record_cache_request(target, false);
self.emit_cache(&out, target, CacheStore::Persistent, false);
}
CachePlan::None => {}
}
if let Some(rate) = resolved.rate {
self.throttle(target, rate, &mut out).await;
}
let admission = self.admit(target, &resolved, &mut out, trace.as_ref());
if admission == Admission::Rejected {
return out;
}
let retry = resolved.retry.clone().unwrap_or_else(|| RetryPolicy {
attempts: core::num::NonZeroU32::MIN,
..RetryPolicy::default()
});
let result = self
.run_attempts(request, &resolved, &retry, effect, &mut out, trace.as_ref())
.await;
let memory_key = match &cache_plan {
CachePlan::Memory { key } => Some(key.clone()),
_ => None,
};
self.settle(target, &resolved, admission, memory_key, result, &mut out);
if let CachePlan::Persistent { key, ttl } = &cache_plan
&& out.result == "ok"
&& let Some(payload) = &out.payload
{
self.write_persistent(target, key, payload, *ttl);
}
out
}
fn plan_cache(&self, target: &str, resolved: &ResolvedPolicy, request: &Request) -> CachePlan {
let (Some(cache), Some(hash)) = (resolved.cache.as_ref(), request.args_hash.as_ref())
else {
return CachePlan::None;
};
let Some(ttl) = cache.ttl else {
return CachePlan::None;
};
match cache.scope {
CacheScope::Persistent if self.current_journal().is_some() => CachePlan::Persistent {
key: JournalCacheKey::new(format!("{target}#{hash}")),
ttl,
},
_ => CachePlan::Memory {
key: CacheKey {
target: target.to_owned(),
args_hash: hash.clone(),
},
},
}
}
fn begin_call(&self, target: &str) -> Outcome {
let mut state = self.state();
state.metrics_for(target).calls += 1;
state.trace_seq += 1;
Outcome {
v: ENVELOPE_VERSION,
result: String::from("error"),
payload: None,
error: None,
attempts: 0,
from_cache: false,
waits_ms: Vec::new(),
throttled: false,
throttle_wait_ms: 0,
breaker: BreakerState::Closed,
trace_id: format!("t-{:06}", state.trace_seq),
}
}
fn serve_from_cache(&self, key: &CacheKey, out: &mut Outcome) -> bool {
let now = Instant::now();
let mut state = self.state();
let payload = match state.cache.get(key) {
Some(entry) if now < entry.expires_at => entry.payload.clone(),
Some(_) => {
state.cache.remove(key);
return false;
}
None => return false,
};
out.result = String::from("ok");
out.payload = Some(payload);
out.from_cache = true;
let metrics = state.metrics_for(&key.target);
metrics.cache_hits += 1;
metrics.successes += 1;
out.breaker = state.breaker_state(&key.target, now);
debug!(target = %key.target, scope = "memory", "cache hit");
true
}
fn serve_from_persistent(
&self,
target: &str,
key: &JournalCacheKey,
out: &mut Outcome,
) -> bool {
let Some(journal) = self.current_journal() else {
return false;
};
let bytes = match journal.get_cache(key) {
Ok(Some(bytes)) => bytes,
Ok(None) => return false,
Err(error) => {
warn!(target = %target, error = %error, "persistent cache read failed; serving live");
return false;
}
};
let payload = match decode_cache_payload(&bytes) {
Ok(payload) => payload,
Err(reason) => {
warn!(target = %target, reason = %reason, "persistent cache entry undecodable; serving live");
return false;
}
};
let now = Instant::now();
let mut state = self.state();
out.result = String::from("ok");
out.payload = Some(payload);
out.from_cache = true;
let metrics = state.metrics_for(target);
metrics.cache_hits += 1;
metrics.successes += 1;
out.breaker = state.breaker_state(target, now);
debug!(target = %target, scope = "persistent", "cache hit");
true
}
async fn throttle(&self, target: &str, rate: Rate, out: &mut Outcome) {
let wait_ms = {
let elapsed = self.elapsed_ms();
let mut state = self.state();
let bucket = state.rate_buckets.entry(target.to_owned()).or_default();
bucket.plan_admit(elapsed, rate)
};
if wait_ms > 0 {
out.throttled = true;
out.throttle_wait_ms = wait_ms;
self.state().metrics_for(target).throttled += 1;
crate::metrics::record_throttled(target, wait_ms);
self.emit_event(|| EventKind::Throttle {
call: out.trace_id.clone(),
target: target.to_owned(),
wait_ms,
});
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
}
}
fn admit(
&self,
target: &str,
resolved: &ResolvedPolicy,
out: &mut Outcome,
trace: Option<&TraceRef>,
) -> Admission {
if resolved.breaker.is_none() {
return Admission::Closed;
}
let now = Instant::now();
let admission = {
let mut state = self.state();
let admission = state
.breakers
.entry(target.to_owned())
.or_default()
.admit(now);
if admission == Admission::Rejected {
out.error = Some(OutcomeError {
code: ErrorCode::BreakerOpen,
class: ErrorClass::Other,
http_status: None,
message: with_trace_ref(
format!("breaker OPEN for {target}: failed fast, call not attempted"),
trace,
),
original: None,
});
out.breaker = BreakerState::Open;
state.metrics_for(target).failures += 1;
}
admission
};
if admission == Admission::Rejected {
self.emit_event(|| EventKind::BreakerReject {
call: out.trace_id.clone(),
target: target.to_owned(),
});
}
if admission == Admission::HalfOpen {
debug!(target = %target, transition = "half_open", "breaker transition");
crate::metrics::record_breaker_transition(target, "half_open");
self.emit_event(|| EventKind::BreakerHalfOpen {
call: out.trace_id.clone(),
target: target.to_owned(),
});
}
admission
}
fn settle(
&self,
target: &str,
resolved: &ResolvedPolicy,
admission: Admission,
cache_key: Option<CacheKey>,
result: Result<Value, OutcomeError>,
out: &mut Outcome,
) {
let now = Instant::now();
let transition = {
let mut state = self.state();
let transition = match result {
Ok(payload) => {
state.metrics_for(target).successes += 1;
let mut transition = BreakerTransition::None;
if let Some(config) = &resolved.breaker
&& let Some(breaker) = state.breakers.get_mut(target)
{
transition = breaker.on_success(now, config);
}
if let (Some(key), Some(cache)) = (cache_key, &resolved.cache)
&& let Some(ttl) = cache.ttl
{
state.cache.retain(|_, entry| entry.expires_at > now);
state.cache.insert(
key,
CacheEntry {
expires_at: now + Duration::from_millis(ttl.0),
payload: payload.clone(),
},
);
}
out.result = String::from("ok");
out.payload = Some(payload);
transition
}
Err(error) => {
state.metrics_for(target).failures += 1;
let mut transition = BreakerTransition::None;
if let Some(config) = &resolved.breaker
&& let Some(breaker) = state.breakers.get_mut(target)
{
transition = breaker.on_terminal_failure(now, config, admission);
}
out.error = Some(error);
transition
}
};
out.breaker = state.breaker_state(target, now);
transition
};
emit_breaker_transition(target, transition);
match transition {
BreakerTransition::Opened => self.emit_event(|| EventKind::BreakerOpen {
call: out.trace_id.clone(),
target: target.to_owned(),
cooldown_ms: resolved.breaker.as_ref().map_or(0, |b| b.cooldown.0),
}),
BreakerTransition::Closed => self.emit_event(|| EventKind::BreakerClose {
call: out.trace_id.clone(),
target: target.to_owned(),
}),
BreakerTransition::None => {}
}
}
fn write_persistent(
&self,
target: &str,
key: &JournalCacheKey,
payload: &Value,
ttl: DurationMs,
) {
let Some(journal) = self.current_journal() else {
return;
};
let bytes = match encode_cache_payload(payload) {
Ok(bytes) => bytes,
Err(error) => {
warn!(target = %target, error = %error, "persistent cache encode failed; entry not stored");
return;
}
};
if let Err(error) = journal.put_cache(key, &bytes, Duration::from_millis(ttl.0)) {
warn!(target = %target, error = %error, "persistent cache write failed; entry not stored");
}
}
fn observe(&self, request: &Request, out: &Outcome, started: Instant) {
let Some(discovery) = self.discovery.as_ref() else {
return;
};
let latency_ms = i64::try_from(started.elapsed().as_millis()).unwrap_or(i64::MAX);
let result = if out.from_cache {
CallResult::CacheHit
} else if out.result == "ok" {
CallResult::Success
} else {
CallResult::Failure
};
let error = out.error.as_ref().map(|e| ObservedError {
class: e.class,
http_status: e.http_status,
});
let breaker_opened = out
.error
.as_ref()
.is_some_and(|e| e.code == ErrorCode::BreakerOpen);
let not_retried = out
.error
.as_ref()
.is_some_and(|e| e.code == ErrorCode::NonIdempotentNotRetried);
let wrapped = self
.policy
.read()
.expect("policy lock poisoned")
.target
.contains_key(&request.target);
let observation = CallObservation {
target: request.target.clone(),
result,
attempts: out.attempts,
latency_ms,
throttled: out.throttled,
breaker_opened,
not_retried,
wrapped,
error,
};
if let Err(error) = discovery.record(&observation) {
warn!(target = %request.target, error = %error, "discovery record failed; observation dropped");
}
}
async fn run_one_attempt<F>(
&self,
timeout: Option<DurationMs>,
effect: &mut F,
attempt: u32,
attempt_span: &tracing::Span,
) -> AttemptOutcome
where
F: AsyncFnMut(u32) -> AttemptResult,
{
match timeout {
Some(limit) => {
match tokio::time::timeout(
Duration::from_millis(limit.0),
effect(attempt).instrument(attempt_span.clone()),
)
.await
{
Ok(result) => AttemptOutcome {
result,
timed_out_by_layer: false,
},
Err(_elapsed) => AttemptOutcome {
result: AttemptResult::Error {
class: ErrorClass::Timeout,
http_status: None,
retry_after_ms: None,
message: format!("no response within {}ms", limit.0),
original: None,
},
timed_out_by_layer: true,
},
}
}
None => AttemptOutcome {
result: effect(attempt).instrument(attempt_span.clone()).await,
timed_out_by_layer: false,
},
}
}
async fn run_attempts<F>(
&self,
request: &Request,
resolved: &ResolvedPolicy,
retry: &RetryPolicy,
effect: &mut F,
out: &mut Outcome,
trace: Option<&TraceRef>,
) -> Result<Value, OutcomeError>
where
F: AsyncFnMut(u32) -> AttemptResult,
{
let target = request.target.as_str();
let max_attempts = retry.attempts.get();
let attempt_timeout = resolved.timeout.filter(|_| request.idempotent);
for attempt in 1..=max_attempts {
out.attempts = attempt;
self.state().metrics_for(target).attempts += 1;
crate::metrics::record_attempt(target);
self.emit_event(|| EventKind::AttemptStart {
call: out.trace_id.clone(),
target: target.to_owned(),
attempt,
});
let attempt_span = tracing::debug_span!(
"keel.attempt",
attempt,
result = tracing::field::Empty,
class = tracing::field::Empty,
http_status = tracing::field::Empty,
wait_ms = tracing::field::Empty,
);
let attempt_outcome = self
.run_one_attempt(attempt_timeout, effect, attempt, &attempt_span)
.await;
match attempt_outcome.result {
AttemptResult::Ok { payload } => {
attempt_span.record("result", "ok");
return Ok(payload);
}
AttemptResult::Error {
class,
http_status,
retry_after_ms,
message,
original,
} => {
attempt_span.record("result", "error");
attempt_span.record("class", class_str(class));
if let Some(status) = http_status {
attempt_span.record("http_status", status);
}
self.emit_event(|| EventKind::AttemptError {
call: out.trace_id.clone(),
target: target.to_owned(),
attempt,
class,
http_status,
});
let retryable = retry.is_retryable(class, http_status);
if let Some(code) =
terminal_code(retryable, attempt, max_attempts, request.idempotent)
{
let ctx = TerminalAttemptCtx {
request,
attempt,
max_attempts,
trace,
timed_out_by_layer: attempt_outcome.timed_out_by_layer,
};
return Err(terminal_attempt_error(
&ctx,
code,
class,
http_status,
&message,
original,
));
}
let (mut wait, jitter) = retry.schedule.wait_and_jitter(attempt);
if jitter && wait > 0 {
wait = fastrand::u64(wait / 2..=wait);
}
if let Some(server_says) = retry_after_ms {
wait = wait.max(server_says);
}
attempt_span.record("wait_ms", wait);
out.waits_ms.push(wait);
self.state().metrics_for(target).retries += 1;
crate::metrics::record_retry(target, wait);
self.emit_event(|| EventKind::Backoff {
call: out.trace_id.clone(),
target: target.to_owned(),
attempt,
wait_ms: wait,
});
tokio::time::sleep(Duration::from_millis(wait)).await;
}
}
}
unreachable!("loop always returns by the final attempt");
}
pub fn report(&self) -> Value {
let now = Instant::now();
let state = self.state();
let targets = state
.metrics
.iter()
.map(|(name, m)| {
let breaker = state.breakers.get(name);
let row = TargetReport {
attempts: m.attempts,
breaker_opens: breaker.map_or(0, |b| b.opens),
breaker_state: state.breaker_state(name, now),
cache_hits: m.cache_hits,
calls: m.calls,
failures: m.failures,
retries: m.retries,
successes: m.successes,
throttled: m.throttled,
};
(name.as_str(), row)
})
.collect();
serde_json::to_value(Report {
v: 1,
clock_ms: self.elapsed_ms(),
targets,
})
.expect("report serialization is infallible")
}
}
#[cfg(test)]
mod tests {
use super::{
Admission, AttemptResult, Breaker, BreakerTransition, ENVELOPE_VERSION, Engine, Instant,
Request, TokenBucket,
};
use core::num::NonZeroU32;
use core::time::Duration;
use keel_core_api::policy::{BreakerPolicy, DurationMs, Rate};
use serde_json::json;
#[test]
fn breaker_rate_mode_evicts_outcomes_older_than_the_window() {
let config = BreakerPolicy {
failures: None,
cooldown: DurationMs(15_000),
window: Some(DurationMs(10_000)),
failure_rate: Some(0.5),
min_calls: Some(NonZeroU32::new(2).unwrap()),
};
let mut breaker = Breaker::default();
let t0 = Instant::now();
assert_eq!(
breaker.on_terminal_failure(t0, &config, Admission::Closed),
BreakerTransition::None,
"one failure is below min_calls"
);
let t_11s = t0 + Duration::from_secs(11);
assert_eq!(
breaker.on_terminal_failure(t_11s, &config, Admission::Closed),
BreakerTransition::None,
"the stale failure must have aged out of the 10s window"
);
}
#[test]
fn token_bucket_caps_refill_at_burst_capacity() {
let rate = Rate {
limit: core::num::NonZeroU64::new(2).unwrap(),
window_ms: 1000,
};
let mut bucket = TokenBucket::default();
assert_eq!(bucket.plan_admit(0, rate), 0, "burst covers the first call");
assert_eq!(
bucket.plan_admit(0, rate),
0,
"burst covers the second call"
);
assert_eq!(
bucket.plan_admit(0, rate),
500,
"burst drained: paced at window/limit = 500ms"
);
assert_eq!(
bucket.plan_admit(50_000, rate),
0,
"capacity refilled to burst"
);
assert_eq!(
bucket.plan_admit(50_000, rate),
0,
"both burst tokens available"
);
assert_eq!(
bucket.plan_admit(50_000, rate),
500,
"capacity clamp: idle time cannot bank more than `limit` tokens"
);
}
fn req(target: &str, args_hash: &str) -> Request {
Request {
v: ENVELOPE_VERSION,
target: target.to_owned(),
op: format!("GET {target}"),
idempotent: true,
args_hash: Some(args_hash.to_owned()),
}
}
#[tokio::test(start_paused = true)]
async fn in_memory_cache_evicts_expired_entries() {
let engine = Engine::new();
engine
.configure(&json!({
"target": { "api.catalog.internal": { "cache": { "ttl": "60s" } } }
}))
.expect("valid policy");
engine
.execute(&req("api.catalog.internal", "k1"), async |_a| {
AttemptResult::Ok { payload: json!(1) }
})
.await;
assert_eq!(engine.state().cache.len(), 1, "k1 cached");
tokio::time::advance(Duration::from_secs(61)).await;
engine
.execute(&req("api.catalog.internal", "k2"), async |_a| {
AttemptResult::Ok { payload: json!(2) }
})
.await;
assert_eq!(
engine.state().cache.len(),
1,
"expired k1 evicted on write; only the live k2 remains"
);
let out = engine
.execute(&req("api.catalog.internal", "k1"), async |_a| {
AttemptResult::Ok { payload: json!(3) }
})
.await;
assert!(!out.from_cache, "expired/evicted key re-runs live");
}
}