Skip to main content

runifold_core/
error.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use thiserror::Error;
6
7/// Whether retrying an operation is safe.
8#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
9#[non_exhaustive]
10pub enum RetrySafety {
11    /// The same operation can be retried safely.
12    Safe,
13    /// Retry is safe only when the external system honors an idempotency key.
14    RequiresIdempotency,
15    /// Visible output has been emitted, so retry may duplicate output.
16    UnsafeAfterVisibleOutput,
17    /// An external side effect may already have occurred.
18    UnsafeAfterSideEffect,
19    /// Safety cannot be determined.
20    Unknown,
21}
22
23/// A normalized run failure category.
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[non_exhaustive]
26pub enum RunErrorKind {
27    /// Input or configuration is invalid.
28    InvalidInput,
29    /// A required capability was not granted.
30    CapabilityDenied,
31    /// A resource budget was exhausted.
32    BudgetExceeded,
33    /// A deadline elapsed.
34    DeadlineExceeded,
35    /// The run was cancelled.
36    Cancelled,
37    /// A transport failed.
38    Transport,
39    /// A remote protocol was malformed or violated its contract.
40    Protocol,
41    /// An invoked component failed.
42    Invocation,
43    /// A namespaced extension error.
44    Extension(String),
45}
46
47impl RunErrorKind {
48    /// Returns a stable machine-readable diagnostic code.
49    pub fn code(&self) -> &str {
50        match self {
51            Self::InvalidInput => "runifold.invalid_input",
52            Self::CapabilityDenied => "runifold.capability_denied",
53            Self::BudgetExceeded => "runifold.budget_exceeded",
54            Self::DeadlineExceeded => "runifold.deadline_exceeded",
55            Self::Cancelled => "runifold.cancelled",
56            Self::Transport => "runifold.transport",
57            Self::Protocol => "runifold.protocol",
58            Self::Invocation => "runifold.invocation",
59            Self::Extension(namespace) => namespace,
60        }
61    }
62
63    /// Returns a safe default remediation hint for operators.
64    pub fn recommendation(&self) -> &'static str {
65        match self {
66            Self::InvalidInput => "Validate configuration and request data before retrying.",
67            Self::CapabilityDenied => {
68                "Grant only the required capability or remove the unauthorized operation."
69            }
70            Self::BudgetExceeded => "Increase the explicit budget or reduce bounded work.",
71            Self::DeadlineExceeded => {
72                "Review the deadline and upstream latency before deciding whether to retry."
73            }
74            Self::Cancelled => "Do not retry unless the caller starts a new operation.",
75            Self::Transport => "Inspect retry safety, endpoint health, and network diagnostics.",
76            Self::Protocol => {
77                "Inspect the Provider response and adapter compatibility before retrying."
78            }
79            Self::Invocation => "Inspect the invoked component's typed cause and metadata.",
80            Self::Extension(_) => "Inspect the namespaced extension metadata and documentation.",
81        }
82    }
83}
84
85/// A structured run error suitable for policy decisions.
86#[derive(Clone, Debug, Deserialize, Error, PartialEq, Serialize)]
87#[error("{kind:?}: {message}")]
88pub struct RunError {
89    /// Normalized error category.
90    pub kind: RunErrorKind,
91    /// Safe human-readable explanation.
92    pub message: String,
93    /// Retry-safety classification.
94    pub retry_safety: RetrySafety,
95    /// Namespaced diagnostic metadata.
96    pub metadata: BTreeMap<String, Value>,
97}
98
99impl RunError {
100    /// Returns the stable machine-readable diagnostic code.
101    pub fn code(&self) -> &str {
102        self.kind.code()
103    }
104
105    /// Returns a safe default remediation hint.
106    pub fn recommendation(&self) -> &'static str {
107        self.kind.recommendation()
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::RunErrorKind;
114
115    #[test]
116    fn diagnostic_codes_are_stable_and_extension_aware() {
117        assert_eq!(RunErrorKind::Protocol.code(), "runifold.protocol");
118        assert_eq!(
119            RunErrorKind::Extension("acme.custom".into()).code(),
120            "acme.custom"
121        );
122        assert!(!RunErrorKind::Transport.recommendation().is_empty());
123    }
124}