1use core::fmt;
9use core::num::{NonZeroU32, NonZeroU64};
10use core::str::FromStr;
11use std::collections::BTreeMap;
12
13use crate::ErrorClass;
14use serde::Deserialize;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ParseError {
20 what: &'static str,
21 input: String,
22 note: Option<&'static str>,
26}
27
28impl ParseError {
29 fn new(what: &'static str, input: &str) -> Self {
30 Self {
31 what,
32 input: input.to_owned(),
33 note: None,
34 }
35 }
36
37 fn with_note(what: &'static str, input: &str, note: &'static str) -> Self {
38 Self {
39 what,
40 input: input.to_owned(),
41 note: Some(note),
42 }
43 }
44}
45
46impl fmt::Display for ParseError {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self.note {
49 None => write!(f, "unparseable {} literal: {:?}", self.what, self.input),
50 Some(note) => write!(
51 f,
52 "invalid {} literal: {:?} — {note}",
53 self.what, self.input
54 ),
55 }
56 }
57}
58
59impl core::error::Error for ParseError {}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
63#[serde(try_from = "String")]
64pub struct DurationMs(pub u64);
65
66impl FromStr for DurationMs {
67 type Err = ParseError;
68
69 fn from_str(s: &str) -> Result<Self, Self::Err> {
70 let err = || ParseError::new("duration", s);
71 let s = s.trim();
72 let unit_at = s.find(|c: char| !c.is_ascii_digit()).ok_or_else(err)?;
73 let (number, unit) = s.split_at(unit_at);
74 let n: u64 = number.parse().map_err(|_| err())?;
75 let mult = match unit {
76 "ms" => 1,
77 "s" => 1_000,
78 "m" => 60_000,
79 "h" => 3_600_000,
80 _ => return Err(err()),
81 };
82 Ok(Self(n * mult))
83 }
84}
85
86impl TryFrom<String> for DurationMs {
87 type Error = ParseError;
88
89 fn try_from(s: String) -> Result<Self, Self::Error> {
90 s.parse()
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
96#[serde(try_from = "String")]
97pub struct Rate {
98 pub limit: NonZeroU64,
99 pub window_ms: u64,
100}
101
102impl FromStr for Rate {
103 type Err = ParseError;
104
105 fn from_str(s: &str) -> Result<Self, Self::Err> {
106 let err = || ParseError::new("rate", s);
107 let (limit, window) = s.trim().split_once('/').ok_or_else(err)?;
108 let limit: NonZeroU64 = limit.trim().parse().map_err(|_| err())?;
109 let window_ms = match window.trim() {
110 "s" | "sec" => 1_000,
111 "min" => 60_000,
112 "h" | "hour" => 3_600_000,
113 _ => return Err(err()),
114 };
115 Ok(Self { limit, window_ms })
116 }
117}
118
119impl TryFrom<String> for Rate {
120 type Error = ParseError;
121
122 fn try_from(s: String) -> Result<Self, Self::Error> {
123 s.parse()
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Deserialize)]
136#[serde(try_from = "String")]
137pub struct Schedule {
138 pub segments: Vec<ScheduleSegment>,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq)]
145pub struct ScheduleSegment {
146 pub primary: SchedulePrimary,
147 pub up_to_ms: Option<u64>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq)]
153pub enum SchedulePrimary {
154 Exp {
155 base_ms: u64,
156 factor: f64,
157 cap_ms: u64,
158 jitter: bool,
159 },
160 Fixed {
161 period_ms: u64,
162 },
163}
164
165impl SchedulePrimary {
166 #[expect(
169 clippy::cast_precision_loss,
170 clippy::cast_possible_truncation,
171 clippy::cast_sign_loss,
172 clippy::cast_possible_wrap,
173 reason = "backoff arithmetic: values are small and non-negative by construction"
174 )]
175 fn wait_ms(self, attempt: u32) -> u64 {
176 match self {
177 Self::Exp {
178 base_ms,
179 factor,
180 cap_ms,
181 ..
182 } => {
183 let wait = base_ms as f64 * factor.powi(attempt as i32 - 1);
184 wait.min(cap_ms as f64).round() as u64
185 }
186 Self::Fixed { period_ms } => period_ms,
187 }
188 }
189
190 fn jitter(self) -> bool {
191 matches!(self, Self::Exp { jitter: true, .. })
192 }
193}
194
195impl Default for Schedule {
196 fn default() -> Self {
198 Self {
199 segments: vec![ScheduleSegment {
200 primary: SchedulePrimary::Exp {
201 base_ms: 200,
202 factor: 2.0,
203 cap_ms: 30_000,
204 jitter: true,
205 },
206 up_to_ms: None,
207 }],
208 }
209 }
210}
211
212impl Schedule {
213 #[must_use]
216 pub fn wait_ms(&self, attempt: u32) -> u64 {
217 self.wait_and_jitter(attempt).0
218 }
219
220 #[must_use]
232 pub fn wait_and_jitter(&self, attempt: u32) -> (u64, bool) {
233 let attempt = attempt.max(1);
234 let last = self.segments.len() - 1;
235 let (mut i, mut a, mut e) = (0_usize, 1_u32, 0_u64);
236 let mut emitted = 0_u32;
237 loop {
238 let segment = self.segments[i];
239 let wait = segment.primary.wait_ms(a);
240 if i < last
243 && let Some(bound) = segment.up_to_ms
244 && e.saturating_add(wait) > bound
245 {
246 (i, a, e) = (i + 1, 1, 0);
247 continue;
248 }
249 emitted += 1;
250 if emitted == attempt {
251 return (wait, segment.primary.jitter());
252 }
253 a += 1;
254 e = e.saturating_add(wait);
255 }
256 }
257}
258
259impl FromStr for SchedulePrimary {
260 type Err = ParseError;
261
262 fn from_str(s: &str) -> Result<Self, Self::Err> {
263 let err = || ParseError::new("schedule", s);
264 let s = s.trim();
265 if let Some(inner) = s.strip_prefix("exp(").and_then(|r| r.strip_suffix(')')) {
266 let parts: Vec<&str> = inner.split(',').map(str::trim).collect();
267 let [base, factor, rest @ ..] = parts.as_slice() else {
268 return Err(err());
269 };
270 let base_ms = base.parse::<DurationMs>().map_err(|_| err())?.0;
271 let factor: f64 = factor
272 .strip_prefix('x')
273 .ok_or_else(err)?
274 .parse()
275 .map_err(|_| err())?;
276 let mut cap_ms = u64::MAX;
277 let mut jitter = false;
278 for part in rest {
279 if let Some(d) = part.strip_prefix("max ") {
280 cap_ms = d.parse::<DurationMs>().map_err(|_| err())?.0;
281 } else if *part == "jitter" {
282 jitter = true;
283 } else {
284 return Err(err());
285 }
286 }
287 Ok(Self::Exp {
288 base_ms,
289 factor,
290 cap_ms,
291 jitter,
292 })
293 } else if let Some(inner) = s.strip_prefix("fixed(").and_then(|r| r.strip_suffix(')')) {
294 let period_ms = inner.parse::<DurationMs>().map_err(|_| err())?.0;
295 Ok(Self::Fixed { period_ms })
296 } else {
297 Err(err())
298 }
299 }
300}
301
302impl FromStr for Schedule {
303 type Err = ParseError;
304
305 fn from_str(s: &str) -> Result<Self, Self::Err> {
306 let err = || ParseError::new("schedule", s);
307 let tokens: Vec<&str> = s.split_whitespace().collect();
314 if tokens.is_empty() {
315 return Err(err());
316 }
317 let mut segments = Vec::new();
318 for segment_tokens in tokens.split(|t| *t == "andThen") {
319 let (primary_tokens, up_to_ms) = match segment_tokens.iter().position(|t| *t == "upTo")
320 {
321 None => (segment_tokens, None),
322 Some(pos) => {
323 let [duration] = &segment_tokens[pos + 1..] else {
325 return Err(err());
326 };
327 let bound = duration.parse::<DurationMs>().map_err(|_| err())?.0;
328 (&segment_tokens[..pos], Some(bound))
329 }
330 };
331 if primary_tokens.is_empty() {
332 return Err(err());
333 }
334 let primary: SchedulePrimary = primary_tokens.join(" ").parse().map_err(|_| err())?;
335 segments.push(ScheduleSegment { primary, up_to_ms });
336 }
337 let last = segments.len() - 1;
341 if segments
342 .iter()
343 .enumerate()
344 .any(|(i, segment)| (i < last) != segment.up_to_ms.is_some())
345 {
346 return Err(ParseError::with_note(
347 "schedule",
348 s,
349 "`upTo` must bound every segment except the last, and never the last \
350 (an unbounded segment never hands off; a bounded tail would leave \
351 attempts without a wait — cap total retrying with `attempts`)",
352 ));
353 }
354 Ok(Self { segments })
355 }
356}
357
358impl TryFrom<String> for Schedule {
359 type Error = ParseError;
360
361 fn try_from(s: String) -> Result<Self, Self::Error> {
362 s.parse()
363 }
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
369#[serde(try_from = "String")]
370pub enum Condition {
371 Conn,
372 Timeout,
373 Cancelled,
374 Other,
375 Class4xx,
376 Class5xx,
377 Status(u16),
378}
379
380impl Condition {
381 pub fn matches(self, class: ErrorClass, http_status: Option<u16>) -> bool {
382 match self {
383 Self::Conn => class == ErrorClass::Conn,
384 Self::Timeout => class == ErrorClass::Timeout,
385 Self::Cancelled => class == ErrorClass::Cancelled,
386 Self::Other => class == ErrorClass::Other,
387 Self::Class4xx => {
388 class == ErrorClass::Http && http_status.is_some_and(|s| (400..=499).contains(&s))
389 }
390 Self::Class5xx => {
391 class == ErrorClass::Http && http_status.is_some_and(|s| (500..=599).contains(&s))
392 }
393 Self::Status(want) => class == ErrorClass::Http && http_status == Some(want),
394 }
395 }
396}
397
398impl FromStr for Condition {
399 type Err = ParseError;
400
401 fn from_str(s: &str) -> Result<Self, Self::Err> {
402 match s {
403 "conn" => Ok(Self::Conn),
404 "timeout" => Ok(Self::Timeout),
405 "cancelled" => Ok(Self::Cancelled),
406 "other" => Ok(Self::Other),
407 "4xx" => Ok(Self::Class4xx),
408 "5xx" => Ok(Self::Class5xx),
409 exact if exact.len() == 3 && exact.bytes().all(|b| b.is_ascii_digit()) => {
413 let code: u16 = exact
414 .parse()
415 .map_err(|_| ParseError::new("retry condition", s))?;
416 if (100..=599).contains(&code) {
417 Ok(Self::Status(code))
418 } else {
419 Err(ParseError::new("retry condition", s))
420 }
421 }
422 _ => Err(ParseError::new("retry condition", s)),
423 }
424 }
425}
426
427impl TryFrom<String> for Condition {
428 type Error = ParseError;
429
430 fn try_from(s: String) -> Result<Self, Self::Error> {
431 s.parse()
432 }
433}
434
435#[derive(Debug, Clone, PartialEq, Deserialize)]
438#[serde(default, deny_unknown_fields)]
439pub struct RetryPolicy {
440 pub attempts: NonZeroU32,
441 pub schedule: Schedule,
442 pub on: Vec<Condition>,
443}
444
445impl RetryPolicy {
446 pub const DEFAULT_ATTEMPTS: NonZeroU32 = NonZeroU32::new(3).unwrap();
447
448 pub fn default_on() -> Vec<Condition> {
450 vec![
451 Condition::Conn,
452 Condition::Timeout,
453 Condition::Status(429),
454 Condition::Class5xx,
455 ]
456 }
457
458 pub fn is_retryable(&self, class: ErrorClass, http_status: Option<u16>) -> bool {
459 self.on.iter().any(|c| c.matches(class, http_status))
460 }
461}
462
463impl Default for RetryPolicy {
464 fn default() -> Self {
465 Self {
466 attempts: Self::DEFAULT_ATTEMPTS,
467 schedule: Schedule::default(),
468 on: Self::default_on(),
469 }
470 }
471}
472
473#[derive(Debug, Clone, PartialEq, Deserialize)]
488#[serde(try_from = "BreakerPolicyDe")]
489pub struct BreakerPolicy {
490 pub failures: Option<NonZeroU64>,
493 pub cooldown: DurationMs,
494 pub window: Option<DurationMs>,
495 pub failure_rate: Option<f64>,
496 pub min_calls: Option<NonZeroU32>,
497}
498
499#[derive(Debug, Clone, Copy, PartialEq)]
502pub enum BreakerMode {
503 Count { failures: NonZeroU64 },
505 Rate {
507 window: DurationMs,
508 failure_rate: f64,
509 min_calls: NonZeroU32,
510 },
511}
512
513impl BreakerPolicy {
514 pub const DEFAULT_FAILURES: NonZeroU64 = NonZeroU64::new(5).unwrap();
516 pub const DEFAULT_MIN_CALLS: NonZeroU32 = NonZeroU32::new(10).unwrap();
518
519 #[must_use]
524 pub fn mode(&self) -> BreakerMode {
525 match (self.failures, self.window, self.failure_rate) {
526 (None, Some(window), Some(failure_rate)) => BreakerMode::Rate {
527 window,
528 failure_rate,
529 min_calls: self.min_calls.unwrap_or(Self::DEFAULT_MIN_CALLS),
530 },
531 (failures, _, _) => BreakerMode::Count {
532 failures: failures.unwrap_or(Self::DEFAULT_FAILURES),
533 },
534 }
535 }
536
537 #[must_use]
541 pub fn has_inert_rate_knobs(&self) -> bool {
542 self.failures.is_some()
543 && (self.window.is_some() || self.failure_rate.is_some() || self.min_calls.is_some())
544 }
545}
546
547#[derive(Deserialize)]
550#[serde(default, deny_unknown_fields)]
551struct BreakerPolicyDe {
552 failures: Option<NonZeroU64>,
553 cooldown: DurationMs,
554 window: Option<DurationMs>,
555 #[serde(deserialize_with = "de_failure_rate")]
556 failure_rate: Option<f64>,
557 min_calls: Option<NonZeroU32>,
558}
559
560impl Default for BreakerPolicyDe {
561 fn default() -> Self {
562 Self {
563 failures: None,
564 cooldown: DurationMs(15_000),
565 window: None,
566 failure_rate: None,
567 min_calls: None,
568 }
569 }
570}
571
572impl TryFrom<BreakerPolicyDe> for BreakerPolicy {
573 type Error = String;
574
575 fn try_from(de: BreakerPolicyDe) -> Result<Self, Self::Error> {
576 let rate_pair = de.window.is_some() && de.failure_rate.is_some();
577 let any_rate_knob =
578 de.window.is_some() || de.failure_rate.is_some() || de.min_calls.is_some();
579 if de.failures.is_none() && any_rate_knob && !rate_pair {
580 return Err(String::from(
581 "breaker rate mode requires both `window` and `failure_rate` \
582 (count mode sets `failures` instead)",
583 ));
584 }
585 Ok(Self {
586 failures: de.failures,
587 cooldown: de.cooldown,
588 window: de.window,
589 failure_rate: de.failure_rate,
590 min_calls: de.min_calls,
591 })
592 }
593}
594
595fn de_failure_rate<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
600where
601 D: serde::Deserializer<'de>,
602{
603 let value = Option::<f64>::deserialize(deserializer)?;
604 if let Some(rate) = value
605 && !(rate > 0.0 && rate <= 1.0)
606 {
607 return Err(serde::de::Error::custom(format!(
608 "breaker.failure_rate must be greater than 0 and at most 1 (got {rate})"
609 )));
610 }
611 Ok(value)
612}
613
614impl Default for BreakerPolicy {
615 fn default() -> Self {
616 Self {
617 failures: None,
618 cooldown: DurationMs(15_000),
619 window: None,
620 failure_rate: None,
621 min_calls: None,
622 }
623 }
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
627#[serde(rename_all = "snake_case")]
628pub enum CacheScope {
629 #[default]
630 Memory,
631 Persistent,
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
635#[serde(rename_all = "snake_case")]
636pub enum CacheMode {
637 #[default]
638 Always,
639 Dev,
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
644#[serde(rename_all = "snake_case")]
645pub enum CacheKeySource {
646 #[default]
647 Args,
648 Url,
649}
650
651#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
653#[serde(default, deny_unknown_fields)]
654pub struct CachePolicy {
655 pub ttl: Option<DurationMs>,
656 pub scope: CacheScope,
657 pub mode: CacheMode,
658 pub key: CacheKeySource,
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
663#[serde(deny_unknown_fields)]
664pub struct IdempotencyPolicy {
665 pub header: String,
666}
667
668#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
672#[serde(try_from = "PollUntilRaw")]
673pub struct PollUntil {
674 pub field: String,
675 pub terminal: Vec<String>,
676}
677
678#[derive(Deserialize)]
679#[serde(deny_unknown_fields)]
680struct PollUntilRaw {
681 field: String,
682 terminal: Vec<String>,
683}
684
685impl TryFrom<PollUntilRaw> for PollUntil {
686 type Error = ParseError;
687
688 fn try_from(raw: PollUntilRaw) -> Result<Self, Self::Error> {
689 if raw.field.is_empty() {
690 return Err(ParseError::new("poll until.field", "(empty)"));
691 }
692 if raw.terminal.is_empty() {
693 return Err(ParseError::new("poll until.terminal", "(empty array)"));
694 }
695 Ok(Self {
696 field: raw.field,
697 terminal: raw.terminal,
698 })
699 }
700}
701
702#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
708#[serde(try_from = "PollPolicyRaw")]
709pub struct PollPolicy {
710 pub interval: DurationMs,
711 pub deadline: DurationMs,
712 pub until: PollUntil,
713}
714
715#[derive(Deserialize)]
716#[serde(deny_unknown_fields)]
717struct PollPolicyRaw {
718 interval: DurationMs,
719 deadline: DurationMs,
720 until: PollUntil,
721}
722
723impl TryFrom<PollPolicyRaw> for PollPolicy {
724 type Error = ParseError;
725
726 fn try_from(raw: PollPolicyRaw) -> Result<Self, Self::Error> {
727 if raw.interval.0 == 0 {
728 return Err(ParseError::with_note(
729 "poll.interval",
730 "0",
731 "must be a nonzero duration",
732 ));
733 }
734 Ok(Self {
735 interval: raw.interval,
736 deadline: raw.deadline,
737 until: raw.until,
738 })
739 }
740}
741
742#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
745#[serde(default, deny_unknown_fields)]
746pub struct TargetPolicy {
747 pub timeout: Option<DurationMs>,
748 pub retry: Option<RetryPolicy>,
749 pub breaker: Option<BreakerPolicy>,
750 pub rate: Option<Rate>,
751 pub cache: Option<CachePolicy>,
752 pub idempotency: Option<IdempotencyPolicy>,
753 pub poll: Option<PollPolicy>,
754 pub fallback: Option<Vec<String>>,
755 pub budget: Option<String>,
756}
757
758#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
759#[serde(default, deny_unknown_fields)]
760pub struct Defaults {
761 pub outbound: Option<TargetPolicy>,
762 pub llm: Option<TargetPolicy>,
763}
764
765#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
766#[serde(rename_all = "snake_case")]
767pub enum NondeterminismResponse {
768 #[default]
769 Fail,
770 Warn,
771 Branch,
772}
773
774#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
777#[serde(rename_all = "snake_case")]
778pub enum OnBusy {
779 #[default]
780 Skip,
781 Wait,
782 Fail,
783}
784
785#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
792#[serde(deny_unknown_fields)]
793pub struct FlowMatchRule {
794 pub argv: Vec<String>,
795}
796
797#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
799#[serde(default, deny_unknown_fields)]
800pub struct FlowsPolicy {
801 pub entrypoints: Vec<String>,
802 pub on_nondeterminism: NondeterminismResponse,
803 pub on_busy: OnBusy,
804 #[serde(rename = "match")]
811 pub match_: Option<BTreeMap<String, FlowMatchRule>>,
812}
813
814#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
821#[serde(try_from = "String")]
822pub struct JournalLocation(pub String);
823
824impl FromStr for JournalLocation {
825 type Err = ParseError;
826
827 fn from_str(s: &str) -> Result<Self, Self::Err> {
828 let valid = s.strip_prefix("file:").is_some_and(|rest| !rest.is_empty())
829 || s.strip_prefix("postgres://")
830 .is_some_and(|rest| !rest.is_empty());
831 if valid {
832 Ok(Self(s.to_owned()))
833 } else {
834 Err(ParseError::new("journal location", s))
835 }
836 }
837}
838
839impl TryFrom<String> for JournalLocation {
840 type Error = ParseError;
841
842 fn try_from(s: String) -> Result<Self, Self::Error> {
843 s.parse()
844 }
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
856#[serde(default, deny_unknown_fields)]
857pub struct TelemetryPolicy {
858 pub otlp_endpoint: Option<String>,
859 pub console: bool,
860}
861
862impl Default for TelemetryPolicy {
863 fn default() -> Self {
864 Self {
866 otlp_endpoint: None,
867 console: true,
868 }
869 }
870}
871
872#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
881#[serde(default, deny_unknown_fields)]
882pub struct Policy {
883 pub defaults: Defaults,
884 pub target: BTreeMap<String, TargetPolicy>,
885 pub flows: Option<FlowsPolicy>,
886 pub journal: Option<JournalLocation>,
889 pub telemetry: Option<TelemetryPolicy>,
893}
894
895#[derive(Debug, Clone, Default)]
898pub struct ResolvedPolicy {
899 pub timeout: Option<DurationMs>,
900 pub retry: Option<RetryPolicy>,
901 pub breaker: Option<BreakerPolicy>,
902 pub rate: Option<Rate>,
903 pub cache: Option<CachePolicy>,
904 pub idempotency: Option<IdempotencyPolicy>,
909 pub poll: Option<PollPolicy>,
911}
912
913impl Policy {
914 pub fn resolve(&self, target: &str) -> ResolvedPolicy {
915 ResolvedPolicy {
916 timeout: self.layer(target, |t| t.timeout.as_ref()).copied(),
917 retry: self.layer(target, |t| t.retry.as_ref()).cloned(),
918 breaker: self.layer(target, |t| t.breaker.as_ref()).cloned(),
919 rate: self.layer(target, |t| t.rate.as_ref()).copied(),
920 cache: self.layer(target, |t| t.cache.as_ref()).cloned(),
921 idempotency: self.layer(target, |t| t.idempotency.as_ref()).cloned(),
922 poll: self.layer(target, |t| t.poll.as_ref()).cloned(),
923 }
924 }
925
926 fn layer<'a, T>(
927 &'a self,
928 target: &str,
929 pick: impl Fn(&'a TargetPolicy) -> Option<&'a T>,
930 ) -> Option<&'a T> {
931 if let Some(t) = self.target.get(target)
932 && let Some(v) = pick(t)
933 {
934 return Some(v);
935 }
936 if target.starts_with("llm:")
937 && let Some(llm) = self.defaults.llm.as_ref()
938 && let Some(v) = pick(llm)
939 {
940 return Some(v);
941 }
942 self.defaults.outbound.as_ref().and_then(pick)
943 }
944}
945
946const OUTBOUND_METHODS: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
970
971const CLASS_PREFIXES: [&str; 6] = ["py:", "ts:", "rs:", "llm:", "tool:", "mcp:"];
973
974const VERTEX_REGIONAL_SUFFIX: &str = "-aiplatform.googleapis.com";
978
979const LLM_HOST_PROVIDERS: &[(&str, &str)] = &[
984 ("api.openai.com", "openai"),
985 ("api.anthropic.com", "anthropic"),
986 ("generativelanguage.googleapis.com", "google-genai"),
987 ("aiplatform.googleapis.com", "google-genai"),
988];
989
990fn scheme_port(scheme: Option<&str>) -> Option<u16> {
993 match scheme {
994 Some("http") => Some(80),
995 Some("https") => Some(443),
996 _ => None,
997 }
998}
999
1000fn glob_match(pattern: &[u8], text: &[u8]) -> bool {
1008 let (mut p, mut t) = (0usize, 0usize);
1009 let (mut star, mut mark) = (None::<usize>, 0usize);
1010 while t < text.len() {
1011 if p < pattern.len() && pattern[p] == b'*' {
1012 star = Some(p);
1013 mark = t;
1014 p += 1;
1015 } else if p < pattern.len() && pattern[p] == text[t] {
1016 p += 1;
1017 t += 1;
1018 } else if let Some(sp) = star {
1019 p = sp + 1;
1020 mark += 1;
1021 t = mark;
1022 } else {
1023 return false;
1024 }
1025 }
1026 while p < pattern.len() && pattern[p] == b'*' {
1027 p += 1;
1028 }
1029 p == pattern.len()
1030}
1031
1032#[allow(clippy::type_complexity)]
1038fn parse_outbound_key(key: &str) -> Option<(Option<String>, String, Option<u16>, Option<String>)> {
1039 let mut method: Option<String> = None;
1040 let mut rest = key;
1041 for m in OUTBOUND_METHODS {
1042 if let Some(stripped) = rest.strip_prefix(m).and_then(|s| s.strip_prefix(' ')) {
1043 method = Some(m.to_owned());
1044 rest = stripped;
1045 break;
1046 }
1047 }
1048 let mut path: Option<String> = None;
1049 if let Some(slash) = rest.find('/') {
1050 path = Some(rest[slash..].to_owned());
1051 rest = &rest[..slash];
1052 }
1053 let (mut host, mut port) = (rest.to_owned(), None);
1054 if let Some((head, tail)) = rest.rsplit_once(':')
1055 && !tail.is_empty()
1056 && tail.bytes().all(|b| b.is_ascii_digit())
1057 && let Ok(n) = tail.parse::<u16>()
1058 {
1059 head.clone_into(&mut host);
1060 port = Some(n);
1061 }
1062 if host.is_empty() {
1063 return None;
1064 }
1065 Some((method, host, port, path))
1066}
1067
1068fn is_bare_host_key(key: &str) -> bool {
1074 !key.contains('*')
1075 && parse_outbound_key(key)
1076 .is_some_and(|(m, _, port, path)| m.is_none() && port.is_none() && path.is_none())
1077}
1078
1079struct OutboundPattern {
1082 key: String,
1083 method: Option<String>,
1084 host_glob: String, port: Option<u16>,
1086 path_glob: Option<String>,
1087 wildcards: usize,
1088 literal: usize,
1089}
1090
1091impl Policy {
1092 #[must_use]
1095 pub fn resolve_target(
1096 &self,
1097 method: &str,
1098 host: &str,
1099 scheme: Option<&str>,
1100 port: Option<u16>,
1101 path: Option<&str>,
1102 ) -> String {
1103 let provider = LLM_HOST_PROVIDERS
1105 .iter()
1106 .find(|(h, _)| *h == host)
1107 .map(|(_, p)| *p)
1108 .or_else(|| {
1109 host.ends_with(VERTEX_REGIONAL_SUFFIX)
1110 .then_some("google-genai")
1111 });
1112 if let Some(p) = provider {
1113 return format!("llm:{p}");
1114 }
1115 if !CLASS_PREFIXES.iter().any(|c| host.starts_with(c))
1117 && self.target.contains_key(host)
1118 && is_bare_host_key(host)
1119 {
1120 return host.to_owned();
1121 }
1122 let mut patterns: Vec<OutboundPattern> = Vec::new();
1124 for key in self.target.keys() {
1125 if CLASS_PREFIXES.iter().any(|c| key.starts_with(c)) || is_bare_host_key(key) {
1126 continue;
1127 }
1128 let Some((m, h, pt, pa)) = parse_outbound_key(key) else {
1129 continue;
1130 };
1131 let wildcards = key.matches('*').count();
1132 patterns.push(OutboundPattern {
1133 key: key.clone(),
1134 method: m,
1135 host_glob: h.to_lowercase(),
1136 port: pt,
1137 path_glob: pa,
1138 wildcards,
1139 literal: key.chars().count() - wildcards,
1140 });
1141 }
1142 patterns.sort_by(|a, b| {
1145 a.wildcards
1146 .cmp(&b.wildcards)
1147 .then(b.literal.cmp(&a.literal))
1148 .then(a.method.is_none().cmp(&b.method.is_none()))
1149 .then(a.key.cmp(&b.key))
1150 });
1151 let effective_port = port.or_else(|| scheme_port(scheme));
1152 let host_l = host.to_lowercase();
1153 let method_u = if method.is_empty() {
1154 "GET".to_owned()
1155 } else {
1156 method.to_uppercase()
1157 };
1158 let path_n = match path {
1159 Some(p) if !p.is_empty() => p,
1160 _ => "/",
1161 };
1162 for p in &patterns {
1163 if let Some(m) = &p.method
1164 && *m != method_u
1165 {
1166 continue;
1167 }
1168 if !glob_match(p.host_glob.as_bytes(), host_l.as_bytes()) {
1169 continue;
1170 }
1171 if let Some(pt) = p.port
1172 && Some(pt) != effective_port
1173 {
1174 continue;
1175 }
1176 if let Some(pg) = &p.path_glob
1177 && !glob_match(pg.as_bytes(), path_n.as_bytes())
1178 {
1179 continue;
1180 }
1181 return p.key.clone();
1182 }
1183 host.to_owned()
1185 }
1186
1187 #[must_use]
1197 pub fn known_llm_hosts() -> Vec<(&'static str, &'static str)> {
1198 LLM_HOST_PROVIDERS.to_vec()
1199 }
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204 use super::*;
1205 use serde_json::json;
1206
1207 #[test]
1208 fn duration_literals() {
1209 assert_eq!("200ms".parse(), Ok(DurationMs(200)));
1210 assert_eq!("30s".parse(), Ok(DurationMs(30_000)));
1211 assert_eq!("5m".parse(), Ok(DurationMs(300_000)));
1212 assert_eq!("2h".parse(), Ok(DurationMs(7_200_000)));
1213 assert!("30".parse::<DurationMs>().is_err());
1214 assert!("30sec".parse::<DurationMs>().is_err());
1215 assert!("-1s".parse::<DurationMs>().is_err());
1216 }
1217
1218 #[test]
1219 fn rate_literals() {
1220 let rate: Rate = "90/s".parse().unwrap();
1221 assert_eq!((rate.limit.get(), rate.window_ms), (90, 1_000));
1222 let rate: Rate = "60/min".parse().unwrap();
1223 assert_eq!((rate.limit.get(), rate.window_ms), (60, 60_000));
1224 assert!("0/s".parse::<Rate>().is_err(), "zero limit unrepresentable");
1225 assert!("10/day".parse::<Rate>().is_err());
1226 }
1227
1228 #[test]
1229 fn schedule_exp_waits_and_cap() {
1230 let schedule: Schedule = "exp(1s, x2, max 4s)".parse().unwrap();
1231 let waits: Vec<u64> = (1..=4).map(|n| schedule.wait_ms(n)).collect();
1232 assert_eq!(waits, [1_000, 2_000, 4_000, 4_000]);
1233 }
1234
1235 #[test]
1236 fn schedule_fixed_and_rejections() {
1237 assert_eq!(
1238 "fixed(1s)".parse::<Schedule>(),
1239 Ok(Schedule {
1240 segments: vec![ScheduleSegment {
1241 primary: SchedulePrimary::Fixed { period_ms: 1_000 },
1242 up_to_ms: None,
1243 }],
1244 })
1245 );
1246 assert!("linear(1s)".parse::<Schedule>().is_err());
1247 }
1248
1249 #[test]
1250 fn schedule_composition_parses_the_spec_example() {
1251 let schedule: Schedule = "exp(1s, x2, max 5m) upTo 10m andThen fixed(1m)"
1253 .parse()
1254 .unwrap();
1255 assert_eq!(
1256 schedule.segments,
1257 vec![
1258 ScheduleSegment {
1259 primary: SchedulePrimary::Exp {
1260 base_ms: 1_000,
1261 factor: 2.0,
1262 cap_ms: 300_000,
1263 jitter: false,
1264 },
1265 up_to_ms: Some(600_000),
1266 },
1267 ScheduleSegment {
1268 primary: SchedulePrimary::Fixed { period_ms: 60_000 },
1269 up_to_ms: None,
1270 },
1271 ]
1272 );
1273 assert_eq!(
1275 "exp(1s, x2, max 5m) upTo 10m andThen fixed(1m)".parse::<Schedule>(),
1276 Ok(schedule)
1277 );
1278 }
1279
1280 #[test]
1281 fn schedule_composition_hands_off_when_the_bound_would_be_overshot() {
1282 let schedule: Schedule = "exp(1s, x2) upTo 4s andThen fixed(500ms)".parse().unwrap();
1283 let waits: Vec<u64> = (1..=5).map(|n| schedule.wait_ms(n)).collect();
1284 assert_eq!(waits, [1_000, 2_000, 500, 500, 500]);
1286 }
1287
1288 #[test]
1289 fn schedule_composition_exact_fit_stays_and_cascade_skips() {
1290 let schedule: Schedule =
1291 "fixed(1s) upTo 3s andThen fixed(10s) upTo 5s andThen fixed(250ms)"
1292 .parse()
1293 .unwrap();
1294 let waits: Vec<u64> = (1..=6).map(|n| schedule.wait_ms(n)).collect();
1295 assert_eq!(waits, [1_000, 1_000, 1_000, 250, 250, 250]);
1299 }
1300
1301 #[test]
1302 fn schedule_composition_restarts_exp_and_tracks_jitter_per_segment() {
1303 let schedule: Schedule = "fixed(1s) upTo 2s andThen exp(100ms, x3, jitter)"
1304 .parse()
1305 .unwrap();
1306 let waits: Vec<u64> = (1..=5).map(|n| schedule.wait_ms(n)).collect();
1308 assert_eq!(waits, [1_000, 1_000, 100, 300, 900]);
1309 assert_eq!(schedule.wait_and_jitter(1), (1_000, false));
1311 assert_eq!(schedule.wait_and_jitter(3), (100, true));
1312 }
1313
1314 #[test]
1315 fn schedule_composition_shape_rule_rejections() {
1316 for degenerate in [
1321 "fixed(1s) andThen fixed(2s)",
1322 "exp(1s, x2, max 5m) upTo 10m",
1323 "fixed(1s) upTo 3s andThen fixed(2s) andThen fixed(4s)",
1324 "fixed(1s) upTo 3s andThen fixed(2s) upTo 5s",
1325 ] {
1326 let error = degenerate.parse::<Schedule>().unwrap_err();
1327 assert!(
1328 error.to_string().contains("upTo"),
1329 "{degenerate}: expected the shape-rule note, got {error}"
1330 );
1331 }
1332 for broken in [
1334 "fixed(1s) upTo",
1335 "upTo 3s andThen fixed(1s)",
1336 "fixed(1s) upTo 1s upTo 2s andThen fixed(1s)",
1337 "fixed(1s) andThen",
1338 "andThen fixed(1s)",
1339 "fixed(1s) upTo 3s fixed(2s)",
1340 ] {
1341 assert!(
1342 broken.parse::<Schedule>().is_err(),
1343 "{broken} must be rejected"
1344 );
1345 }
1346 }
1347
1348 #[test]
1349 fn condition_matching() {
1350 let on = RetryPolicy::default_on();
1351 let matches = |class, status| on.iter().any(|c| c.matches(class, status));
1352 assert!(matches(ErrorClass::Conn, None));
1353 assert!(matches(ErrorClass::Http, Some(429)));
1354 assert!(matches(ErrorClass::Http, Some(503)));
1355 assert!(!matches(ErrorClass::Http, Some(400)));
1356 assert!(!matches(ErrorClass::Cancelled, None));
1357 assert!("teapot".parse::<Condition>().is_err());
1358 assert_eq!("429".parse::<Condition>(), Ok(Condition::Status(429)));
1360 assert_eq!("100".parse::<Condition>(), Ok(Condition::Status(100)));
1361 assert_eq!("599".parse::<Condition>(), Ok(Condition::Status(599)));
1362 for bad in ["999", "099", "600", "000", "12", "1234", "1x9"] {
1363 assert!(bad.parse::<Condition>().is_err(), "{bad} must be rejected");
1364 }
1365 }
1366
1367 #[test]
1368 fn zero_attempts_is_unrepresentable() {
1369 let doc = json!({ "target": { "x": { "retry": { "attempts": 0 } } } });
1370 let err = serde_path_to_error::deserialize::<_, Policy>(&doc).unwrap_err();
1371 assert_eq!(err.path().to_string(), "target.x.retry.attempts");
1372 }
1373
1374 #[test]
1375 fn breaker_failure_rate_range_is_enforced() {
1376 let bad = |rate: serde_json::Value| {
1378 let doc = json!({ "target": { "x": { "breaker": { "failure_rate": rate } } } });
1379 serde_path_to_error::deserialize::<_, Policy>(&doc)
1380 };
1381 for rate in [json!(0.0), json!(-0.1), json!(1.5), json!(2.0)] {
1382 let err = bad(rate.clone()).unwrap_err();
1383 assert_eq!(
1384 err.path().to_string(),
1385 "target.x.breaker.failure_rate",
1386 "out-of-range failure_rate {rate} must fail at its path"
1387 );
1388 }
1389 for rate in [0.01_f64, 0.5, 1.0] {
1392 let doc = json!({
1393 "target": { "x": { "breaker": { "window": "30s", "failure_rate": rate } } }
1394 });
1395 let policy = serde_path_to_error::deserialize::<_, Policy>(&doc).unwrap();
1396 let breaker = policy.target["x"].breaker.as_ref().unwrap();
1397 assert_eq!(breaker.failure_rate, Some(rate));
1398 }
1399 }
1400
1401 #[test]
1402 fn breaker_mode_selection_follows_the_schema() {
1403 let breaker = |doc: serde_json::Value| -> BreakerPolicy {
1404 let doc = json!({ "target": { "x": { "breaker": doc } } });
1405 let policy: Policy = serde_path_to_error::deserialize(&doc).unwrap();
1406 policy.target["x"].breaker.clone().unwrap()
1407 };
1408
1409 assert_eq!(
1411 breaker(json!({})).mode(),
1412 BreakerMode::Count {
1413 failures: BreakerPolicy::DEFAULT_FAILURES
1414 }
1415 );
1416
1417 assert_eq!(
1419 breaker(json!({ "window": "30s", "failure_rate": 0.5 })).mode(),
1420 BreakerMode::Rate {
1421 window: DurationMs(30_000),
1422 failure_rate: 0.5,
1423 min_calls: BreakerPolicy::DEFAULT_MIN_CALLS,
1424 }
1425 );
1426 assert_eq!(
1427 breaker(json!({ "window": "10s", "failure_rate": 1.0, "min_calls": 4 })).mode(),
1428 BreakerMode::Rate {
1429 window: DurationMs(10_000),
1430 failure_rate: 1.0,
1431 min_calls: NonZeroU32::new(4).unwrap(),
1432 }
1433 );
1434
1435 let mixed = breaker(json!({ "failures": 3, "window": "30s", "failure_rate": 0.5 }));
1438 assert_eq!(
1439 mixed.mode(),
1440 BreakerMode::Count {
1441 failures: NonZeroU64::new(3).unwrap()
1442 }
1443 );
1444 assert!(mixed.has_inert_rate_knobs());
1445 assert!(!breaker(json!({ "failures": 3 })).has_inert_rate_knobs());
1446 }
1447
1448 #[test]
1449 fn half_configured_breaker_rate_mode_is_rejected() {
1450 for doc in [
1454 json!({ "window": "30s" }),
1455 json!({ "failure_rate": 0.5 }),
1456 json!({ "min_calls": 10 }),
1457 json!({ "window": "30s", "min_calls": 10 }),
1458 json!({ "failure_rate": 0.5, "min_calls": 10 }),
1459 ] {
1460 let policy = json!({ "target": { "x": { "breaker": doc } } });
1461 let err = serde_path_to_error::deserialize::<_, Policy>(&policy).unwrap_err();
1462 assert_eq!(err.path().to_string(), "target.x.breaker", "doc: {doc}");
1463 assert!(
1464 err.inner().to_string().contains("rate mode requires both"),
1465 "doc {doc}: got {}",
1466 err.inner()
1467 );
1468 }
1469 let policy =
1472 json!({ "target": { "x": { "breaker": { "failures": 3, "window": "30s" } } } });
1473 assert!(serde_path_to_error::deserialize::<_, Policy>(&policy).is_ok());
1474 }
1475
1476 #[test]
1477 fn unknown_key_is_rejected_with_its_path() {
1478 let doc = json!({ "target": { "api.stripe.com": { "retry": { "atempts": 10 } } } });
1482 let err = serde_path_to_error::deserialize::<_, Policy>(&doc).unwrap_err();
1483 assert!(
1484 err.inner().to_string().contains("atempts")
1485 || err.inner().to_string().contains("unknown field"),
1486 "expected an unknown-field error, got {}",
1487 err.inner()
1488 );
1489 }
1490
1491 #[test]
1492 fn unknown_top_level_and_layer_keys_are_rejected() {
1493 assert!(
1494 serde_path_to_error::deserialize::<_, Policy>(&json!({ "bogus_top": true })).is_err()
1495 );
1496 assert!(
1497 serde_path_to_error::deserialize::<_, Policy>(
1498 &json!({ "target": { "api.x": { "retrys": {} } } })
1499 )
1500 .is_err(),
1501 "a mistyped layer table must be rejected, not dropped"
1502 );
1503 }
1504
1505 #[test]
1506 fn journal_and_telemetry_parse_and_validate() {
1507 let doc = json!({
1508 "journal": "file:/srv/keel/journal.db",
1509 "telemetry": { "otlp_endpoint": "http://collector:4317" }
1510 });
1511 let policy: Policy = serde_path_to_error::deserialize(&doc).unwrap();
1512 assert_eq!(
1513 policy.journal.unwrap(),
1514 JournalLocation("file:/srv/keel/journal.db".to_owned())
1515 );
1516 let telemetry = policy.telemetry.unwrap();
1517 assert_eq!(
1518 telemetry.otlp_endpoint.as_deref(),
1519 Some("http://collector:4317")
1520 );
1521 assert!(telemetry.console, "schema default console = true");
1522
1523 let bad = json!({ "journal": "sqlite:/tmp/x.db" });
1525 assert!(serde_path_to_error::deserialize::<_, Policy>(&bad).is_err());
1526 }
1527
1528 #[test]
1529 fn idempotency_resolves_like_any_other_layer() {
1530 let doc = json!({
1534 "defaults": {
1535 "outbound": { "idempotency": { "header": "X-Idem" } },
1536 "llm": { "idempotency": { "header": "X-Llm-Idem" } }
1537 },
1538 "target": {
1539 "api.stripe.com": { "idempotency": { "header": "Idempotency-Key" } },
1540 "api.plain.example": { "timeout": "1s" }
1541 }
1542 });
1543 let policy: Policy = serde_path_to_error::deserialize(&doc).unwrap();
1544
1545 let stripe = policy.resolve("api.stripe.com");
1547 assert_eq!(
1548 stripe.idempotency.as_ref().map(|i| i.header.as_str()),
1549 Some("Idempotency-Key")
1550 );
1551 let llm = policy.resolve("llm:openai");
1553 assert_eq!(
1554 llm.idempotency.as_ref().map(|i| i.header.as_str()),
1555 Some("X-Llm-Idem")
1556 );
1557 let plain = policy.resolve("api.plain.example");
1558 assert_eq!(
1559 plain.idempotency.as_ref().map(|i| i.header.as_str()),
1560 Some("X-Idem")
1561 );
1562 let empty: Policy = serde_path_to_error::deserialize(&json!({})).unwrap();
1564 assert!(empty.resolve("api.stripe.com").idempotency.is_none());
1565 }
1566
1567 #[test]
1568 fn poll_policy_parses_and_resolves() {
1569 let policy: Policy = serde_json::from_value(serde_json::json!({
1570 "target": { "api.jobs.example": { "poll": {
1571 "interval": "10s", "deadline": "90s",
1572 "until": { "field": "status", "terminal": ["completed", "failed"] }
1573 } } }
1574 }))
1575 .expect("valid poll policy");
1576 let resolved = policy.resolve("api.jobs.example");
1577 let poll = resolved.poll.expect("poll resolved");
1578 assert_eq!(poll.interval.0, 10_000);
1579 assert_eq!(poll.deadline.0, 90_000);
1580 assert_eq!(poll.until.field, "status");
1581 assert_eq!(poll.until.terminal, vec!["completed", "failed"]);
1582 }
1583
1584 #[test]
1585 fn poll_rejects_empty_terminal_and_empty_field() {
1586 for bad in [
1587 serde_json::json!({ "interval": "10s", "deadline": "90s",
1588 "until": { "field": "status", "terminal": [] } }),
1589 serde_json::json!({ "interval": "10s", "deadline": "90s",
1590 "until": { "field": "", "terminal": ["done"] } }),
1591 serde_json::json!({ "interval": "10s",
1592 "until": { "field": "status", "terminal": ["done"] } }),
1593 ] {
1594 let doc = serde_json::json!({ "target": { "x": { "poll": bad } } });
1595 assert!(serde_json::from_value::<Policy>(doc).is_err());
1596 }
1597 }
1598
1599 #[test]
1600 fn poll_rejects_zero_interval() {
1601 let doc = serde_json::json!({ "target": { "x": { "poll": {
1602 "interval": "0ms", "deadline": "90s",
1603 "until": { "field": "status", "terminal": ["done"] }
1604 } } } });
1605 assert!(serde_json::from_value::<Policy>(doc).is_err());
1606
1607 let doc = serde_json::json!({ "target": { "x": { "poll": {
1608 "interval": "1ms", "deadline": "90s",
1609 "until": { "field": "status", "terminal": ["done"] }
1610 } } } });
1611 assert!(serde_json::from_value::<Policy>(doc).is_ok());
1612 }
1613
1614 #[test]
1615 fn layer_resolution_precedence() {
1616 let doc = json!({
1617 "defaults": {
1618 "outbound": { "retry": { "attempts": 3 }, "rate": "9/s" },
1619 "llm": { "retry": { "attempts": 6 } }
1620 },
1621 "target": { "llm:openai": { "cache": { "ttl": "10m" } } }
1622 });
1623 let policy: Policy = serde_path_to_error::deserialize(&doc).unwrap();
1624
1625 let llm = policy.resolve("llm:openai");
1628 assert_eq!(llm.cache.unwrap().ttl, Some(DurationMs(600_000)));
1629 assert_eq!(llm.retry.unwrap().attempts.get(), 6);
1630 assert_eq!(llm.rate.unwrap().limit.get(), 9);
1631
1632 let plain = policy.resolve("api.example.com");
1634 assert_eq!(plain.retry.unwrap().attempts.get(), 3);
1635 assert!(plain.cache.is_none());
1636 }
1637
1638 #[test]
1639 fn flows_on_busy_parses_with_skip_default() {
1640 let p: Policy = serde_json::from_value(serde_json::json!({
1641 "flows": { "entrypoints": ["cmd:autonomous-run"], "on_busy": "wait" }
1642 }))
1643 .unwrap();
1644 assert_eq!(p.flows.as_ref().unwrap().on_busy, OnBusy::Wait);
1645 let p: Policy = serde_json::from_value(serde_json::json!({ "flows": {} })).unwrap();
1646 assert_eq!(p.flows.unwrap().on_busy, OnBusy::Skip);
1647 }
1648
1649 #[test]
1650 fn flows_match_cmd_rules_parse() {
1651 let p: Policy = serde_json::from_value(serde_json::json!({
1654 "flows": {
1655 "entrypoints": ["cmd:nightly-etl"],
1656 "match": { "cmd:nightly-etl": { "argv": ["*/run_etl.sh", "--env=prod"] } }
1657 }
1658 }))
1659 .unwrap();
1660 let flows = p.flows.unwrap();
1661 let rules = flows.match_.expect("match table carried");
1662 assert_eq!(
1663 rules["cmd:nightly-etl"].argv,
1664 vec!["*/run_etl.sh".to_owned(), "--env=prod".to_owned()]
1665 );
1666 let p: Policy = serde_json::from_value(serde_json::json!({ "flows": {} })).unwrap();
1668 assert!(p.flows.unwrap().match_.is_none());
1669 assert!(
1671 serde_json::from_value::<Policy>(serde_json::json!({
1672 "flows": { "match": { "cmd:x": { "argv": ["a"], "bogus": 1 } } }
1673 }))
1674 .is_err()
1675 );
1676 }
1677}
1678
1679#[cfg(test)]
1680mod resolve_target_tests {
1681 use super::*;
1682
1683 fn policy(keys: &[&str]) -> Policy {
1684 let mut p = Policy::default();
1685 for k in keys {
1686 p.target.insert((*k).to_owned(), TargetPolicy::default());
1687 }
1688 p
1689 }
1690
1691 #[test]
1692 fn no_table_returns_bare_host() {
1693 assert_eq!(
1694 Policy::default().resolve_target("GET", "api.example.com", None, None, None),
1695 "api.example.com"
1696 );
1697 }
1698 #[test]
1699 fn exact_beats_pattern() {
1700 let p = policy(&["api.example.com", "*.example.com"]);
1701 assert_eq!(
1702 p.resolve_target("GET", "api.example.com", None, None, None),
1703 "api.example.com"
1704 );
1705 }
1706 #[test]
1707 fn host_wildcard_crosses_dots() {
1708 let p = policy(&["*.internal.corp"]);
1709 assert_eq!(
1710 p.resolve_target("GET", "a.b.internal.corp", None, None, None),
1711 "*.internal.corp"
1712 );
1713 assert_eq!(
1714 p.resolve_target("GET", "internal.corp", None, None, None),
1715 "internal.corp"
1716 );
1717 }
1718 #[test]
1719 fn host_is_case_insensitive() {
1720 let p = policy(&["*.Internal.Corp"]);
1721 assert_eq!(
1722 p.resolve_target("GET", "DB.INTERNAL.CORP", None, None, None),
1723 "*.Internal.Corp"
1724 );
1725 }
1726 #[test]
1727 fn path_glob_crosses_slashes_case_sensitive() {
1728 let p = policy(&["api.catalog.internal/*"]);
1729 assert_eq!(
1730 p.resolve_target("GET", "api.catalog.internal", None, None, Some("/a/b/c")),
1731 "api.catalog.internal/*"
1732 );
1733 let p2 = policy(&["api.x/A/*"]);
1734 assert_eq!(
1735 p2.resolve_target("GET", "api.x", None, None, Some("/a/y")),
1736 "api.x"
1737 );
1738 }
1739 #[test]
1740 fn missing_path_normalizes_to_slash() {
1741 let p = policy(&["api.x/*"]);
1742 assert_eq!(
1743 p.resolve_target("GET", "api.x", None, None, None),
1744 "api.x/*"
1745 );
1746 assert_eq!(
1747 p.resolve_target("GET", "api.x", None, None, Some("")),
1748 "api.x/*"
1749 );
1750 }
1751 #[test]
1752 fn method_prefix_must_match() {
1753 let p = policy(&["POST api.example.com"]);
1754 assert_eq!(
1755 p.resolve_target("GET", "api.example.com", None, None, None),
1756 "api.example.com"
1757 );
1758 assert_eq!(
1759 p.resolve_target("POST", "api.example.com", None, None, None),
1760 "POST api.example.com"
1761 );
1762 assert_eq!(
1763 p.resolve_target("post", "api.example.com", None, None, None),
1764 "POST api.example.com"
1765 );
1766 }
1767 #[test]
1768 fn port_uses_scheme_default() {
1769 let p = policy(&["api.example.com:443"]);
1770 assert_eq!(
1771 p.resolve_target("GET", "api.example.com", Some("https"), None, None),
1772 "api.example.com:443"
1773 );
1774 assert_eq!(
1775 p.resolve_target("GET", "api.example.com", Some("http"), None, None),
1776 "api.example.com"
1777 );
1778 }
1779 #[test]
1780 fn explicit_port_overrides_scheme() {
1781 let p = policy(&["api.example.com:8443"]);
1782 assert_eq!(
1783 p.resolve_target("GET", "api.example.com", Some("https"), Some(8443), None),
1784 "api.example.com:8443"
1785 );
1786 assert_eq!(
1787 p.resolve_target("GET", "api.example.com", Some("https"), Some(443), None),
1788 "api.example.com"
1789 );
1790 }
1791 #[test]
1792 fn most_specific_by_literal_length() {
1793 let p = policy(&["*.example.com", "GET api.example.com/*"]);
1794 assert_eq!(
1795 p.resolve_target("GET", "api.example.com", None, None, Some("/v1/x")),
1796 "GET api.example.com/*"
1797 );
1798 }
1799 #[test]
1800 fn lexicographic_tie_break_is_total() {
1801 let p = policy(&["api.example.com/x/*", "api.example.com/*/y"]);
1802 assert_eq!(
1803 p.resolve_target("GET", "api.example.com", None, None, Some("/x/y")),
1804 "api.example.com/*/y"
1805 );
1806 }
1807 #[test]
1808 fn class_prefixed_keys_are_not_hosts() {
1809 let p = policy(&["py:pkg.mod.fn", "llm:openai"]);
1810 assert_eq!(
1811 p.resolve_target("GET", "py:pkg.mod.fn", None, None, None),
1812 "py:pkg.mod.fn"
1813 );
1814 }
1815 #[test]
1816 fn llm_host_map_wins_over_patterns() {
1817 let p = policy(&["*.openai.com"]);
1818 assert_eq!(
1819 p.resolve_target("POST", "api.openai.com", None, None, None),
1820 "llm:openai"
1821 );
1822 }
1823 #[test]
1824 fn vertex_regional_suffix_maps_to_google_genai() {
1825 assert_eq!(
1826 Policy::default().resolve_target(
1827 "POST",
1828 "us-central1-aiplatform.googleapis.com",
1829 None,
1830 None,
1831 None
1832 ),
1833 "llm:google-genai"
1834 );
1835 }
1836
1837 #[test]
1838 fn known_llm_hosts_matches_resolve_target_for_every_pair() {
1839 let hosts = Policy::known_llm_hosts();
1840 assert!(hosts.contains(&("api.openai.com", "openai")));
1841 assert!(hosts.contains(&("api.anthropic.com", "anthropic")));
1842 assert!(hosts.contains(&("generativelanguage.googleapis.com", "google-genai")));
1843 for (host, provider) in hosts {
1844 assert_eq!(
1845 Policy::default().resolve_target("GET", host, None, None, None),
1846 format!("llm:{provider}")
1847 );
1848 }
1849 }
1850}