1use crate::ctx::DepTerminal;
8use crate::graph::{Graph, GraphNodeOpts};
9use crate::node::Node;
10use crate::time::timeout;
11
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub enum BackoffPolicy {
15 #[default]
16 None,
18 Constant {
20 delay_ms: u64,
22 },
23 Linear {
25 initial_ms: u64,
27 step_ms: u64,
29 max_ms: Option<u64>,
31 },
32 Exponential {
34 initial_ms: u64,
36 factor: u32,
38 max_ms: Option<u64>,
40 },
41 Fibonacci {
43 unit_ms: u64,
45 max_ms: Option<u64>,
47 },
48}
49
50impl BackoffPolicy {
51 pub fn delay_ms(&self, attempt: u32) -> u64 {
53 match self {
54 BackoffPolicy::None => 0,
55 BackoffPolicy::Constant { delay_ms } => *delay_ms,
56 BackoffPolicy::Linear {
57 initial_ms,
58 step_ms,
59 max_ms,
60 } => cap(
61 initial_ms.saturating_add(step_ms.saturating_mul(attempt.saturating_sub(1) as u64)),
62 *max_ms,
63 ),
64 BackoffPolicy::Exponential {
65 initial_ms,
66 factor,
67 max_ms,
68 } => {
69 let multiplier = factor.saturating_pow(attempt.saturating_sub(1));
70 cap(initial_ms.saturating_mul(multiplier as u64), *max_ms)
71 }
72 BackoffPolicy::Fibonacci { unit_ms, max_ms } => {
73 cap(unit_ms.saturating_mul(fibonacci(attempt) as u64), *max_ms)
74 }
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct RetryPolicy {
82 pub max_attempts: u32,
84 pub backoff: BackoffPolicy,
86}
87
88impl RetryPolicy {
89 pub fn new(max_attempts: u32, backoff: BackoffPolicy) -> Self {
91 assert!(max_attempts > 0, "RetryPolicy: max_attempts must be > 0");
92 Self {
93 max_attempts,
94 backoff,
95 }
96 }
97
98 pub fn should_retry(&self, failed_attempt: u32) -> bool {
100 failed_attempt < self.max_attempts
101 }
102
103 pub fn next_delay_ms(&self, next_attempt: u32) -> Option<u64> {
105 if next_attempt == 0 || next_attempt > self.max_attempts {
106 return None;
107 }
108 Some(self.backoff.delay_ms(next_attempt))
109 }
110}
111
112impl Default for RetryPolicy {
113 fn default() -> Self {
114 Self {
115 max_attempts: 1,
116 backoff: BackoffPolicy::None,
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct RetryStatus {
124 pub attempt: u32,
126 pub max_attempts: u32,
128 pub delay_ms: Option<u64>,
130 pub state: RetryState,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum RetryState {
137 Idle,
139 Running,
141 Waiting,
143 Succeeded,
145 Failed,
147 Exhausted,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum RetryEvent {
154 Attempt {
156 attempt: u32,
158 },
159 Retry {
161 attempt: u32,
163 delay_ms: u64,
165 error: String,
167 },
168 Success {
170 attempt: u32,
172 },
173 Failure {
175 attempt: u32,
177 error: String,
179 },
180 Exhausted {
182 attempt: u32,
184 error: String,
186 },
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct BreakerStatus {
192 pub state: BreakerState,
194 pub failures: u32,
196 pub opened_at_ms: Option<u64>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum BreakerState {
203 Closed,
205 Open,
207 HalfOpen,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct RateLimitStatus {
214 pub allowed: u64,
216 pub dropped: u64,
218 pub remaining: u32,
220 pub reset_at_ms: u64,
222}
223
224pub struct RateLimitBundle<T: 'static> {
226 pub allowed: Node<T>,
228 pub dropped: Node<T>,
230 pub status: Node<RateLimitStatus>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum TimeoutStatus {
237 Running,
239 Completed,
241 Errored,
243}
244
245pub struct TimeoutBundle<T: 'static> {
247 pub node: Node<T>,
249 pub status: Node<TimeoutStatus>,
251 pub errors: Node<String>,
253}
254
255#[derive(Clone)]
256struct RateLimitEvent<T> {
257 kind: RateLimitEventKind,
258 value: T,
259 status: RateLimitStatus,
260}
261
262#[derive(Clone, Copy)]
263enum RateLimitEventKind {
264 Allowed,
265 Dropped,
266}
267
268#[derive(Clone)]
269struct RateLimitState {
270 count: u32,
271 reset_at_ms: u64,
272 allowed: u64,
273 dropped: u64,
274}
275
276pub fn retry_status_node(
278 graph: &Graph,
279 events: &Node<RetryEvent>,
280 policy: RetryPolicy,
281 name: impl Into<String>,
282) -> Node<RetryStatus> {
283 let name = name.into();
284 graph.node_opts::<RetryStatus, _>(
285 vec![events.erased()],
286 move |ctx| {
287 let mut next = RetryStatus {
288 attempt: 0,
289 max_attempts: policy.max_attempts,
290 delay_ms: None,
291 state: RetryState::Idle,
292 };
293 for event in ctx.batch::<RetryEvent>(0) {
294 next = match event.as_ref() {
295 RetryEvent::Attempt { attempt } => RetryStatus {
296 attempt: *attempt,
297 max_attempts: policy.max_attempts,
298 delay_ms: None,
299 state: RetryState::Running,
300 },
301 RetryEvent::Retry {
302 attempt, delay_ms, ..
303 } => RetryStatus {
304 attempt: *attempt,
305 max_attempts: policy.max_attempts,
306 delay_ms: Some(*delay_ms),
307 state: RetryState::Waiting,
308 },
309 RetryEvent::Success { attempt } => RetryStatus {
310 attempt: *attempt,
311 max_attempts: policy.max_attempts,
312 delay_ms: None,
313 state: RetryState::Succeeded,
314 },
315 RetryEvent::Failure { attempt, .. } => RetryStatus {
316 attempt: *attempt,
317 max_attempts: policy.max_attempts,
318 delay_ms: policy.next_delay_ms(attempt.saturating_add(1)),
319 state: if policy.should_retry(*attempt) {
320 RetryState::Failed
321 } else {
322 RetryState::Exhausted
323 },
324 },
325 RetryEvent::Exhausted { attempt, .. } => RetryStatus {
326 attempt: *attempt,
327 max_attempts: policy.max_attempts,
328 delay_ms: None,
329 state: RetryState::Exhausted,
330 },
331 };
332 }
333 ctx.emit(next);
334 },
335 GraphNodeOpts::named(format!("{name}/status")),
336 )
337}
338
339pub fn breaker_status_node(
341 graph: &Graph,
342 events: &Node<RetryEvent>,
343 failure_threshold: u32,
344 now_ms: impl Fn() -> u64 + 'static,
345 name: impl Into<String>,
346) -> Node<BreakerStatus> {
347 assert!(
348 failure_threshold > 0,
349 "breaker_status_node: failure_threshold must be > 0"
350 );
351 let name = name.into();
352 graph.node_opts::<BreakerStatus, _>(
353 vec![events.erased()],
354 move |ctx| {
355 let mut status = ctx.state_get::<BreakerStatus>().map_or(
356 BreakerStatus {
357 state: BreakerState::Closed,
358 failures: 0,
359 opened_at_ms: None,
360 },
361 |value| (*value).clone(),
362 );
363 for event in ctx.batch::<RetryEvent>(0) {
364 match event.as_ref() {
365 RetryEvent::Success { .. } => {
366 status = BreakerStatus {
367 state: BreakerState::Closed,
368 failures: 0,
369 opened_at_ms: None,
370 };
371 }
372 RetryEvent::Failure { .. } | RetryEvent::Exhausted { .. } => {
373 status.failures = status.failures.saturating_add(1);
374 if status.failures >= failure_threshold {
375 status.state = BreakerState::Open;
376 status.opened_at_ms = Some(now_ms());
377 }
378 }
379 RetryEvent::Attempt { .. } | RetryEvent::Retry { .. } => {}
380 }
381 }
382 ctx.state_set(status.clone());
383 ctx.emit(status);
384 },
385 GraphNodeOpts::named(format!("{name}/status")),
386 )
387}
388
389pub fn rate_limit_bundle<T>(
391 graph: &Graph,
392 source: &Node<T>,
393 max: u32,
394 window_ms: u64,
395 now_ms: impl Fn() -> u64 + 'static,
396 name: impl Into<String>,
397) -> RateLimitBundle<T>
398where
399 T: Clone + 'static,
400{
401 assert!(max > 0, "rate_limit_bundle: max must be > 0");
402 assert!(window_ms > 0, "rate_limit_bundle: window_ms must be > 0");
403 let name = name.into();
404 let events = graph.node_opts::<RateLimitEvent<T>, _>(
405 vec![source.erased()],
406 move |ctx| {
407 let current = now_ms();
408 let mut state = ctx.state_get::<RateLimitState>().map_or(
409 RateLimitState {
410 count: 0,
411 reset_at_ms: current.saturating_add(window_ms),
412 allowed: 0,
413 dropped: 0,
414 },
415 |value| (*value).clone(),
416 );
417 if current >= state.reset_at_ms {
418 state.count = 0;
419 state.reset_at_ms = current.saturating_add(window_ms);
420 }
421 for value in ctx.batch::<T>(0) {
422 let kind = if state.count < max {
423 state.count = state.count.saturating_add(1);
424 state.allowed = state.allowed.saturating_add(1);
425 RateLimitEventKind::Allowed
426 } else {
427 state.dropped = state.dropped.saturating_add(1);
428 RateLimitEventKind::Dropped
429 };
430 let status = RateLimitStatus {
431 allowed: state.allowed,
432 dropped: state.dropped,
433 remaining: max.saturating_sub(state.count),
434 reset_at_ms: state.reset_at_ms,
435 };
436 ctx.emit(RateLimitEvent {
437 kind,
438 value: (*value).clone(),
439 status,
440 });
441 }
442 ctx.state_set(state);
443 },
444 GraphNodeOpts::named(format!("{name}/events")),
445 );
446 let allowed = graph.node_opts::<T, _>(
447 vec![events.erased()],
448 move |ctx| {
449 for event in ctx.batch::<RateLimitEvent<T>>(0) {
450 if matches!(event.kind, RateLimitEventKind::Allowed) {
451 ctx.emit(event.value.clone());
452 }
453 }
454 },
455 GraphNodeOpts::named(format!("{name}/allowed")),
456 );
457 let dropped = graph.node_opts::<T, _>(
458 vec![events.erased()],
459 move |ctx| {
460 for event in ctx.batch::<RateLimitEvent<T>>(0) {
461 if matches!(event.kind, RateLimitEventKind::Dropped) {
462 ctx.emit(event.value.clone());
463 }
464 }
465 },
466 GraphNodeOpts::named(format!("{name}/dropped")),
467 );
468 let status = graph.node_opts::<RateLimitStatus, _>(
469 vec![events.erased()],
470 move |ctx| {
471 for event in ctx.batch::<RateLimitEvent<T>>(0) {
472 ctx.emit(event.status.clone());
473 }
474 },
475 GraphNodeOpts::named(format!("{name}/status")),
476 );
477 RateLimitBundle {
478 allowed,
479 dropped,
480 status,
481 }
482}
483
484pub fn timeout_bundle<T>(
486 graph: &Graph,
487 source: &Node<T>,
488 ms: u64,
489 name: impl Into<String>,
490) -> TimeoutBundle<T>
491where
492 T: Clone + 'static,
493{
494 let name = name.into();
495 let node = timeout(source, ms);
496 let status = graph.node_opts::<TimeoutStatus, _>(
497 vec![node.erased()],
498 move |ctx| match ctx.terminal(0) {
499 Some(DepTerminal::Complete) => ctx.emit(TimeoutStatus::Completed),
500 Some(DepTerminal::Error(_)) => ctx.emit(TimeoutStatus::Errored),
501 None => {
502 if !ctx.batch::<T>(0).is_empty() {
503 ctx.emit(TimeoutStatus::Running);
504 }
505 }
506 },
507 timeout_projection_opts(format!("{name}/status")),
508 );
509 let errors = graph.node_opts::<String, _>(
510 vec![node.erased()],
511 move |ctx| {
512 if let Some(DepTerminal::Error(error)) = ctx.terminal(0) {
513 ctx.emit(error.to_string());
514 }
515 },
516 timeout_projection_opts(format!("{name}/errors")),
517 );
518 TimeoutBundle {
519 node,
520 status,
521 errors,
522 }
523}
524
525fn timeout_projection_opts(name: String) -> GraphNodeOpts {
526 GraphNodeOpts {
527 name: Some(name),
528 node: crate::node::NodeOpts {
529 complete_when_deps_complete: false,
530 error_when_deps_error: false,
531 terminal_as_real_input: true,
532 ..crate::node::NodeOpts::default()
533 },
534 ..GraphNodeOpts::default()
535 }
536}
537
538fn cap(value: u64, max: Option<u64>) -> u64 {
539 max.map_or(value, |max| value.min(max))
540}
541
542fn fibonacci(n: u32) -> u32 {
543 match n {
544 0 => 0,
545 1 => 1,
546 _ => {
547 let mut prev = 0u32;
548 let mut curr = 1u32;
549 for _ in 1..n {
550 let next = prev.saturating_add(curr);
551 prev = curr;
552 curr = next;
553 }
554 curr
555 }
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 #[test]
564 fn backoff_policy_calculates_bounded_delays() {
565 assert_eq!(BackoffPolicy::None.delay_ms(1), 0);
566 assert_eq!(BackoffPolicy::Constant { delay_ms: 25 }.delay_ms(3), 25);
567 assert_eq!(
568 BackoffPolicy::Linear {
569 initial_ms: 10,
570 step_ms: 5,
571 max_ms: Some(18),
572 }
573 .delay_ms(4),
574 18
575 );
576 assert_eq!(
577 BackoffPolicy::Exponential {
578 initial_ms: 10,
579 factor: 2,
580 max_ms: Some(50),
581 }
582 .delay_ms(4),
583 50
584 );
585 assert_eq!(
586 BackoffPolicy::Fibonacci {
587 unit_ms: 10,
588 max_ms: None,
589 }
590 .delay_ms(6),
591 80
592 );
593 }
594
595 #[test]
596 fn retry_policy_bounds_attempts() {
597 let policy = RetryPolicy::new(
598 3,
599 BackoffPolicy::Linear {
600 initial_ms: 10,
601 step_ms: 10,
602 max_ms: None,
603 },
604 );
605
606 assert!(policy.should_retry(1));
607 assert!(policy.should_retry(2));
608 assert!(!policy.should_retry(3));
609 assert_eq!(policy.next_delay_ms(1), Some(10));
610 assert_eq!(policy.next_delay_ms(3), Some(30));
611 assert_eq!(policy.next_delay_ms(4), None);
612 }
613
614 #[test]
615 fn retry_and_breaker_status_nodes_project_event_facts() {
616 let g = crate::graph::graph();
617 let events = g.state_empty::<RetryEvent>();
618 let policy = RetryPolicy::new(2, BackoffPolicy::Constant { delay_ms: 25 });
619 let retry = retry_status_node(&g, &events, policy, "retry");
620 let breaker = breaker_status_node(&g, &events, 1, || 100, "breaker");
621 let _retry_sub = retry.subscribe(|_| {});
622 let _breaker_sub = breaker.subscribe(|_| {});
623
624 events.set(RetryEvent::Attempt { attempt: 1 });
625 events.set(RetryEvent::Failure {
626 attempt: 1,
627 error: "nope".to_owned(),
628 });
629
630 assert_eq!(
631 retry.cache(),
632 Some(RetryStatus {
633 attempt: 1,
634 max_attempts: 2,
635 delay_ms: Some(25),
636 state: RetryState::Failed,
637 })
638 );
639 assert_eq!(
640 breaker.cache(),
641 Some(BreakerStatus {
642 state: BreakerState::Open,
643 failures: 1,
644 opened_at_ms: Some(100),
645 })
646 );
647 }
648
649 #[test]
650 fn rate_limit_bundle_projects_allowed_dropped_and_status() {
651 let g = crate::graph::graph();
652 let source = g.state_empty::<i32>();
653 let now = std::rc::Rc::new(std::cell::Cell::new(0_u64));
654 let now_for_bundle = now.clone();
655 let bundle = rate_limit_bundle(&g, &source, 2, 100, move || now_for_bundle.get(), "limit");
656 let _allowed = bundle.allowed.subscribe(|_| {});
657 let _dropped = bundle.dropped.subscribe(|_| {});
658 let _status = bundle.status.subscribe(|_| {});
659
660 source.set(1);
661 source.set(2);
662 source.set(3);
663 now.set(101);
664 source.set(4);
665
666 assert_eq!(bundle.allowed.cache(), Some(4));
667 assert_eq!(bundle.dropped.cache(), Some(3));
668 assert_eq!(
669 bundle.status.cache(),
670 Some(RateLimitStatus {
671 allowed: 3,
672 dropped: 1,
673 remaining: 1,
674 reset_at_ms: 201,
675 })
676 );
677 }
678}