1use core::fmt;
7use std::collections::{BTreeMap, HashMap, VecDeque};
8use std::sync::{Arc, Mutex, MutexGuard, RwLock};
9use std::time::Duration;
10
11use keel_core_api::policy::{
12 BreakerMode, BreakerPolicy, CacheScope, DurationMs, JournalLocation, NondeterminismResponse,
13 Policy, Rate, ResolvedPolicy, RetryPolicy,
14};
15use keel_core_api::{
16 AttemptResult, BreakerState, ENVELOPE_VERSION, ErrorClass, ErrorCode, KeelError, Outcome,
17 OutcomeError, Request,
18};
19use keel_journal::{
20 CacheKey as JournalCacheKey, CallObservation, CallResult, Clock, DiscoveryStore, Journal,
21 ObservedError,
22};
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use tokio::time::Instant;
26use tracing::{Instrument, debug, warn};
27
28use crate::events::{CacheStore, EventKind, EventSink, TraceRef};
29use crate::journal_backend::{self, JournalBackend};
30
31#[derive(Debug, Default)]
36struct Breaker {
37 consecutive: u64,
39 outcomes: VecDeque<(Instant, bool)>,
43 open_until: Option<Instant>,
44 opens: u64,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum Admission {
50 Closed,
51 HalfOpen,
53 Rejected,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum BreakerTransition {
62 None,
64 Opened,
66 Closed,
68}
69
70impl Breaker {
71 fn admit(&self, now: Instant) -> Admission {
72 match self.open_until {
73 Some(until) if now < until => Admission::Rejected,
74 Some(_) => Admission::HalfOpen,
75 None => Admission::Closed,
76 }
77 }
78
79 fn state_at(&self, now: Instant) -> BreakerState {
80 match self.admit(now) {
81 Admission::Rejected => BreakerState::Open,
82 Admission::Closed | Admission::HalfOpen => BreakerState::Closed,
83 }
84 }
85
86 fn on_success(&mut self, now: Instant, config: &BreakerPolicy) -> BreakerTransition {
87 let closed_a_probe = self.open_until.is_some();
91 self.consecutive = 0;
92 self.open_until = None;
93 if closed_a_probe {
94 self.outcomes.clear();
97 return BreakerTransition::Closed;
98 }
99 if let BreakerMode::Rate { window, .. } = config.mode() {
100 self.observe(now, window, false);
101 }
102 BreakerTransition::None
103 }
104
105 fn on_terminal_failure(
106 &mut self,
107 now: Instant,
108 config: &BreakerPolicy,
109 admission: Admission,
110 ) -> BreakerTransition {
111 let should_trip = if admission == Admission::HalfOpen {
112 true } else {
114 match config.mode() {
115 BreakerMode::Count { failures } => {
116 self.consecutive += 1;
117 self.consecutive >= failures.get()
118 }
119 BreakerMode::Rate {
120 window,
121 failure_rate,
122 min_calls,
123 } => {
124 self.observe(now, window, true);
125 self.window_rate_reached(failure_rate, min_calls)
126 }
127 }
128 };
129 if should_trip {
130 self.open_until = Some(now + Duration::from_millis(config.cooldown.0));
131 self.opens += 1;
132 self.consecutive = 0;
133 self.outcomes.clear();
134 BreakerTransition::Opened
135 } else {
136 BreakerTransition::None
137 }
138 }
139
140 fn observe(&mut self, now: Instant, window: DurationMs, failed: bool) {
144 let window = Duration::from_millis(window.0);
145 while let Some(&(at, _)) = self.outcomes.front() {
146 if now.duration_since(at) >= window {
147 self.outcomes.pop_front();
148 } else {
149 break;
150 }
151 }
152 self.outcomes.push_back((now, failed));
153 }
154
155 fn window_rate_reached(&self, failure_rate: f64, min_calls: core::num::NonZeroU32) -> bool {
157 let total = self.outcomes.len();
158 if (total as u64) < u64::from(min_calls.get()) {
159 return false;
160 }
161 let failed = self.outcomes.iter().filter(|&&(_, f)| f).count();
162 #[expect(
163 clippy::cast_precision_loss,
164 reason = "window counts are bounded by the calls observed within one \
165 breaker window — far below f64's 2^53 exact-integer range"
166 )]
167 let rate = failed as f64 / total as f64;
168 rate >= failure_rate
169 }
170}
171
172#[derive(Debug, Default)]
185struct TokenBucket {
186 scaled_tokens: i128,
190 last_refill_ms: u64,
192 primed: bool,
195}
196
197impl TokenBucket {
198 fn plan_admit(&mut self, elapsed_ms: u64, rate: Rate) -> u64 {
202 let limit = i128::from(rate.limit.get());
203 let window = i128::from(rate.window_ms);
204 let capacity = limit * window; if !self.primed {
206 self.primed = true;
207 self.scaled_tokens = capacity;
208 self.last_refill_ms = elapsed_ms;
209 }
210 let elapsed = i128::from(elapsed_ms.saturating_sub(self.last_refill_ms));
213 self.last_refill_ms = self.last_refill_ms.max(elapsed_ms);
214 self.scaled_tokens = capacity.min(
215 self.scaled_tokens
216 .saturating_add(elapsed.saturating_mul(limit)),
217 );
218 self.scaled_tokens -= window;
219 if self.scaled_tokens >= 0 {
220 0
221 } else {
222 let deficit = -self.scaled_tokens;
224 u64::try_from((deficit + limit - 1) / limit).unwrap_or(u64::MAX)
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Hash)]
230struct CacheKey {
231 target: String,
232 args_hash: String,
233}
234
235#[derive(Debug, Clone)]
236struct CacheEntry {
237 expires_at: Instant,
238 payload: Value,
239}
240
241const CACHE_PAYLOAD_SCHEMA: &str = "keel.cache/v1";
246
247#[derive(Serialize)]
250struct CachePayloadRef<'a> {
251 schema: &'a str,
252 payload: &'a Value,
253}
254
255#[derive(Deserialize)]
257struct CachePayloadOwned {
258 schema: String,
259 payload: Value,
260}
261
262fn encode_cache_payload(payload: &Value) -> Result<Vec<u8>, rmp_serde::encode::Error> {
264 rmp_serde::to_vec_named(&CachePayloadRef {
265 schema: CACHE_PAYLOAD_SCHEMA,
266 payload,
267 })
268}
269
270fn decode_cache_payload(bytes: &[u8]) -> Result<Value, String> {
274 let envelope: CachePayloadOwned =
275 rmp_serde::from_slice(bytes).map_err(|e| format!("messagepack decode failed: {e}"))?;
276 if envelope.schema != CACHE_PAYLOAD_SCHEMA {
277 return Err(format!(
278 "unrecognized cache payload schema {:?}",
279 envelope.schema
280 ));
281 }
282 Ok(envelope.payload)
283}
284
285#[derive(Debug)]
290enum CachePlan {
291 None,
292 Memory {
293 key: CacheKey,
294 },
295 Persistent {
296 key: JournalCacheKey,
297 ttl: DurationMs,
298 },
299}
300
301pub trait DiscoveryRecorder: Send + Sync {
305 fn record(&self, observation: &CallObservation) -> keel_journal::Result<()>;
307}
308
309impl<C: Clock> DiscoveryRecorder for DiscoveryStore<C> {
310 fn record(&self, observation: &CallObservation) -> keel_journal::Result<()> {
311 DiscoveryStore::record(self, observation)
312 }
313}
314
315#[derive(Debug, Default)]
316struct TargetMetrics {
317 calls: u64,
318 attempts: u64,
319 retries: u64,
320 successes: u64,
321 failures: u64,
322 cache_hits: u64,
323 throttled: u64,
324}
325
326#[derive(Debug, Serialize)]
329struct TargetReport {
330 attempts: u64,
331 breaker_opens: u64,
332 breaker_state: BreakerState,
333 cache_hits: u64,
334 calls: u64,
335 failures: u64,
336 retries: u64,
337 successes: u64,
338 throttled: u64,
339}
340
341#[derive(Debug, Serialize)]
342struct Report<'a> {
343 v: u32,
344 clock_ms: u64,
345 targets: BTreeMap<&'a str, TargetReport>,
346}
347
348#[derive(Debug, Default)]
349struct State {
350 trace_seq: u64,
351 breakers: HashMap<String, Breaker>,
352 rate_buckets: HashMap<String, TokenBucket>,
353 cache: HashMap<CacheKey, CacheEntry>,
354 metrics: BTreeMap<String, TargetMetrics>,
355}
356
357impl State {
358 fn metrics_for(&mut self, target: &str) -> &mut TargetMetrics {
359 self.metrics.entry(target.to_owned()).or_default()
360 }
361
362 fn breaker_state(&self, target: &str, now: Instant) -> BreakerState {
363 self.breakers
364 .get(target)
365 .map_or(BreakerState::Closed, |b| b.state_at(now))
366 }
367}
368
369#[derive(Debug)]
373struct AttemptOutcome {
374 result: AttemptResult,
375 timed_out_by_layer: bool,
376}
377
378fn terminal_code(
381 retryable: bool,
382 attempt: u32,
383 max_attempts: u32,
384 idempotent: bool,
385) -> Option<ErrorCode> {
386 if !retryable {
387 Some(ErrorCode::NonRetryableError)
388 } else if attempt == max_attempts {
389 Some(ErrorCode::AttemptsExhausted)
390 } else if !idempotent {
391 Some(ErrorCode::NonIdempotentNotRetried)
392 } else {
393 None
394 }
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400enum PollVerdict {
401 Terminal,
402 Pending,
403 FailOpen,
404}
405
406fn poll_verdict(poll: &keel_core_api::policy::PollPolicy, payload: &Value) -> PollVerdict {
410 use base64::Engine as _;
411 let Some(obj) = payload.as_object() else {
412 return PollVerdict::FailOpen;
413 };
414 let doc_owned;
415 let doc = if obj.contains_key("body_b64")
416 || (obj.contains_key("status") && obj.contains_key("headers"))
417 {
418 let Some(b64) = obj.get("body_b64").and_then(Value::as_str) else {
419 return PollVerdict::FailOpen;
420 };
421 let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) else {
422 return PollVerdict::FailOpen;
423 };
424 let Ok(parsed) = serde_json::from_slice::<Value>(&bytes) else {
425 return PollVerdict::FailOpen;
426 };
427 if !parsed.is_object() {
428 return PollVerdict::FailOpen;
429 }
430 doc_owned = parsed;
431 doc_owned.as_object().expect("checked object")
432 } else {
433 obj
434 };
435 match doc.get(&poll.until.field) {
436 None => PollVerdict::FailOpen,
437 Some(Value::String(s)) if poll.until.terminal.iter().any(|t| t == s) => {
438 PollVerdict::Terminal
439 }
440 Some(_) => PollVerdict::Pending,
441 }
442}
443
444fn poll_applies(request: &Request) -> bool {
447 request.idempotent && (request.op.starts_with("GET ") || request.op.starts_with("HEAD "))
448}
449
450fn class_str(class: ErrorClass) -> &'static str {
451 match class {
452 ErrorClass::Conn => "conn",
453 ErrorClass::Timeout => "timeout",
454 ErrorClass::Http => "http",
455 ErrorClass::Cancelled => "cancelled",
456 ErrorClass::Other => "other",
457 }
458}
459
460fn breaker_str(state: BreakerState) -> &'static str {
463 match state {
464 BreakerState::Closed => "closed",
465 BreakerState::Open => "open",
466 BreakerState::HalfOpen => "half_open",
467 }
468}
469
470fn record_call_fields(span: &tracing::Span, out: &Outcome) {
475 span.record("trace_id", out.trace_id.as_str());
476 span.record("result", out.result.as_str());
477 if let Some(error) = out.error.as_ref() {
478 span.record("error_code", error.code.as_str());
479 }
480 span.record("attempts", out.attempts);
481 span.record("from_cache", out.from_cache);
482 span.record("throttled", out.throttled);
483 span.record("breaker", breaker_str(out.breaker));
484}
485
486fn emit_breaker_transition(target: &str, transition: BreakerTransition) {
490 match transition {
491 BreakerTransition::Opened => {
492 debug!(target = %target, transition = "opened", "breaker transition");
493 crate::metrics::record_breaker_transition(target, "opened");
494 }
495 BreakerTransition::Closed => {
496 debug!(target = %target, transition = "closed", "breaker transition");
497 crate::metrics::record_breaker_transition(target, "closed");
498 }
499 BreakerTransition::None => {}
500 }
501}
502
503fn warn_inert_breaker_knobs(policy: &Policy) {
508 let defaults = &policy.defaults;
509 let tables = defaults
510 .outbound
511 .iter()
512 .map(|t| (String::from("defaults.outbound"), t))
513 .chain(
514 defaults
515 .llm
516 .iter()
517 .map(|t| (String::from("defaults.llm"), t)),
518 )
519 .chain(
520 policy
521 .target
522 .iter()
523 .map(|(name, t)| (format!("target.\"{name}\""), t)),
524 );
525 for (path, table) in tables {
526 if table
527 .breaker
528 .as_ref()
529 .is_some_and(BreakerPolicy::has_inert_rate_knobs)
530 {
531 warn!(
532 "policy {path}.breaker sets `failures` (count mode) alongside rate-mode knobs \
533 (window/failure_rate/min_calls), which are inert in count mode. Remove \
534 `failures` to select rate mode."
535 );
536 }
537 }
538}
539
540fn with_trace_ref(message: String, trace: Option<&TraceRef>) -> String {
545 match trace {
546 Some(t) => {
547 let sep = if message.ends_with('.') { "" } else { "." };
548 format!("{message}{sep} trace: keel trace {t}")
549 }
550 None => message,
551 }
552}
553
554fn terminal_message(
555 code: ErrorCode,
556 request: &Request,
557 attempt: u32,
558 max_attempts: u32,
559 class: ErrorClass,
560 http_status: Option<u16>,
561 message: &str,
562) -> String {
563 let detail = match http_status {
564 Some(status) => format!("{} {status}", class_str(class)),
565 None => class_str(class).to_owned(),
566 };
567 let text = match code {
568 ErrorCode::Timeout => format!(
569 "{} exceeded its policy timeout on attempt {attempt}/{max_attempts}. {message}",
570 request.op
571 ),
572 ErrorCode::AttemptsExhausted => format!(
573 "{} failed {attempt}/{max_attempts} attempts (last: {detail}). {message}",
574 request.op
575 ),
576 ErrorCode::NonIdempotentNotRetried => format!(
577 "{} failed ({detail}). Not retried: call is not idempotent — observed, not retried. {message}",
578 request.op
579 ),
580 _ => format!(
581 "{} failed ({detail}); error class is not retryable per policy. {message}",
582 request.op
583 ),
584 };
585 text.trim_end().to_owned()
586}
587
588struct TerminalAttemptCtx<'a> {
592 request: &'a Request,
593 attempt: u32,
594 max_attempts: u32,
595 trace: Option<&'a TraceRef>,
596 timed_out_by_layer: bool,
601}
602
603fn terminal_attempt_error(
608 ctx: &TerminalAttemptCtx<'_>,
609 code: ErrorCode,
610 class: ErrorClass,
611 http_status: Option<u16>,
612 message: &str,
613 original: Option<Value>,
614) -> OutcomeError {
615 let code = if ctx.timed_out_by_layer && code != ErrorCode::NonIdempotentNotRetried {
616 ErrorCode::Timeout
617 } else {
618 code
619 };
620 OutcomeError {
621 code,
622 class,
623 http_status,
624 message: with_trace_ref(
625 terminal_message(
626 code,
627 ctx.request,
628 ctx.attempt,
629 ctx.max_attempts,
630 class,
631 http_status,
632 message,
633 ),
634 ctx.trace,
635 ),
636 original,
637 }
638}
639
640pub struct Engine {
646 started: Instant,
647 policy: RwLock<Policy>,
648 raw_policy: RwLock<Value>,
655 state: Mutex<State>,
656 journal: RwLock<JournalSlot>,
661 discovery: Option<Arc<dyn DiscoveryRecorder>>,
663 events: Option<EventSink>,
667}
668
669#[derive(Default)]
674struct JournalSlot {
675 journal: Option<Arc<dyn Journal>>,
676 backend: Option<JournalBackend>,
679}
680
681impl fmt::Debug for Engine {
682 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
683 f.debug_struct("Engine")
685 .field("policy", &self.policy)
686 .field("raw_policy", &self.raw_policy)
687 .field("state", &self.state)
688 .field("journal_attached", &self.current_journal().is_some())
689 .field("discovery_attached", &self.discovery.is_some())
690 .field("events_attached", &self.events.is_some())
691 .finish_non_exhaustive()
692 }
693}
694
695impl Default for Engine {
696 fn default() -> Self {
697 Self::new()
698 }
699}
700
701impl Engine {
702 #[must_use]
703 pub fn new() -> Self {
704 Self {
705 started: Instant::now(),
706 policy: RwLock::new(Policy::default()),
707 raw_policy: RwLock::new(Value::Null),
708 state: Mutex::new(State::default()),
709 journal: RwLock::new(JournalSlot::default()),
710 discovery: None,
711 events: EventSink::from_env(),
715 }
716 }
717
718 pub fn attach_journal(&mut self, journal: impl Journal + 'static) -> &mut Self {
724 let slot = self.journal.get_mut().expect("journal lock poisoned");
725 slot.journal = Some(Arc::new(journal));
726 slot.backend = None;
727 self
728 }
729
730 #[must_use]
738 pub fn journal(&self) -> Option<Arc<dyn Journal>> {
739 self.current_journal()
740 }
741
742 fn current_journal(&self) -> Option<Arc<dyn Journal>> {
745 self.journal
746 .read()
747 .expect("journal lock poisoned")
748 .journal
749 .clone()
750 }
751
752 pub fn attach_discovery(&mut self, discovery: impl DiscoveryRecorder + 'static) -> &mut Self {
755 self.discovery = Some(Arc::new(discovery));
756 self
757 }
758
759 pub fn attach_events(&mut self, sink: EventSink) -> &mut Self {
763 self.events = Some(sink);
764 self
765 }
766
767 #[must_use]
770 pub fn events(&self) -> Option<&EventSink> {
771 self.events.as_ref()
772 }
773
774 fn emit_event(&self, kind: impl FnOnce() -> EventKind) {
778 if let Some(sink) = self.events.as_ref() {
779 sink.emit(self.elapsed_ms(), kind());
780 }
781 }
782
783 fn emit_cache(&self, out: &Outcome, target: &str, scope: CacheStore, hit: bool) {
786 self.emit_event(|| {
787 let call = out.trace_id.clone();
788 let target = target.to_owned();
789 if hit {
790 EventKind::CacheHit {
791 call,
792 target,
793 scope,
794 }
795 } else {
796 EventKind::CacheMiss {
797 call,
798 target,
799 scope,
800 }
801 }
802 });
803 }
804
805 pub fn configure(&self, policy_json: &Value) -> Result<(), KeelError> {
811 let policy: Policy =
812 serde_path_to_error::deserialize(policy_json).map_err(|e| KeelError {
813 code: ErrorCode::PolicyInvalid,
814 message: format!("policy invalid at {}: {}", e.path(), e.inner()),
815 })?;
816 if let Some(location) = &policy.journal {
821 self.apply_journal_location(location)?;
822 }
823 if let Some(telemetry) = &policy.telemetry
831 && !telemetry.console
832 {
833 warn!(
834 "policy `telemetry.console = false` is validated but not yet wired: v0.1 always \
835 uses the default local summary. `telemetry.otlp_endpoint` IS honored by front \
836 ends built with the `otel` feature."
837 );
838 }
839 warn_inert_breaker_knobs(&policy);
840 *self.policy.write().expect("policy lock poisoned") = policy;
841 *self.raw_policy.write().expect("raw policy lock poisoned") = policy_json.clone();
842 Ok(())
843 }
844
845 #[must_use]
851 pub fn telemetry_otlp_endpoint(&self) -> Option<String> {
852 self.policy
853 .read()
854 .expect("policy lock poisoned")
855 .telemetry
856 .as_ref()
857 .and_then(|t| t.otlp_endpoint.clone())
858 }
859
860 fn apply_journal_location(&self, location: &JournalLocation) -> Result<(), KeelError> {
873 let backend = JournalBackend::select(location);
874 {
875 let slot = self.journal.read().expect("journal lock poisoned");
876 if slot.backend.as_ref() == Some(&backend) {
877 return Ok(()); }
879 }
880 let journal = journal_backend::open(&backend)?;
885 if let JournalBackend::File(path) = &backend {
886 debug!(path = %path.display(), "journal selected by policy");
887 }
888 let mut slot = self.journal.write().expect("journal lock poisoned");
889 slot.journal = Some(journal);
890 slot.backend = Some(backend);
891 Ok(())
892 }
893
894 #[must_use]
901 pub fn idempotency_header(&self, target: &str) -> Option<String> {
902 self.policy
903 .read()
904 .expect("policy lock poisoned")
905 .resolve(target)
906 .idempotency
907 .map(|i| i.header)
908 }
909
910 #[must_use]
915 pub fn resolve_target(
916 &self,
917 method: &str,
918 host: &str,
919 scheme: Option<&str>,
920 port: Option<u16>,
921 path: Option<&str>,
922 ) -> String {
923 self.policy
924 .read()
925 .expect("policy lock poisoned")
926 .resolve_target(method, host, scheme, port, path)
927 }
928
929 #[must_use]
940 pub fn layer(&self, target: &str, key: &str) -> Value {
941 let raw = self.raw_policy.read().expect("raw policy lock poisoned");
942 if let Some(value) = raw
943 .get("target")
944 .and_then(|t| t.get(target))
945 .and_then(|t| t.get(key))
946 {
947 return value.clone();
948 }
949 if target.starts_with("llm:")
950 && let Some(value) = raw
951 .get("defaults")
952 .and_then(|d| d.get("llm"))
953 .and_then(|l| l.get(key))
954 {
955 return value.clone();
956 }
957 raw.get("defaults")
958 .and_then(|d| d.get("outbound"))
959 .and_then(|o| o.get(key))
960 .cloned()
961 .unwrap_or(Value::Null)
962 }
963
964 #[must_use]
969 pub fn known_llm_hosts() -> Vec<(&'static str, &'static str)> {
970 Policy::known_llm_hosts()
971 }
972
973 #[must_use]
977 pub fn nondeterminism_response(&self) -> NondeterminismResponse {
978 self.policy
979 .read()
980 .expect("policy lock poisoned")
981 .flows
982 .as_ref()
983 .map_or(NondeterminismResponse::default(), |f| f.on_nondeterminism)
984 }
985
986 fn state(&self) -> MutexGuard<'_, State> {
987 self.state.lock().expect("state lock poisoned")
988 }
989
990 fn elapsed_ms(&self) -> u64 {
991 u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
992 }
993
994 pub async fn execute<F>(&self, request: &Request, mut effect: F) -> Outcome
999 where
1000 F: AsyncFnMut(u32) -> AttemptResult,
1001 {
1002 let started = Instant::now();
1003 let span = tracing::info_span!(
1009 "keel.call",
1010 target = %request.target,
1011 op = %request.op,
1012 trace_id = tracing::field::Empty,
1013 result = tracing::field::Empty,
1014 error_code = tracing::field::Empty,
1015 attempts = tracing::field::Empty,
1016 from_cache = tracing::field::Empty,
1017 throttled = tracing::field::Empty,
1018 breaker = tracing::field::Empty,
1019 );
1020 let out = self
1021 .run_chain(request, &mut effect)
1022 .instrument(span.clone())
1023 .await;
1024 record_call_fields(&span, &out);
1025 self.observe(request, &out, started);
1026 self.emit_event(|| EventKind::CallEnd {
1029 call: out.trace_id.clone(),
1030 target: request.target.clone(),
1031 result: out.result.clone(),
1032 code: out.error.as_ref().map(|e| e.code),
1033 attempts: out.attempts,
1034 });
1035 out
1036 }
1037
1038 async fn run_chain<F>(&self, request: &Request, effect: &mut F) -> Outcome
1043 where
1044 F: AsyncFnMut(u32) -> AttemptResult,
1045 {
1046 let target = request.target.as_str();
1047 let mut out = self.begin_call(target);
1048
1049 let trace = self.events.as_ref().map(|sink| {
1052 let seq = sink.emit(
1053 self.elapsed_ms(),
1054 EventKind::CallStart {
1055 call: out.trace_id.clone(),
1056 target: target.to_owned(),
1057 op: request.op.clone(),
1058 },
1059 );
1060 TraceRef {
1061 run: sink.run_id().to_owned(),
1062 seq,
1063 }
1064 });
1065
1066 if request.v != ENVELOPE_VERSION {
1067 out.error = Some(OutcomeError {
1068 code: ErrorCode::EnvelopeVersion,
1069 class: ErrorClass::Other,
1070 http_status: None,
1071 message: format!("unsupported envelope version {}", request.v),
1072 original: None,
1073 });
1074 self.state().metrics_for(target).failures += 1;
1075 return out;
1076 }
1077
1078 let resolved = self
1079 .policy
1080 .read()
1081 .expect("policy lock poisoned")
1082 .resolve(target);
1083
1084 let cache_plan = self.plan_cache(target, &resolved, request);
1087 match &cache_plan {
1088 CachePlan::Memory { key } => {
1089 if self.serve_from_cache(key, &mut out) {
1090 crate::metrics::record_cache_request(target, true);
1091 self.emit_cache(&out, target, CacheStore::Memory, true);
1092 return out;
1093 }
1094 crate::metrics::record_cache_request(target, false);
1095 self.emit_cache(&out, target, CacheStore::Memory, false);
1096 }
1097 CachePlan::Persistent { key, .. } => {
1098 if self.serve_from_persistent(target, key, &mut out) {
1099 crate::metrics::record_cache_request(target, true);
1100 self.emit_cache(&out, target, CacheStore::Persistent, true);
1101 return out;
1102 }
1103 crate::metrics::record_cache_request(target, false);
1104 self.emit_cache(&out, target, CacheStore::Persistent, false);
1105 }
1106 CachePlan::None => {}
1107 }
1108
1109 if let Some(rate) = resolved.rate {
1111 self.throttle(target, rate, &mut out).await;
1112 }
1113
1114 let admission = self.admit(target, &resolved, &mut out, trace.as_ref());
1116 if admission == Admission::Rejected {
1117 return out;
1118 }
1119
1120 let retry = resolved.retry.clone().unwrap_or_else(|| RetryPolicy {
1122 attempts: core::num::NonZeroU32::MIN,
1123 ..RetryPolicy::default()
1124 });
1125 let result = match resolved.poll.as_ref().filter(|_| poll_applies(request)) {
1126 Some(poll) => {
1127 self.run_poll(
1128 request,
1129 &resolved,
1130 &retry,
1131 poll,
1132 effect,
1133 &mut out,
1134 trace.as_ref(),
1135 )
1136 .await
1137 }
1138 None => {
1139 self.run_attempts(request, &resolved, &retry, effect, &mut out, trace.as_ref())
1140 .await
1141 }
1142 };
1143 let memory_key = match &cache_plan {
1146 CachePlan::Memory { key } => Some(key.clone()),
1147 _ => None,
1148 };
1149 self.settle(target, &resolved, admission, memory_key, result, &mut out);
1150
1151 if let CachePlan::Persistent { key, ttl } = &cache_plan
1152 && out.result == "ok"
1153 && let Some(payload) = &out.payload
1154 {
1155 self.write_persistent(target, key, payload, *ttl);
1156 }
1157 out
1158 }
1159
1160 fn plan_cache(&self, target: &str, resolved: &ResolvedPolicy, request: &Request) -> CachePlan {
1164 let (Some(cache), Some(hash)) = (resolved.cache.as_ref(), request.args_hash.as_ref())
1165 else {
1166 return CachePlan::None;
1167 };
1168 let Some(ttl) = cache.ttl else {
1169 return CachePlan::None;
1170 };
1171 match cache.scope {
1172 CacheScope::Persistent if self.current_journal().is_some() => CachePlan::Persistent {
1173 key: JournalCacheKey::new(format!("{target}#{hash}")),
1174 ttl,
1175 },
1176 _ => CachePlan::Memory {
1177 key: CacheKey {
1178 target: target.to_owned(),
1179 args_hash: hash.clone(),
1180 },
1181 },
1182 }
1183 }
1184
1185 fn begin_call(&self, target: &str) -> Outcome {
1187 let mut state = self.state();
1188 state.metrics_for(target).calls += 1;
1189 state.trace_seq += 1;
1190 Outcome {
1191 v: ENVELOPE_VERSION,
1192 result: String::from("error"),
1193 payload: None,
1194 error: None,
1195 attempts: 0,
1196 from_cache: false,
1197 waits_ms: Vec::new(),
1198 throttled: false,
1199 throttle_wait_ms: 0,
1200 breaker: BreakerState::Closed,
1201 trace_id: format!("t-{:06}", state.trace_seq),
1202 }
1203 }
1204
1205 fn serve_from_cache(&self, key: &CacheKey, out: &mut Outcome) -> bool {
1210 let now = Instant::now();
1211 let mut state = self.state();
1212 let payload = match state.cache.get(key) {
1213 Some(entry) if now < entry.expires_at => entry.payload.clone(),
1214 Some(_) => {
1215 state.cache.remove(key);
1218 return false;
1219 }
1220 None => return false,
1221 };
1222 out.result = String::from("ok");
1223 out.payload = Some(payload);
1224 out.from_cache = true;
1225 let metrics = state.metrics_for(&key.target);
1226 metrics.cache_hits += 1;
1227 metrics.successes += 1;
1228 out.breaker = state.breaker_state(&key.target, now);
1229 debug!(target = %key.target, scope = "memory", "cache hit");
1230 true
1231 }
1232
1233 fn serve_from_persistent(
1240 &self,
1241 target: &str,
1242 key: &JournalCacheKey,
1243 out: &mut Outcome,
1244 ) -> bool {
1245 let Some(journal) = self.current_journal() else {
1246 return false;
1247 };
1248 let bytes = match journal.get_cache(key) {
1249 Ok(Some(bytes)) => bytes,
1250 Ok(None) => return false,
1251 Err(error) => {
1252 warn!(target = %target, error = %error, "persistent cache read failed; serving live");
1253 return false;
1254 }
1255 };
1256 let payload = match decode_cache_payload(&bytes) {
1257 Ok(payload) => payload,
1258 Err(reason) => {
1259 warn!(target = %target, reason = %reason, "persistent cache entry undecodable; serving live");
1260 return false;
1261 }
1262 };
1263 let now = Instant::now();
1264 let mut state = self.state();
1265 out.result = String::from("ok");
1266 out.payload = Some(payload);
1267 out.from_cache = true;
1268 let metrics = state.metrics_for(target);
1269 metrics.cache_hits += 1;
1270 metrics.successes += 1;
1271 out.breaker = state.breaker_state(target, now);
1272 debug!(target = %target, scope = "persistent", "cache hit");
1273 true
1274 }
1275
1276 async fn throttle(&self, target: &str, rate: Rate, out: &mut Outcome) {
1278 let wait_ms = {
1279 let elapsed = self.elapsed_ms();
1280 let mut state = self.state();
1281 let bucket = state.rate_buckets.entry(target.to_owned()).or_default();
1282 bucket.plan_admit(elapsed, rate)
1283 };
1284 if wait_ms > 0 {
1285 out.throttled = true;
1286 out.throttle_wait_ms = wait_ms;
1287 self.state().metrics_for(target).throttled += 1;
1288 crate::metrics::record_throttled(target, wait_ms);
1289 self.emit_event(|| EventKind::Throttle {
1291 call: out.trace_id.clone(),
1292 target: target.to_owned(),
1293 wait_ms,
1294 });
1295 tokio::time::sleep(Duration::from_millis(wait_ms)).await;
1296 }
1297 }
1298
1299 fn admit(
1302 &self,
1303 target: &str,
1304 resolved: &ResolvedPolicy,
1305 out: &mut Outcome,
1306 trace: Option<&TraceRef>,
1307 ) -> Admission {
1308 if resolved.breaker.is_none() {
1309 return Admission::Closed;
1310 }
1311 let now = Instant::now();
1312 let admission = {
1313 let mut state = self.state();
1314 let admission = state
1315 .breakers
1316 .entry(target.to_owned())
1317 .or_default()
1318 .admit(now);
1319 if admission == Admission::Rejected {
1320 out.error = Some(OutcomeError {
1321 code: ErrorCode::BreakerOpen,
1322 class: ErrorClass::Other,
1323 http_status: None,
1324 message: with_trace_ref(
1325 format!("breaker OPEN for {target}: failed fast, call not attempted"),
1326 trace,
1327 ),
1328 original: None,
1329 });
1330 out.breaker = BreakerState::Open;
1331 state.metrics_for(target).failures += 1;
1332 }
1333 admission
1334 };
1335 if admission == Admission::Rejected {
1337 self.emit_event(|| EventKind::BreakerReject {
1338 call: out.trace_id.clone(),
1339 target: target.to_owned(),
1340 });
1341 }
1342 if admission == Admission::HalfOpen {
1344 debug!(target = %target, transition = "half_open", "breaker transition");
1345 crate::metrics::record_breaker_transition(target, "half_open");
1346 self.emit_event(|| EventKind::BreakerHalfOpen {
1347 call: out.trace_id.clone(),
1348 target: target.to_owned(),
1349 });
1350 }
1351 admission
1352 }
1353
1354 fn settle(
1357 &self,
1358 target: &str,
1359 resolved: &ResolvedPolicy,
1360 admission: Admission,
1361 cache_key: Option<CacheKey>,
1362 result: Result<Value, OutcomeError>,
1363 out: &mut Outcome,
1364 ) {
1365 let now = Instant::now();
1366 let transition = {
1367 let mut state = self.state();
1368 let transition = match result {
1369 Ok(payload) => {
1370 state.metrics_for(target).successes += 1;
1371 let mut transition = BreakerTransition::None;
1372 if let Some(config) = &resolved.breaker
1373 && let Some(breaker) = state.breakers.get_mut(target)
1374 {
1375 transition = breaker.on_success(now, config);
1376 }
1377 if let (Some(key), Some(cache)) = (cache_key, &resolved.cache)
1378 && let Some(ttl) = cache.ttl
1379 {
1380 state.cache.retain(|_, entry| entry.expires_at > now);
1388 state.cache.insert(
1389 key,
1390 CacheEntry {
1391 expires_at: now + Duration::from_millis(ttl.0),
1392 payload: payload.clone(),
1393 },
1394 );
1395 }
1396 out.result = String::from("ok");
1397 out.payload = Some(payload);
1398 transition
1399 }
1400 Err(error) => {
1401 state.metrics_for(target).failures += 1;
1402 let mut transition = BreakerTransition::None;
1403 if let Some(config) = &resolved.breaker
1404 && let Some(breaker) = state.breakers.get_mut(target)
1405 {
1406 transition = breaker.on_terminal_failure(now, config, admission);
1407 }
1408 out.error = Some(error);
1409 transition
1410 }
1411 };
1412 out.breaker = state.breaker_state(target, now);
1413 transition
1414 };
1415 emit_breaker_transition(target, transition);
1416 match transition {
1417 BreakerTransition::Opened => self.emit_event(|| EventKind::BreakerOpen {
1418 call: out.trace_id.clone(),
1419 target: target.to_owned(),
1420 cooldown_ms: resolved.breaker.as_ref().map_or(0, |b| b.cooldown.0),
1421 }),
1422 BreakerTransition::Closed => self.emit_event(|| EventKind::BreakerClose {
1423 call: out.trace_id.clone(),
1424 target: target.to_owned(),
1425 }),
1426 BreakerTransition::None => {}
1427 }
1428 }
1429
1430 fn write_persistent(
1435 &self,
1436 target: &str,
1437 key: &JournalCacheKey,
1438 payload: &Value,
1439 ttl: DurationMs,
1440 ) {
1441 let Some(journal) = self.current_journal() else {
1442 return;
1443 };
1444 let bytes = match encode_cache_payload(payload) {
1445 Ok(bytes) => bytes,
1446 Err(error) => {
1447 warn!(target = %target, error = %error, "persistent cache encode failed; entry not stored");
1448 return;
1449 }
1450 };
1451 if let Err(error) = journal.put_cache(key, &bytes, Duration::from_millis(ttl.0)) {
1452 warn!(target = %target, error = %error, "persistent cache write failed; entry not stored");
1453 }
1454 }
1455
1456 fn observe(&self, request: &Request, out: &Outcome, started: Instant) {
1461 let Some(discovery) = self.discovery.as_ref() else {
1462 return;
1463 };
1464 let latency_ms = i64::try_from(started.elapsed().as_millis()).unwrap_or(i64::MAX);
1465 let result = if out.from_cache {
1466 CallResult::CacheHit
1467 } else if out.result == "ok" {
1468 CallResult::Success
1469 } else {
1470 CallResult::Failure
1471 };
1472 let error = out.error.as_ref().map(|e| ObservedError {
1473 class: e.class,
1474 http_status: e.http_status,
1475 });
1476 let breaker_opened = out
1477 .error
1478 .as_ref()
1479 .is_some_and(|e| e.code == ErrorCode::BreakerOpen);
1480 let not_retried = out
1483 .error
1484 .as_ref()
1485 .is_some_and(|e| e.code == ErrorCode::NonIdempotentNotRetried);
1486 let wrapped = self
1490 .policy
1491 .read()
1492 .expect("policy lock poisoned")
1493 .target
1494 .contains_key(&request.target);
1495 let observation = CallObservation {
1496 target: request.target.clone(),
1497 result,
1498 attempts: out.attempts,
1499 latency_ms,
1500 throttled: out.throttled,
1501 breaker_opened,
1502 not_retried,
1503 wrapped,
1504 error,
1505 };
1506 if let Err(error) = discovery.record(&observation) {
1507 warn!(target = %request.target, error = %error, "discovery record failed; observation dropped");
1508 }
1509 }
1510
1511 async fn run_one_attempt<F>(
1527 &self,
1528 timeout: Option<DurationMs>,
1529 effect: &mut F,
1530 attempt: u32,
1531 attempt_span: &tracing::Span,
1532 ) -> AttemptOutcome
1533 where
1534 F: AsyncFnMut(u32) -> AttemptResult,
1535 {
1536 match timeout {
1537 Some(limit) => {
1538 match tokio::time::timeout(
1539 Duration::from_millis(limit.0),
1540 effect(attempt).instrument(attempt_span.clone()),
1541 )
1542 .await
1543 {
1544 Ok(result) => AttemptOutcome {
1545 result,
1546 timed_out_by_layer: false,
1547 },
1548 Err(_elapsed) => AttemptOutcome {
1549 result: AttemptResult::Error {
1550 class: ErrorClass::Timeout,
1551 http_status: None,
1552 retry_after_ms: None,
1553 message: format!("no response within {}ms", limit.0),
1554 original: None,
1555 },
1556 timed_out_by_layer: true,
1557 },
1558 }
1559 }
1560 None => AttemptOutcome {
1561 result: effect(attempt).instrument(attempt_span.clone()).await,
1562 timed_out_by_layer: false,
1563 },
1564 }
1565 }
1566
1567 #[expect(clippy::too_many_arguments, reason = "mirrors run_attempts' signature")]
1571 async fn run_poll<F>(
1572 &self,
1573 request: &Request,
1574 resolved: &ResolvedPolicy,
1575 retry: &RetryPolicy,
1576 poll: &keel_core_api::policy::PollPolicy,
1577 effect: &mut F,
1578 out: &mut Outcome,
1579 trace: Option<&TraceRef>,
1580 ) -> Result<Value, OutcomeError>
1581 where
1582 F: AsyncFnMut(u32) -> AttemptResult,
1583 {
1584 let started = Instant::now();
1585 loop {
1586 let payload = self
1587 .run_attempts(request, resolved, retry, effect, out, trace)
1588 .await?;
1589 match poll_verdict(poll, &payload) {
1590 PollVerdict::Terminal => return Ok(payload),
1591 PollVerdict::FailOpen => {
1592 debug!(
1593 target = %request.target, field = %poll.until.field,
1594 "poll fail-open: response not judgeable; returned as-is"
1595 );
1596 return Ok(payload);
1597 }
1598 PollVerdict::Pending => {
1599 let elapsed = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
1600 if elapsed.saturating_add(poll.interval.0) > poll.deadline.0 {
1601 return Err(OutcomeError {
1602 code: ErrorCode::PollDeadlineExceeded,
1603 class: ErrorClass::Other,
1604 http_status: None,
1605 message: with_trace_ref(
1606 format!(
1607 "{} poll deadline exceeded: '{}' not terminal after {}ms",
1608 request.op, poll.until.field, poll.deadline.0
1609 ),
1610 trace,
1611 ),
1612 original: None,
1613 });
1614 }
1615 tokio::time::sleep(Duration::from_millis(poll.interval.0)).await;
1616 }
1617 }
1618 }
1619 }
1620
1621 async fn run_attempts<F>(
1624 &self,
1625 request: &Request,
1626 resolved: &ResolvedPolicy,
1627 retry: &RetryPolicy,
1628 effect: &mut F,
1629 out: &mut Outcome,
1630 trace: Option<&TraceRef>,
1631 ) -> Result<Value, OutcomeError>
1632 where
1633 F: AsyncFnMut(u32) -> AttemptResult,
1634 {
1635 let target = request.target.as_str();
1636 let max_attempts = retry.attempts.get();
1637 let attempt_timeout = resolved.timeout.filter(|_| request.idempotent);
1644 for attempt in 1..=max_attempts {
1645 out.attempts += 1;
1646 self.state().metrics_for(target).attempts += 1;
1647 crate::metrics::record_attempt(target);
1648 self.emit_event(|| EventKind::AttemptStart {
1649 call: out.trace_id.clone(),
1650 target: target.to_owned(),
1651 attempt,
1652 });
1653
1654 let attempt_span = tracing::debug_span!(
1658 "keel.attempt",
1659 attempt,
1660 result = tracing::field::Empty,
1661 class = tracing::field::Empty,
1662 http_status = tracing::field::Empty,
1663 wait_ms = tracing::field::Empty,
1664 );
1665
1666 let attempt_outcome = self
1667 .run_one_attempt(attempt_timeout, effect, attempt, &attempt_span)
1668 .await;
1669
1670 match attempt_outcome.result {
1671 AttemptResult::Ok { payload } => {
1672 attempt_span.record("result", "ok");
1673 return Ok(payload);
1674 }
1675 AttemptResult::Error {
1676 class,
1677 http_status,
1678 retry_after_ms,
1679 message,
1680 original,
1681 } => {
1682 attempt_span.record("result", "error");
1683 attempt_span.record("class", class_str(class));
1684 if let Some(status) = http_status {
1685 attempt_span.record("http_status", status);
1686 }
1687 self.emit_event(|| EventKind::AttemptError {
1688 call: out.trace_id.clone(),
1689 target: target.to_owned(),
1690 attempt,
1691 class,
1692 http_status,
1693 });
1694 let retryable = retry.is_retryable(class, http_status);
1695 if let Some(code) =
1696 terminal_code(retryable, attempt, max_attempts, request.idempotent)
1697 {
1698 let ctx = TerminalAttemptCtx {
1699 request,
1700 attempt,
1701 max_attempts,
1702 trace,
1703 timed_out_by_layer: attempt_outcome.timed_out_by_layer,
1704 };
1705 return Err(terminal_attempt_error(
1706 &ctx,
1707 code,
1708 class,
1709 http_status,
1710 &message,
1711 original,
1712 ));
1713 }
1714 let (mut wait, jitter) = retry.schedule.wait_and_jitter(attempt);
1718 if jitter && wait > 0 {
1719 wait = fastrand::u64(wait / 2..=wait);
1720 }
1721 if let Some(server_says) = retry_after_ms {
1722 wait = wait.max(server_says);
1723 }
1724 attempt_span.record("wait_ms", wait);
1725 out.waits_ms.push(wait);
1726 self.state().metrics_for(target).retries += 1;
1727 crate::metrics::record_retry(target, wait);
1728 self.emit_event(|| EventKind::Backoff {
1730 call: out.trace_id.clone(),
1731 target: target.to_owned(),
1732 attempt,
1733 wait_ms: wait,
1734 });
1735 tokio::time::sleep(Duration::from_millis(wait)).await;
1736 }
1737 }
1738 }
1739 unreachable!("loop always returns by the final attempt");
1740 }
1741
1742 pub fn report(&self) -> Value {
1745 let now = Instant::now();
1746 let state = self.state();
1747 let targets = state
1748 .metrics
1749 .iter()
1750 .map(|(name, m)| {
1751 let breaker = state.breakers.get(name);
1752 let row = TargetReport {
1753 attempts: m.attempts,
1754 breaker_opens: breaker.map_or(0, |b| b.opens),
1755 breaker_state: state.breaker_state(name, now),
1756 cache_hits: m.cache_hits,
1757 calls: m.calls,
1758 failures: m.failures,
1759 retries: m.retries,
1760 successes: m.successes,
1761 throttled: m.throttled,
1762 };
1763 (name.as_str(), row)
1764 })
1765 .collect();
1766 serde_json::to_value(Report {
1767 v: 1,
1768 clock_ms: self.elapsed_ms(),
1769 targets,
1770 })
1771 .expect("report serialization is infallible")
1772 }
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777 use super::{
1778 Admission, AttemptResult, Breaker, BreakerTransition, ENVELOPE_VERSION, Engine, Instant,
1779 Request, TokenBucket,
1780 };
1781 use core::num::NonZeroU32;
1782 use core::time::Duration;
1783 use keel_core_api::policy::{BreakerPolicy, DurationMs, Rate};
1784 use serde_json::json;
1785
1786 #[test]
1792 fn breaker_rate_mode_evicts_outcomes_older_than_the_window() {
1793 let config = BreakerPolicy {
1794 failures: None,
1795 cooldown: DurationMs(15_000),
1796 window: Some(DurationMs(10_000)),
1797 failure_rate: Some(0.5),
1798 min_calls: Some(NonZeroU32::new(2).unwrap()),
1799 };
1800 let mut breaker = Breaker::default();
1801 let t0 = Instant::now();
1802
1803 assert_eq!(
1804 breaker.on_terminal_failure(t0, &config, Admission::Closed),
1805 BreakerTransition::None,
1806 "one failure is below min_calls"
1807 );
1808 let t_11s = t0 + Duration::from_secs(11);
1809 assert_eq!(
1810 breaker.on_terminal_failure(t_11s, &config, Admission::Closed),
1811 BreakerTransition::None,
1812 "the stale failure must have aged out of the 10s window"
1813 );
1814 }
1815
1816 #[test]
1820 fn token_bucket_caps_refill_at_burst_capacity() {
1821 let rate = Rate {
1822 limit: core::num::NonZeroU64::new(2).unwrap(),
1823 window_ms: 1000,
1824 };
1825 let mut bucket = TokenBucket::default();
1826
1827 assert_eq!(bucket.plan_admit(0, rate), 0, "burst covers the first call");
1828 assert_eq!(
1829 bucket.plan_admit(0, rate),
1830 0,
1831 "burst covers the second call"
1832 );
1833 assert_eq!(
1834 bucket.plan_admit(0, rate),
1835 500,
1836 "burst drained: paced at window/limit = 500ms"
1837 );
1838
1839 assert_eq!(
1842 bucket.plan_admit(50_000, rate),
1843 0,
1844 "capacity refilled to burst"
1845 );
1846 assert_eq!(
1847 bucket.plan_admit(50_000, rate),
1848 0,
1849 "both burst tokens available"
1850 );
1851 assert_eq!(
1852 bucket.plan_admit(50_000, rate),
1853 500,
1854 "capacity clamp: idle time cannot bank more than `limit` tokens"
1855 );
1856 }
1857
1858 fn req(target: &str, args_hash: &str) -> Request {
1859 Request {
1860 v: ENVELOPE_VERSION,
1861 target: target.to_owned(),
1862 op: format!("GET {target}"),
1863 idempotent: true,
1864 args_hash: Some(args_hash.to_owned()),
1865 }
1866 }
1867
1868 #[tokio::test(start_paused = true)]
1872 async fn in_memory_cache_evicts_expired_entries() {
1873 let engine = Engine::new();
1874 engine
1875 .configure(&json!({
1876 "target": { "api.catalog.internal": { "cache": { "ttl": "60s" } } }
1877 }))
1878 .expect("valid policy");
1879
1880 engine
1881 .execute(&req("api.catalog.internal", "k1"), async |_a| {
1882 AttemptResult::Ok { payload: json!(1) }
1883 })
1884 .await;
1885 assert_eq!(engine.state().cache.len(), 1, "k1 cached");
1886
1887 tokio::time::advance(Duration::from_secs(61)).await;
1889 engine
1890 .execute(&req("api.catalog.internal", "k2"), async |_a| {
1891 AttemptResult::Ok { payload: json!(2) }
1892 })
1893 .await;
1894 assert_eq!(
1895 engine.state().cache.len(),
1896 1,
1897 "expired k1 evicted on write; only the live k2 remains"
1898 );
1899
1900 let out = engine
1903 .execute(&req("api.catalog.internal", "k1"), async |_a| {
1904 AttemptResult::Ok { payload: json!(3) }
1905 })
1906 .await;
1907 assert!(!out.from_cache, "expired/evicted key re-runs live");
1908 }
1909
1910 fn poll_policy_json(interval: &str, deadline: &str) -> serde_json::Value {
1911 json!({ "target": { "api.jobs.internal": {
1912 "retry": { "attempts": 3, "schedule": "fixed(200ms)", "on": ["conn", "timeout", "429", "5xx"] },
1913 "poll": { "interval": interval, "deadline": deadline,
1914 "until": { "field": "status", "terminal": ["completed", "failed"] } }
1915 } } })
1916 }
1917
1918 #[tokio::test(start_paused = true)]
1919 async fn poll_reissues_until_terminal_and_accumulates_attempts() {
1920 let engine = Engine::new();
1921 engine.configure(&poll_policy_json("10s", "90s")).unwrap();
1922 let mut script = vec![
1923 json!({"status": "running"}),
1924 json!({"status": "running"}),
1925 json!({"status": "completed", "result": 42}),
1926 ]
1927 .into_iter();
1928 let out = engine
1929 .execute(&req("api.jobs.internal", "h1"), async |_a| {
1930 AttemptResult::Ok {
1931 payload: script.next().expect("script exhausted"),
1932 }
1933 })
1934 .await;
1935 assert_eq!(out.result, "ok");
1936 assert_eq!(
1937 out.payload,
1938 Some(json!({"status": "completed", "result": 42}))
1939 );
1940 assert_eq!(
1941 out.attempts, 3,
1942 "attempts accumulate across poll iterations"
1943 );
1944 assert!(
1945 out.waits_ms.is_empty(),
1946 "poll intervals are not retry waits"
1947 );
1948 let report = engine.report();
1949 assert_eq!(report["targets"]["api.jobs.internal"]["calls"], 1);
1950 assert_eq!(report["targets"]["api.jobs.internal"]["successes"], 1);
1951 }
1952
1953 #[tokio::test(start_paused = true)]
1954 async fn poll_deadline_is_e016_and_breaker_countable() {
1955 let engine = Engine::new();
1956 let mut policy = poll_policy_json("10s", "25s");
1957 policy["target"]["api.jobs.internal"]["breaker"] = json!({ "failures": 1 });
1958 engine.configure(&policy).unwrap();
1959 let out = engine
1960 .execute(&req("api.jobs.internal", "h1"), async |_a| {
1961 AttemptResult::Ok {
1962 payload: json!({"status": "running"}),
1963 }
1964 })
1965 .await;
1966 assert_eq!(out.result, "error");
1967 let err = out.error.expect("terminal error");
1968 assert_eq!(err.code.as_str(), "KEEL-E016");
1969 assert_eq!(
1970 err.message,
1971 "GET api.jobs.internal poll deadline exceeded: 'status' not terminal after 25000ms"
1972 );
1973 assert_eq!(out.attempts, 3);
1975 assert_eq!(
1976 out.breaker,
1977 keel_core_api::BreakerState::Open,
1978 "breaker-countable"
1979 );
1980 }
1981
1982 #[tokio::test(start_paused = true)]
1983 async fn poll_fails_open_on_missing_field_and_skips_non_get() {
1984 let engine = Engine::new();
1985 engine.configure(&poll_policy_json("10s", "90s")).unwrap();
1986 let out = engine
1988 .execute(&req("api.jobs.internal", "h2"), async |_a| {
1989 AttemptResult::Ok {
1990 payload: json!({"progress": 0.5}),
1991 }
1992 })
1993 .await;
1994 assert_eq!(out.result, "ok");
1995 assert_eq!(out.attempts, 1);
1996 let mut post = req("api.jobs.internal", "h3");
1998 post.op = String::from("POST api.jobs.internal");
1999 let out = engine
2000 .execute(&post, async |_a| AttemptResult::Ok {
2001 payload: json!({"status": "running"}),
2002 })
2003 .await;
2004 assert_eq!(out.attempts, 1);
2005 }
2006
2007 #[tokio::test(start_paused = true)]
2008 async fn poll_judges_http_envelope_bodies() {
2009 use base64::Engine as _;
2010 let engine = Engine::new();
2011 engine.configure(&poll_policy_json("10s", "90s")).unwrap();
2012 let b64 =
2013 |v: &serde_json::Value| base64::engine::general_purpose::STANDARD.encode(v.to_string());
2014 let envelope = |body: &serde_json::Value| {
2015 json!({ "status": 200, "headers": [["content-type", "application/json"]],
2016 "body_b64": b64(body) })
2017 };
2018 let mut script = vec![
2019 envelope(&json!({"status": "running"})),
2020 envelope(&json!({"status": "completed"})),
2021 ]
2022 .into_iter();
2023 let out = engine
2024 .execute(&req("api.jobs.internal", "h4"), async |_a| {
2025 AttemptResult::Ok {
2026 payload: script.next().expect("script exhausted"),
2027 }
2028 })
2029 .await;
2030 assert_eq!(out.result, "ok");
2031 assert_eq!(out.attempts, 2);
2032 let raw = json!({ "status": 200, "headers": [],
2034 "body_b64": base64::engine::general_purpose::STANDARD.encode("not json") });
2035 let out = engine
2036 .execute(&req("api.jobs.internal", "h5"), async |_a| {
2037 AttemptResult::Ok {
2038 payload: raw.clone(),
2039 }
2040 })
2041 .await;
2042 assert_eq!(out.result, "ok");
2043 assert_eq!(out.payload, Some(raw));
2044 assert_eq!(out.attempts, 1);
2045 }
2046
2047 #[test]
2048 fn engine_resolve_target_delegates_to_policy() {
2049 let e = Engine::new();
2050 e.configure(&json!({ "target": { "*.internal.corp": {} } }))
2051 .unwrap();
2052 assert_eq!(
2053 e.resolve_target("GET", "db.internal.corp", None, None, None),
2054 "*.internal.corp"
2055 );
2056 }
2057
2058 #[test]
2059 fn engine_layer_reads_resolved_value() {
2060 let e = Engine::new();
2061 e.configure(
2062 &json!({ "target": { "api.x": { "idempotency": { "header": "Idempotency-Key" } } } }),
2063 )
2064 .unwrap();
2065 assert_eq!(
2066 e.layer("api.x", "idempotency"),
2067 json!({ "header": "Idempotency-Key" })
2068 );
2069 assert_eq!(e.layer("api.x", "retry"), serde_json::Value::Null);
2070 }
2071
2072 #[test]
2079 fn engine_layer_round_trips_the_original_duration_literal_verbatim() {
2080 let e = Engine::new();
2081 e.configure(&json!({ "defaults": { "llm": { "timeout": "120s" } } }))
2082 .unwrap();
2083 assert_eq!(e.layer("llm:openai", "timeout"), json!("120s"));
2084 }
2085}