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 {}