Skip to main content

khive_types/
khive_error.rs

1//! Unified cross-crate error model: `KhiveError`, `ErrorKind`, `ErrorCode`, `Details`, `RetryHint`.
2
3extern crate alloc;
4use alloc::borrow::Cow;
5use alloc::string::String;
6use core::fmt;
7
8#[cfg(feature = "serde")]
9use alloc::string::ToString;
10
11#[cfg(feature = "serde")]
12use serde::{Deserialize, Serialize};
13
14// ---- ErrorKind ----
15
16/// Semantic error category — maps to HTTP status codes.
17///
18/// | Variant | HTTP |
19/// |---------|------|
20/// | `NotFound` | 404 |
21/// | `InvalidInput` | 400 |
22/// | `Unauthorized` | 403 |
23/// | `Conflict` | 409 |
24/// | `Unavailable` | 503 |
25/// | `Internal` | 500 |
26///
27/// Closed taxonomy. New variants are a source-breaking change and require an ADR.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
31pub enum ErrorKind {
32    NotFound,
33    InvalidInput,
34    Unauthorized,
35    Conflict,
36    Unavailable,
37    Internal,
38}
39
40impl ErrorKind {
41    /// HTTP status code for this kind.
42    pub fn http_status(self) -> u16 {
43        match self {
44            Self::NotFound => 404,
45            Self::InvalidInput => 400,
46            Self::Unauthorized => 403,
47            Self::Conflict => 409,
48            Self::Unavailable => 503,
49            Self::Internal => 500,
50        }
51    }
52
53    /// Snake-case string representation (stable across versions).
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::NotFound => "not_found",
57            Self::InvalidInput => "invalid_input",
58            Self::Unauthorized => "unauthorized",
59            Self::Conflict => "conflict",
60            Self::Unavailable => "unavailable",
61            Self::Internal => "internal",
62        }
63    }
64}
65
66impl fmt::Display for ErrorKind {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.write_str(self.as_str())
69    }
70}
71
72// ---- ErrorDomain ----
73
74/// Domain that owns the error code namespace.
75///
76/// Only the OSS-relevant domains are exposed; internal-only domains
77/// (auth, billing, etc.) are not included.
78///
79/// Closed taxonomy. New variants are a source-breaking change and require an ADR.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
81#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
82#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
83pub enum ErrorDomain {
84    Db,
85    Query,
86    Runtime,
87    Types,
88}
89
90impl ErrorDomain {
91    /// Return the lowercase string name for this domain.
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Self::Db => "db",
95            Self::Query => "query",
96            Self::Runtime => "runtime",
97            Self::Types => "types",
98        }
99    }
100}
101
102impl fmt::Display for ErrorDomain {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.write_str(self.as_str())
105    }
106}
107
108// ---- ErrorCode ----
109
110/// Domain-scoped numeric error code.
111///
112/// Wire shape: `"domain:N"` (e.g., `"db:1"`, `"runtime:10"`).
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
114pub struct ErrorCode {
115    domain: ErrorDomain,
116    code: u32,
117}
118
119impl ErrorCode {
120    /// Create a new error code in the given domain.
121    pub fn new(domain: ErrorDomain, code: u32) -> Self {
122        Self { domain, code }
123    }
124
125    /// Return the domain that owns this error code.
126    pub fn domain(self) -> ErrorDomain {
127        self.domain
128    }
129
130    /// Return the numeric code within the domain.
131    pub fn code(self) -> u32 {
132        self.code
133    }
134}
135
136impl fmt::Display for ErrorCode {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        write!(f, "{}:{}", self.domain, self.code)
139    }
140}
141
142#[cfg(feature = "serde")]
143impl Serialize for ErrorCode {
144    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
145        s.serialize_str(&self.to_string())
146    }
147}
148
149#[cfg(feature = "serde")]
150impl<'de> Deserialize<'de> for ErrorCode {
151    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
152        let s = alloc::string::String::deserialize(d)?;
153        let (domain_str, code_str) = s
154            .split_once(':')
155            .ok_or_else(|| serde::de::Error::custom("expected 'domain:N'"))?;
156        let domain = match domain_str {
157            "db" => ErrorDomain::Db,
158            "query" => ErrorDomain::Query,
159            "runtime" => ErrorDomain::Runtime,
160            "types" => ErrorDomain::Types,
161            other => {
162                return Err(serde::de::Error::custom(alloc::format!(
163                    "unknown domain: {other}"
164                )))
165            }
166        };
167        let code: u32 = code_str.parse().map_err(serde::de::Error::custom)?;
168        Ok(ErrorCode::new(domain, code))
169    }
170}
171
172// ---- RetryHint ----
173
174/// Guidance to callers on whether retrying the operation makes sense.
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
176#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
177#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
178pub enum RetryHint {
179    /// Do not retry — the same request will fail again.
180    NoRetry,
181    /// Retry may succeed (transient failure).
182    Retryable,
183}
184
185// ---- Details ----
186
187/// Bounded key/value metadata attached to a `KhiveError` (max 8 pairs).
188///
189/// Stored as `Cow<'static, str>` pairs: zero-alloc for static string literals
190/// (the common construction path) and owned strings on deserialization (no
191/// memory leak). Both paths are `no_std` + `alloc` compatible.
192#[derive(Clone, Debug, PartialEq, Eq)]
193pub struct Details {
194    entries: alloc::vec::Vec<(Cow<'static, str>, Cow<'static, str>)>,
195}
196
197impl Details {
198    /// Build `Details` from an iterable of `(&'static str, &'static str)` pairs.
199    /// Silently truncates to 8 entries.
200    pub fn new<I>(pairs: I) -> Self
201    where
202        I: IntoIterator<Item = (&'static str, &'static str)>,
203    {
204        let entries: alloc::vec::Vec<_> = pairs
205            .into_iter()
206            .take(8)
207            .map(|(k, v)| (Cow::Borrowed(k), Cow::Borrowed(v)))
208            .collect();
209        Self { entries }
210    }
211
212    /// Look up a value by key.
213    pub fn get(&self, key: &str) -> Option<&str> {
214        self.entries
215            .iter()
216            .find(|(k, _)| k.as_ref() == key)
217            .map(|(_, v)| v.as_ref())
218    }
219
220    /// Iterate over (key, value) pairs.
221    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> + '_ {
222        self.entries.iter().map(|(k, v)| (k.as_ref(), v.as_ref()))
223    }
224}
225
226#[cfg(feature = "serde")]
227impl Serialize for Details {
228    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
229        use serde::ser::SerializeMap;
230        let mut map = s.serialize_map(Some(self.entries.len()))?;
231        for (k, v) in &self.entries {
232            map.serialize_entry(k.as_ref(), v.as_ref())?;
233        }
234        map.end()
235    }
236}
237
238#[cfg(feature = "serde")]
239impl<'de> Deserialize<'de> for Details {
240    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
241        use serde::de::{MapAccess, Visitor};
242
243        struct DetailsVisitor;
244
245        impl<'de> Visitor<'de> for DetailsVisitor {
246            type Value = Details;
247
248            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249                f.write_str("a map of string key-value pairs")
250            }
251
252            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Details, A::Error> {
253                let mut entries: alloc::vec::Vec<(Cow<'static, str>, Cow<'static, str>)> =
254                    alloc::vec::Vec::new();
255                while let Some((k, v)) = map.next_entry::<String, String>()? {
256                    if entries.len() >= 8 {
257                        break;
258                    }
259                    entries.push((Cow::Owned(k), Cow::Owned(v)));
260                }
261                Ok(Details { entries })
262            }
263        }
264
265        d.deserialize_map(DetailsVisitor)
266    }
267}
268
269// ---- KhiveError ----
270
271/// Unified error type for the khive runtime.
272///
273/// # Wire shape (serde)
274///
275/// ```json
276/// {
277///   "kind": "not_found",
278///   "message": "entity not found: abc123",
279///   "code": "runtime:10",
280///   "details": { "resource": "entity", "id": "abc123" }
281/// }
282/// ```
283///
284/// `code` and `details` are `null` when absent.
285#[derive(Clone, Debug)]
286#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
287pub struct KhiveError {
288    kind: ErrorKind,
289    message: String,
290    code: Option<ErrorCode>,
291    details: Option<Details>,
292}
293
294impl KhiveError {
295    // ---- constructors ----
296
297    /// Create a `NotFound` error for a missing resource identified by `id`.
298    pub fn not_found(resource: impl fmt::Display, id: impl fmt::Display) -> Self {
299        Self {
300            kind: ErrorKind::NotFound,
301            message: alloc::format!("{resource} not found: {id}"),
302            code: None,
303            details: None,
304        }
305    }
306
307    /// Create an `InvalidInput` error with the given message.
308    pub fn invalid_input(message: impl Into<String>) -> Self {
309        Self {
310            kind: ErrorKind::InvalidInput,
311            message: alloc::format!("invalid input: {}", message.into()),
312            code: None,
313            details: None,
314        }
315    }
316
317    /// Create an `Unauthorized` error with the given message.
318    pub fn unauthorized(message: impl Into<String>) -> Self {
319        Self {
320            kind: ErrorKind::Unauthorized,
321            message: alloc::format!("unauthorized: {}", message.into()),
322            code: None,
323            details: None,
324        }
325    }
326
327    /// Create a `Conflict` error with the given message.
328    pub fn conflict(message: impl Into<String>) -> Self {
329        Self {
330            kind: ErrorKind::Conflict,
331            message: alloc::format!("conflict: {}", message.into()),
332            code: None,
333            details: None,
334        }
335    }
336
337    /// Create an `Unavailable` error with the given message.
338    pub fn unavailable(message: impl Into<String>) -> Self {
339        Self {
340            kind: ErrorKind::Unavailable,
341            message: alloc::format!("unavailable: {}", message.into()),
342            code: None,
343            details: None,
344        }
345    }
346
347    /// Create an `Internal` error with the given message.
348    pub fn internal(message: impl Into<String>) -> Self {
349        Self {
350            kind: ErrorKind::Internal,
351            message: alloc::format!("internal: {}", message.into()),
352            code: None,
353            details: None,
354        }
355    }
356
357    // ---- builder methods ----
358
359    /// Attach a domain-scoped error code.
360    pub fn with_code(mut self, code: ErrorCode) -> Self {
361        self.code = Some(code);
362        self
363    }
364
365    /// Attach bounded key-value metadata.
366    pub fn with_details(mut self, details: Details) -> Self {
367        self.details = Some(details);
368        self
369    }
370
371    // ---- accessors ----
372
373    /// Return the semantic error category.
374    pub fn kind(&self) -> ErrorKind {
375        self.kind
376    }
377
378    /// Return the human-readable error message.
379    pub fn message(&self) -> &str {
380        &self.message
381    }
382
383    /// Return the domain-scoped error code, if set.
384    pub fn code(&self) -> Option<ErrorCode> {
385        self.code
386    }
387
388    /// Return the bounded metadata details, if set.
389    pub fn details(&self) -> Option<&Details> {
390        self.details.as_ref()
391    }
392
393    /// Retry guidance based on the error kind.
394    pub fn retry_hint(&self) -> RetryHint {
395        match self.kind {
396            ErrorKind::Unavailable => RetryHint::Retryable,
397            _ => RetryHint::NoRetry,
398        }
399    }
400}
401
402impl fmt::Display for KhiveError {
403    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
404        write!(f, "{}", self.message)
405    }
406}
407
408#[cfg(feature = "std")]
409impl std::error::Error for KhiveError {}