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/// Reserved key inserted in place of the 8th slot when a `Details` source
188/// (constructor input or a deserialized wire map) supplies more than 8 pairs.
189/// Its value is the count of dropped pairs, so truncation is observable
190/// instead of silently discarding data (RUNTIME-AUD-002 / #487 follow-up).
191pub const DETAILS_TRUNCATED_KEY: &str = "details_truncated";
192
193/// Bounded key/value metadata attached to a `KhiveError` (max 8 pairs).
194///
195/// Stored as `Cow<'static, str>` pairs: zero-alloc for static string literals
196/// (the common construction path) and owned strings on deserialization (no
197/// memory leak). Both paths are `no_std` + `alloc` compatible.
198///
199/// When the source supplies more than 8 pairs, the wire shape stays bounded
200/// at 8 entries, but the truncation is observable: the first 7 pairs are
201/// retained and the 8th slot becomes [`DETAILS_TRUNCATED_KEY`] mapped to the
202/// dropped-pair count. [`DETAILS_TRUNCATED_KEY`] is a *reserved* key: a
203/// client-supplied pair using that name is never retained as an ordinary
204/// entry (PR #549 round-2 review) — it is stripped and folded into the
205/// drop count instead, so a client can neither fake truncation on a small
206/// map nor shadow the real indicator on an oversized one. The drop count
207/// itself is tracked in an internal, non-serialized field
208/// ([`Details::dropped_count`]) rather than parsed back out of the entry
209/// list, so a same-shaped client map can't spoof it either.
210#[derive(Clone, Debug, PartialEq, Eq)]
211pub struct Details {
212    entries: alloc::vec::Vec<(Cow<'static, str>, Cow<'static, str>)>,
213    /// Internal truncation flag — `Some(dropped_count)` when this instance
214    /// was built by dropping pairs (8-entry overflow and/or a reserved-key
215    /// collision), `None` otherwise. Not serialized directly; the wire
216    /// shape communicates truncation via the [`DETAILS_TRUNCATED_KEY`]
217    /// entry, this field is the trusted, non-spoofable read side.
218    dropped: Option<usize>,
219}
220
221impl Details {
222    /// Build `Details` from an iterable of `(&'static str, &'static str)` pairs.
223    ///
224    /// Up to 8 pairs are kept as-is. When more than 8 are supplied, the first
225    /// 7 client pairs are kept and the 8th slot is replaced with
226    /// [`DETAILS_TRUNCATED_KEY`] carrying the dropped-pair count, so the
227    /// truncation is observable rather than silent. A client-supplied pair
228    /// named [`DETAILS_TRUNCATED_KEY`] is always treated as reserved: it is
229    /// dropped (never stored as an ordinary entry) and counted, even when
230    /// the remaining pairs fit within the 8-entry bound.
231    pub fn new<I>(pairs: I) -> Self
232    where
233        I: IntoIterator<Item = (&'static str, &'static str)>,
234    {
235        let all: alloc::vec::Vec<(&'static str, &'static str)> = pairs.into_iter().collect();
236        Self::from_owned(
237            all.into_iter()
238                .map(|(k, v)| (Cow::Borrowed(k), Cow::Borrowed(v))),
239        )
240    }
241
242    /// Shared bounding/truncation logic for the constructor: partition the
243    /// source into ordinary pairs and reserved-key collisions, then hand
244    /// off to [`Details::build`] for the bound + indicator logic.
245    fn from_owned<I>(pairs: I) -> Self
246    where
247        I: IntoIterator<Item = (Cow<'static, str>, Cow<'static, str>)>,
248    {
249        let mut ordinary: alloc::vec::Vec<(Cow<'static, str>, Cow<'static, str>)> =
250            alloc::vec::Vec::new();
251        let mut total_ordinary: usize = 0;
252        let mut collisions: usize = 0;
253        for (k, v) in pairs {
254            if k.as_ref() == DETAILS_TRUNCATED_KEY {
255                collisions += 1;
256            } else {
257                total_ordinary += 1;
258                if ordinary.len() < 8 {
259                    ordinary.push((k, v));
260                }
261            }
262        }
263        Self::build(ordinary, total_ordinary, collisions)
264    }
265
266    /// Bound `ordinary` (already capped at 8 entries, `total_ordinary` the
267    /// true count before capping) plus a reserved-key `collisions` count to
268    /// the wire shape: at most 8 entries, with a [`DETAILS_TRUNCATED_KEY`]
269    /// indicator appended whenever anything was dropped (either overflow
270    /// past 7 ordinary pairs, or a stripped reserved-key collision).
271    fn build(
272        ordinary: alloc::vec::Vec<(Cow<'static, str>, Cow<'static, str>)>,
273        total_ordinary: usize,
274        collisions: usize,
275    ) -> Self {
276        if total_ordinary <= 8 && collisions == 0 {
277            return Self {
278                entries: ordinary,
279                dropped: None,
280            };
281        }
282        let keep = total_ordinary.min(7);
283        let dropped = (total_ordinary - keep) + collisions;
284        let mut entries: alloc::vec::Vec<_> = ordinary.into_iter().take(keep).collect();
285        entries.push((
286            Cow::Borrowed(DETAILS_TRUNCATED_KEY),
287            Cow::Owned(alloc::format!("{dropped}")),
288        ));
289        Self {
290            entries,
291            dropped: Some(dropped),
292        }
293    }
294
295    /// Look up a value by key.
296    pub fn get(&self, key: &str) -> Option<&str> {
297        self.entries
298            .iter()
299            .find(|(k, _)| k.as_ref() == key)
300            .map(|(_, v)| v.as_ref())
301    }
302
303    /// Iterate over (key, value) pairs.
304    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> + '_ {
305        self.entries.iter().map(|(k, v)| (k.as_ref(), v.as_ref()))
306    }
307
308    /// Number of pairs dropped due to the 8-entry bound and/or a
309    /// reserved-key collision, if any were.
310    ///
311    /// Returns `None` when nothing was dropped. Returns
312    /// `Some(dropped_count)` otherwise, read from the internal truncation
313    /// flag set at construction/deserialization time — never re-parsed from
314    /// the entry list, so a client-supplied `details_truncated` pair can't
315    /// spoof this value.
316    pub fn dropped_count(&self) -> Option<usize> {
317        self.dropped
318    }
319}
320
321#[cfg(feature = "serde")]
322impl Serialize for Details {
323    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
324        use serde::ser::SerializeMap;
325        let mut map = s.serialize_map(Some(self.entries.len()))?;
326        for (k, v) in &self.entries {
327            map.serialize_entry(k.as_ref(), v.as_ref())?;
328        }
329        map.end()
330    }
331}
332
333#[cfg(feature = "serde")]
334impl<'de> Deserialize<'de> for Details {
335    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
336        use serde::de::{MapAccess, Visitor};
337
338        struct DetailsVisitor;
339
340        impl<'de> Visitor<'de> for DetailsVisitor {
341            type Value = Details;
342
343            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344                f.write_str("a map of string key-value pairs")
345            }
346
347            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Details, A::Error> {
348                // Drain to completion regardless of size (#487: a naive early-exit
349                // once 8 entries are collected leaves trailing map bytes unconsumed
350                // and corrupts the surrounding deserializer). Only the first 8
351                // ordinary pairs are retained in memory as they arrive; pairs
352                // beyond that are counted, not stored, so an adversarially
353                // large map can't inflate memory.
354                //
355                // `DETAILS_TRUNCATED_KEY` is reserved (PR #549 round-2 review):
356                // it is never stored as an ordinary entry. Instead we track
357                // whether the wire map looks exactly like our own truncated
358                // serialization — the reserved key appears exactly once, as
359                // the very last pair, immediately after exactly 7 ordinary
360                // pairs, with a value that parses as a count — and if so,
361                // restore that as the trusted drop count (round-trip of a
362                // `Details` we truncated ourselves). Any other occurrence
363                // (wrong position, duplicated, or paired with an ordinary
364                // count that isn't 7) is treated as a client-supplied
365                // collision: stripped and folded into `Details::build`'s
366                // drop accounting like any other reserved-key collision.
367                let mut ordinary: alloc::vec::Vec<(Cow<'static, str>, Cow<'static, str>)> =
368                    alloc::vec::Vec::new();
369                let mut total_ordinary: usize = 0;
370                let mut reserved_count: usize = 0;
371                let mut reserved_is_trailing = false;
372                let mut reserved_follows_seven = false;
373                let mut last_reserved_value: Option<String> = None;
374                while let Some((k, v)) = map.next_entry::<String, String>()? {
375                    if k == DETAILS_TRUNCATED_KEY {
376                        reserved_count += 1;
377                        reserved_is_trailing = true;
378                        reserved_follows_seven = total_ordinary == 7;
379                        last_reserved_value = Some(v);
380                    } else {
381                        reserved_is_trailing = false;
382                        total_ordinary += 1;
383                        if ordinary.len() < 8 {
384                            ordinary.push((Cow::Owned(k), Cow::Owned(v)));
385                        }
386                    }
387                }
388                if reserved_count == 1 && reserved_is_trailing && reserved_follows_seven {
389                    if let Some(dropped) =
390                        last_reserved_value.as_deref().and_then(|s| s.parse().ok())
391                    {
392                        let mut entries = ordinary;
393                        entries.push((
394                            Cow::Borrowed(DETAILS_TRUNCATED_KEY),
395                            Cow::Owned(alloc::format!("{dropped}")),
396                        ));
397                        return Ok(Details {
398                            entries,
399                            dropped: Some(dropped),
400                        });
401                    }
402                }
403                Ok(Details::build(ordinary, total_ordinary, reserved_count))
404            }
405        }
406
407        d.deserialize_map(DetailsVisitor)
408    }
409}
410
411// ---- KhiveError ----
412
413/// Unified error type for the khive runtime.
414///
415/// # Wire shape (serde)
416///
417/// ```json
418/// {
419///   "kind": "not_found",
420///   "message": "entity not found: abc123",
421///   "code": "runtime:10",
422///   "details": { "resource": "entity", "id": "abc123" }
423/// }
424/// ```
425///
426/// `code` and `details` are `null` when absent.
427#[derive(Clone, Debug)]
428#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
429pub struct KhiveError {
430    kind: ErrorKind,
431    message: String,
432    code: Option<ErrorCode>,
433    details: Option<Details>,
434}
435
436impl KhiveError {
437    // ---- constructors ----
438
439    /// Create a `NotFound` error for a missing resource identified by `id`.
440    pub fn not_found(resource: impl fmt::Display, id: impl fmt::Display) -> Self {
441        Self {
442            kind: ErrorKind::NotFound,
443            message: alloc::format!("{resource} not found: {id}"),
444            code: None,
445            details: None,
446        }
447    }
448
449    /// Create an `InvalidInput` error with the given message.
450    pub fn invalid_input(message: impl Into<String>) -> Self {
451        Self {
452            kind: ErrorKind::InvalidInput,
453            message: alloc::format!("invalid input: {}", message.into()),
454            code: None,
455            details: None,
456        }
457    }
458
459    /// Create an `Unauthorized` error with the given message.
460    pub fn unauthorized(message: impl Into<String>) -> Self {
461        Self {
462            kind: ErrorKind::Unauthorized,
463            message: alloc::format!("unauthorized: {}", message.into()),
464            code: None,
465            details: None,
466        }
467    }
468
469    /// Create a `Conflict` error with the given message.
470    pub fn conflict(message: impl Into<String>) -> Self {
471        Self {
472            kind: ErrorKind::Conflict,
473            message: alloc::format!("conflict: {}", message.into()),
474            code: None,
475            details: None,
476        }
477    }
478
479    /// Create an `Unavailable` error with the given message.
480    pub fn unavailable(message: impl Into<String>) -> Self {
481        Self {
482            kind: ErrorKind::Unavailable,
483            message: alloc::format!("unavailable: {}", message.into()),
484            code: None,
485            details: None,
486        }
487    }
488
489    /// Create an `Internal` error with the given message.
490    pub fn internal(message: impl Into<String>) -> Self {
491        Self {
492            kind: ErrorKind::Internal,
493            message: alloc::format!("internal: {}", message.into()),
494            code: None,
495            details: None,
496        }
497    }
498
499    // ---- builder methods ----
500
501    /// Attach a domain-scoped error code.
502    pub fn with_code(mut self, code: ErrorCode) -> Self {
503        self.code = Some(code);
504        self
505    }
506
507    /// Attach bounded key-value metadata.
508    pub fn with_details(mut self, details: Details) -> Self {
509        self.details = Some(details);
510        self
511    }
512
513    // ---- accessors ----
514
515    /// Return the semantic error category.
516    pub fn kind(&self) -> ErrorKind {
517        self.kind
518    }
519
520    /// Return the human-readable error message.
521    pub fn message(&self) -> &str {
522        &self.message
523    }
524
525    /// Return the domain-scoped error code, if set.
526    pub fn code(&self) -> Option<ErrorCode> {
527        self.code
528    }
529
530    /// Return the bounded metadata details, if set.
531    pub fn details(&self) -> Option<&Details> {
532        self.details.as_ref()
533    }
534
535    /// Retry guidance based on the error kind.
536    pub fn retry_hint(&self) -> RetryHint {
537        match self.kind {
538            ErrorKind::Unavailable => RetryHint::Retryable,
539            _ => RetryHint::NoRetry,
540        }
541    }
542}
543
544impl fmt::Display for KhiveError {
545    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546        write!(f, "{}", self.message)
547    }
548}
549
550#[cfg(feature = "std")]
551impl std::error::Error for KhiveError {}