Skip to main content

mocra_core/common/
policy.rs

1use crate::errors::{Error, ErrorKind};
2use serde::{Deserialize, Serialize};
3
4/// Retry backoff strategy used by a resolved policy.
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
6pub enum BackoffPolicy {
7    /// Do not delay between retries.
8    None,
9    /// Increase delay by a fixed amount each retry until `max_ms`.
10    Linear { base_ms: u64, max_ms: u64 },
11    /// Increase delay exponentially until `max_ms`.
12    Exponential { base_ms: u64, max_ms: u64 },
13}
14
15/// Dead-letter queue strategy for failed events.
16#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
17pub enum DlqPolicy {
18    /// Never route to DLQ.
19    Never,
20    /// Route to DLQ only after retries are exhausted.
21    OnExhausted,
22    /// Always route to DLQ immediately.
23    Always,
24}
25
26/// Alert severity emitted for a handled error.
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28pub enum AlertLevel {
29    /// Informational signal.
30    Info,
31    /// Warning-level alert.
32    Warn,
33    /// Error-level alert.
34    Error,
35    /// Critical operational alert.
36    Critical,
37}
38
39/// Fully materialized policy returned by the resolver.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Policy {
42    /// Whether retry is allowed.
43    pub retryable: bool,
44    /// Backoff strategy for retries.
45    pub backoff: BackoffPolicy,
46    /// DLQ routing behavior.
47    pub dlq: DlqPolicy,
48    /// Alerting level.
49    pub alert: AlertLevel,
50    /// Maximum retry attempts.
51    pub max_retries: u32,
52    /// Initial backoff value in milliseconds.
53    pub backoff_ms: u64,
54}
55
56impl Default for Policy {
57    fn default() -> Self {
58        Self {
59            retryable: true,
60            backoff: BackoffPolicy::Exponential {
61                base_ms: 500,
62                max_ms: 30_000,
63            },
64            dlq: DlqPolicy::OnExhausted,
65            alert: AlertLevel::Warn,
66            max_retries: 3,
67            backoff_ms: 500,
68        }
69    }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct PolicyConfig {
74    /// Override rules applied on top of built-in defaults.
75    pub overrides: Vec<PolicyOverride>,
76}
77
78/// A conditional rule used to override the default policy.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PolicyOverride {
81    /// Optional domain match, e.g. `engine`.
82    pub domain: Option<String>,
83    /// Optional event type match, e.g. `download`.
84    pub event_type: Option<String>,
85    /// Optional lifecycle phase match, e.g. `failed`.
86    pub phase: Option<String>,
87    /// Required error kind match.
88    pub kind: ErrorKind,
89    /// Optional override for retryability.
90    pub retryable: Option<bool>,
91    /// Optional override for backoff strategy.
92    pub backoff: Option<BackoffPolicy>,
93    /// Optional override for DLQ strategy.
94    pub dlq: Option<DlqPolicy>,
95    /// Optional override for alert level.
96    pub alert: Option<AlertLevel>,
97    /// Optional override for max retries.
98    pub max_retries: Option<u32>,
99    /// Optional override for initial backoff in milliseconds.
100    pub backoff_ms: Option<u64>,
101}
102
103/// Resolver output with the selected policy and a normalized reason key.
104#[derive(Debug, Clone)]
105pub struct Decision {
106    /// The resolved policy after defaults + overrides.
107    pub policy: Policy,
108    /// A normalized trace key in the format `domain/event/phase/kind`.
109    pub reason: String,
110}
111
112/// Resolves runtime error handling policies using default rules and optional overrides.
113#[derive(Debug, Clone)]
114pub struct PolicyResolver {
115    overrides: Vec<PolicyOverride>,
116}
117
118impl PolicyResolver {
119    /// Creates a resolver from optional config.
120    pub fn new(config: Option<&PolicyConfig>) -> Self {
121        let overrides = config.map(|cfg| cfg.overrides.clone()).unwrap_or_default();
122        Self { overrides }
123    }
124
125    /// Resolves a policy from a concrete [`Error`].
126    pub fn resolve_with_error(
127        &self,
128        domain: &str,
129        event_type: Option<&str>,
130        phase: Option<&str>,
131        err: &Error,
132    ) -> Decision {
133        self.resolve_with_kind(domain, event_type, phase, err.kind().clone())
134    }
135
136    /// Resolves a policy from a known [`ErrorKind`].
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use mocra_core::common::policy::{DlqPolicy, PolicyResolver};
142    /// use mocra_core::errors::ErrorKind;
143    ///
144    /// let resolver = PolicyResolver::new(None);
145    /// let decision = resolver.resolve_with_kind(
146    ///     "engine",
147    ///     Some("parser"),
148    ///     Some("failed"),
149    ///     ErrorKind::Parser,
150    /// );
151    ///
152    /// assert!(!decision.policy.retryable);
153    /// assert_eq!(decision.policy.dlq, DlqPolicy::Always);
154    /// ```
155    pub fn resolve_with_kind(
156        &self,
157        domain: &str,
158        event_type: Option<&str>,
159        phase: Option<&str>,
160        kind: ErrorKind,
161    ) -> Decision {
162        let policy = default_policy(domain, event_type, phase, &kind);
163        let policy = apply_overrides(policy, &self.overrides, domain, event_type, phase, &kind);
164        Decision {
165            policy,
166            reason: format!(
167                "{}/{}/{}/{:?}",
168                normalize(domain),
169                normalize_opt(event_type).unwrap_or("-"),
170                normalize_opt(phase).unwrap_or("-"),
171                kind
172            ),
173        }
174    }
175}
176
177fn normalize(value: &str) -> &str {
178    value.trim()
179}
180
181fn normalize_opt(value: Option<&str>) -> Option<&str> {
182    value.map(|v| v.trim()).filter(|v| !v.is_empty())
183}
184
185fn default_policy(
186    domain: &str,
187    event_type: Option<&str>,
188    phase: Option<&str>,
189    kind: &ErrorKind,
190) -> Policy {
191    let domain = normalize(domain).to_ascii_lowercase();
192    let event_type = normalize_opt(event_type).unwrap_or("").to_ascii_lowercase();
193    let phase = normalize_opt(phase).unwrap_or("").to_ascii_lowercase();
194
195    let is_failed_or_retry = phase == "failed" || phase == "retry";
196
197    match (
198        domain.as_str(),
199        event_type.as_str(),
200        is_failed_or_retry,
201        kind,
202    ) {
203        ("engine", "task_model", true, ErrorKind::Task | ErrorKind::Module) => Policy {
204            backoff: BackoffPolicy::Exponential {
205                base_ms: 500,
206                max_ms: 30_000,
207            },
208            dlq: DlqPolicy::OnExhausted,
209            alert: AlertLevel::Warn,
210            max_retries: 3,
211            backoff_ms: 500,
212            retryable: true,
213        },
214        ("engine", "parser_task_model", true, ErrorKind::Parser | ErrorKind::ProcessorChain) => {
215            Policy {
216                backoff: BackoffPolicy::Exponential {
217                    base_ms: 500,
218                    max_ms: 30_000,
219                },
220                dlq: DlqPolicy::OnExhausted,
221                alert: AlertLevel::Warn,
222                max_retries: 3,
223                backoff_ms: 500,
224                retryable: true,
225            }
226        }
227        ("engine", "request_publish", true, ErrorKind::Queue) => Policy {
228            backoff: BackoffPolicy::Exponential {
229                base_ms: 500,
230                max_ms: 30_000,
231            },
232            dlq: DlqPolicy::OnExhausted,
233            alert: AlertLevel::Warn,
234            max_retries: 3,
235            backoff_ms: 500,
236            retryable: true,
237        },
238        ("engine", "request_middleware", _, ErrorKind::Request | ErrorKind::ProcessorChain) => {
239            Policy {
240                retryable: false,
241                backoff: BackoffPolicy::None,
242                dlq: DlqPolicy::Always,
243                alert: AlertLevel::Error,
244                max_retries: 0,
245                backoff_ms: 0,
246            }
247        }
248        (
249            "engine",
250            "download",
251            true,
252            ErrorKind::Download | ErrorKind::Proxy | ErrorKind::RateLimit,
253        ) => Policy {
254            backoff: BackoffPolicy::Exponential {
255                base_ms: 500,
256                max_ms: 60_000,
257            },
258            dlq: DlqPolicy::OnExhausted,
259            alert: AlertLevel::Warn,
260            max_retries: 5,
261            backoff_ms: 500,
262            retryable: true,
263        },
264        ("engine", "response_middleware", _, ErrorKind::Response | ErrorKind::ProcessorChain) => {
265            Policy {
266                retryable: false,
267                backoff: BackoffPolicy::None,
268                dlq: DlqPolicy::Always,
269                alert: AlertLevel::Error,
270                max_retries: 0,
271                backoff_ms: 0,
272            }
273        }
274        ("engine", "response_publish", true, ErrorKind::Queue) => Policy {
275            backoff: BackoffPolicy::Exponential {
276                base_ms: 500,
277                max_ms: 30_000,
278            },
279            dlq: DlqPolicy::OnExhausted,
280            alert: AlertLevel::Warn,
281            max_retries: 3,
282            backoff_ms: 500,
283            retryable: true,
284        },
285        ("engine", "module_generate", true, ErrorKind::Module | ErrorKind::Task) => Policy {
286            backoff: BackoffPolicy::Exponential {
287                base_ms: 500,
288                max_ms: 30_000,
289            },
290            dlq: DlqPolicy::OnExhausted,
291            alert: AlertLevel::Error,
292            max_retries: 3,
293            backoff_ms: 500,
294            retryable: true,
295        },
296        ("engine", "parser", true, ErrorKind::Parser) => Policy {
297            retryable: false,
298            backoff: BackoffPolicy::None,
299            dlq: DlqPolicy::Always,
300            alert: AlertLevel::Error,
301            max_retries: 0,
302            backoff_ms: 0,
303        },
304        ("engine", "middleware_before", _, ErrorKind::DataMiddleware) => Policy {
305            backoff: BackoffPolicy::Linear {
306                base_ms: 200,
307                max_ms: 10_000,
308            },
309            dlq: DlqPolicy::OnExhausted,
310            alert: AlertLevel::Warn,
311            max_retries: 3,
312            backoff_ms: 200,
313            retryable: true,
314        },
315        ("engine", "data_store", _, ErrorKind::DataStore | ErrorKind::Orm) => Policy {
316            backoff: BackoffPolicy::Linear {
317                base_ms: 200,
318                max_ms: 10_000,
319            },
320            dlq: DlqPolicy::OnExhausted,
321            alert: AlertLevel::Error,
322            max_retries: 3,
323            backoff_ms: 200,
324            retryable: true,
325        },
326        ("system", "system_error", true, _) => Policy {
327            retryable: false,
328            backoff: BackoffPolicy::None,
329            dlq: DlqPolicy::Never,
330            alert: AlertLevel::Error,
331            max_retries: 0,
332            backoff_ms: 0,
333        },
334        _ => Policy::default(),
335    }
336}
337
338fn apply_overrides(
339    mut policy: Policy,
340    overrides: &[PolicyOverride],
341    domain: &str,
342    event_type: Option<&str>,
343    phase: Option<&str>,
344    kind: &ErrorKind,
345) -> Policy {
346    let domain = normalize(domain).to_ascii_lowercase();
347    let event_type = normalize_opt(event_type).unwrap_or("").to_ascii_lowercase();
348    let phase = normalize_opt(phase).unwrap_or("").to_ascii_lowercase();
349
350    let mut best_score = -1_i32;
351    let mut best_override: Option<&PolicyOverride> = None;
352
353    for override_item in overrides {
354        if &override_item.kind != kind {
355            continue;
356        }
357
358        let domain_match = match &override_item.domain {
359            Some(value) => value.trim().eq_ignore_ascii_case(&domain),
360            None => true,
361        };
362        if !domain_match {
363            continue;
364        }
365
366        let event_match = match &override_item.event_type {
367            Some(value) => value.trim().eq_ignore_ascii_case(&event_type),
368            None => true,
369        };
370        if !event_match {
371            continue;
372        }
373
374        let phase_match = match &override_item.phase {
375            Some(value) => value.trim().eq_ignore_ascii_case(&phase),
376            None => true,
377        };
378        if !phase_match {
379            continue;
380        }
381
382        let score = (override_item.domain.is_some() as i32)
383            + (override_item.event_type.is_some() as i32)
384            + (override_item.phase.is_some() as i32)
385            + 1;
386
387        if score > best_score || (score == best_score && best_override.is_some()) {
388            best_score = score;
389            best_override = Some(override_item);
390        }
391    }
392
393    if let Some(override_item) = best_override {
394        if let Some(value) = override_item.retryable {
395            policy.retryable = value;
396        }
397        if let Some(value) = override_item.backoff {
398            policy.backoff = value;
399        }
400        if let Some(value) = override_item.dlq {
401            policy.dlq = value;
402        }
403        if let Some(value) = override_item.alert {
404            policy.alert = value;
405        }
406        if let Some(value) = override_item.max_retries {
407            policy.max_retries = value;
408        }
409        if let Some(value) = override_item.backoff_ms {
410            policy.backoff_ms = value;
411        }
412    }
413
414    policy
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn default_policy_parser_failed_is_not_retryable() {
423        let resolver = PolicyResolver::new(None);
424        let decision =
425            resolver.resolve_with_kind("engine", Some("parser"), Some("failed"), ErrorKind::Parser);
426        assert!(!decision.policy.retryable);
427        assert_eq!(decision.policy.dlq, DlqPolicy::Always);
428    }
429
430    #[test]
431    fn overrides_take_precedence() {
432        let cfg = PolicyConfig {
433            overrides: vec![PolicyOverride {
434                domain: Some("engine".to_string()),
435                event_type: Some("download".to_string()),
436                phase: Some("failed".to_string()),
437                kind: ErrorKind::Download,
438                retryable: Some(false),
439                backoff: Some(BackoffPolicy::None),
440                dlq: Some(DlqPolicy::Always),
441                alert: Some(AlertLevel::Error),
442                max_retries: Some(0),
443                backoff_ms: Some(0),
444            }],
445        };
446
447        let resolver = PolicyResolver::new(Some(&cfg));
448        let decision = resolver.resolve_with_kind(
449            "engine",
450            Some("download"),
451            Some("failed"),
452            ErrorKind::Download,
453        );
454
455        assert!(!decision.policy.retryable);
456        assert_eq!(decision.policy.dlq, DlqPolicy::Always);
457        assert_eq!(decision.policy.max_retries, 0);
458    }
459}