Skip to main content

khive_types/
khive_error.rs

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