Skip to main content

agent_first_data/
error_catalog.rs

1//! Stable, public-facing domain error declarations.
2//!
3//! Runtime diagnostics are deliberately absent from these types. A parser,
4//! database, or network error may be logged separately, but it cannot drift
5//! into a protocol message merely because an application formats `source`.
6
7use crate::{BuildError, ErrorBuilder, Event, json_error};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::fmt;
11
12/// Stable fields that are safe to return to a caller.
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ErrorSpec {
15    code: String,
16    message: String,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    hint: Option<String>,
19    #[serde(default)]
20    retryable: bool,
21}
22
23impl ErrorSpec {
24    /// Declare a stable error code and public message.
25    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
26        Self {
27            code: code.into(),
28            message: message.into(),
29            hint: None,
30            retryable: false,
31        }
32    }
33
34    /// Add a stable caller-facing recovery hint.
35    pub fn hint(mut self, hint: impl Into<String>) -> Self {
36        let hint = hint.into();
37        self.hint = (!hint.is_empty()).then_some(hint);
38        self
39    }
40
41    /// Declare whether retrying may succeed without changing the request.
42    pub const fn retryable(mut self, retryable: bool) -> Self {
43        self.retryable = retryable;
44        self
45    }
46
47    pub fn code(&self) -> &str {
48        &self.code
49    }
50
51    pub fn message(&self) -> &str {
52        &self.message
53    }
54
55    pub fn hint_value(&self) -> Option<&str> {
56        self.hint.as_deref()
57    }
58
59    pub const fn is_retryable(&self) -> bool {
60        self.retryable
61    }
62
63    /// Start an error builder using only catalogued public fields.
64    ///
65    /// Applications may add explicitly selected structured fields to the
66    /// returned builder. No runtime diagnostic is accepted by this API.
67    pub fn builder(&self) -> ErrorBuilder {
68        json_error(&self.code, &self.message)
69            .hint_if_some(self.hint.as_deref())
70            .retryable_if(self.retryable)
71    }
72
73    /// Build the strict protocol error event.
74    pub fn event(&self) -> Result<Event, BuildError> {
75        self.builder().build()
76    }
77}
78
79/// Failure to construct or query an [`ErrorCatalog`].
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub enum ErrorCatalogError {
82    DuplicateCode(String),
83    UnknownCode(String),
84    InvalidSpec { code: String, source: BuildError },
85}
86
87impl fmt::Display for ErrorCatalogError {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::DuplicateCode(code) => write!(formatter, "duplicate error code {code:?}"),
91            Self::UnknownCode(code) => write!(formatter, "unknown error code {code:?}"),
92            Self::InvalidSpec { code, source } => {
93                write!(formatter, "invalid error spec {code:?}: {source}")
94            }
95        }
96    }
97}
98
99impl std::error::Error for ErrorCatalogError {
100    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101        match self {
102            Self::InvalidSpec { source, .. } => Some(source),
103            Self::DuplicateCode(_) | Self::UnknownCode(_) => None,
104        }
105    }
106}
107
108/// Validated lookup table of stable domain errors.
109#[derive(Clone, Debug, Default)]
110pub struct ErrorCatalog {
111    specs: BTreeMap<String, ErrorSpec>,
112}
113
114impl ErrorCatalog {
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Validate and collect a finite set of declarations.
120    pub fn from_specs<I>(specs: I) -> Result<Self, ErrorCatalogError>
121    where
122        I: IntoIterator<Item = ErrorSpec>,
123    {
124        let mut catalog = Self::new();
125        for spec in specs {
126            catalog.insert(spec)?;
127        }
128        Ok(catalog)
129    }
130
131    /// Validate and insert one declaration.
132    pub fn insert(&mut self, spec: ErrorSpec) -> Result<(), ErrorCatalogError> {
133        let code = spec.code.clone();
134        if self.specs.contains_key(&code) {
135            return Err(ErrorCatalogError::DuplicateCode(code));
136        }
137        spec.event()
138            .map_err(|source| ErrorCatalogError::InvalidSpec {
139                code: code.clone(),
140                source,
141            })?;
142        self.specs.insert(code, spec);
143        Ok(())
144    }
145
146    pub fn get(&self, code: &str) -> Option<&ErrorSpec> {
147        self.specs.get(code)
148    }
149
150    /// Start a builder for a catalogued code.
151    pub fn builder(&self, code: &str) -> Result<ErrorBuilder, ErrorCatalogError> {
152        self.get(code)
153            .map(ErrorSpec::builder)
154            .ok_or_else(|| ErrorCatalogError::UnknownCode(code.to_string()))
155    }
156
157    /// Build a strict error event for a catalogued code.
158    pub fn event(&self, code: &str) -> Result<Event, ErrorCatalogError> {
159        self.builder(code)?
160            .build()
161            .map_err(|source| ErrorCatalogError::InvalidSpec {
162                code: code.to_string(),
163                source,
164            })
165    }
166
167    pub fn len(&self) -> usize {
168        self.specs.len()
169    }
170
171    pub fn is_empty(&self) -> bool {
172        self.specs.is_empty()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::{ErrorCatalog, ErrorCatalogError, ErrorSpec};
179    use crate::validate_protocol_event;
180
181    #[test]
182    fn catalog_builds_only_declared_public_fields() {
183        let catalog = ErrorCatalog::from_specs([ErrorSpec::new(
184            "config_load_failed",
185            "Failed to load configuration",
186        )
187        .hint("inspect the configuration and retry")
188        .retryable(false)])
189        .unwrap_or_else(|error| panic!("{error}"));
190
191        let event = catalog
192            .event("config_load_failed")
193            .unwrap_or_else(|error| panic!("{error}"));
194        validate_protocol_event(event.as_value(), true).unwrap_or_else(|error| panic!("{error}"));
195        assert_eq!(
196            event.as_value()["error"]["message"],
197            "Failed to load configuration"
198        );
199        assert!(!event.to_string().contains("database diagnostic"));
200    }
201
202    #[test]
203    fn catalog_rejects_invalid_duplicate_and_unknown_codes() {
204        let invalid = ErrorCatalog::from_specs([ErrorSpec::new("", "message")]);
205        assert!(matches!(
206            invalid,
207            Err(ErrorCatalogError::InvalidSpec { .. })
208        ));
209
210        let duplicate = ErrorCatalog::from_specs([
211            ErrorSpec::new("same", "first"),
212            ErrorSpec::new("same", "second"),
213        ]);
214        assert!(matches!(
215            duplicate,
216            Err(ErrorCatalogError::DuplicateCode(code)) if code == "same"
217        ));
218
219        let empty = ErrorCatalog::new();
220        assert!(matches!(
221            empty.event("missing"),
222            Err(ErrorCatalogError::UnknownCode(code)) if code == "missing"
223        ));
224    }
225}