Skip to main content

yeti_types/error/
mod.rs

1//! Domain-specific error types for the Yeti platform.
2//!
3//! Provides structured, type-safe errors with HTTP status code mapping,
4//! context extension, and convenience error types for handlers.
5//!
6//! The top-level [`crate::error::YetiError`] composes domain-specific
7//! error enums from the `domain` submodule via `#[from]`, and exposes
8//! HTTP status / metadata helpers for the response layer.
9//!
10//! # Errors
11//!
12//! `ResultExt` adapters (`with_file_context`, `with_config_context`,
13//! `with_request_context`, `with_context`, `with_json_context`) return
14//! the `Err` variant of the input `Result` rewrapped with additional
15//! context — they don't *introduce* failure modes, only annotate them.
16//! The resulting `YetiError` variant matches the adapter name (`Internal`
17//! for file/request, `Config` for config, etc.).
18#![allow(clippy::missing_errors_doc)]
19
20use std::path::Path;
21
22mod domain;
23
24pub use domain::{BackendError, EncodingError, IndexError, QueryError, SchemaError, StorageError};
25
26/// Main error type for Yeti platform operations.
27#[derive(Debug, thiserror::Error)]
28pub enum YetiError {
29    /// Storage layer errors (`RocksDB`, I/O, corruption)
30    #[error("Storage error: {0}")]
31    Storage(#[from] StorageError),
32
33    /// Query parsing and evaluation errors
34    #[error("Query error: {0}")]
35    Query(#[from] QueryError),
36
37    /// Schema validation and definition errors
38    #[error("Schema error: {0}")]
39    Schema(#[from] SchemaError),
40
41    /// Resource not found (404)
42    #[error("Resource not found: {resource_type} with id '{id}'")]
43    NotFound {
44        /// Type of the resource that was not found
45        resource_type: String,
46        /// Identifier of the missing resource
47        id: String,
48    },
49
50    /// Request validation errors (400)
51    #[error("Validation error: {0}")]
52    Validation(String),
53
54    /// Encoding/decoding errors
55    #[error("Encoding error: {0}")]
56    Encoding(#[from] EncodingError),
57
58    /// Backend management errors
59    #[error("Backend error: {0}")]
60    Backend(#[from] BackendError),
61
62    /// Index-related errors
63    #[error("Index error: {0}")]
64    Index(#[from] IndexError),
65
66    /// Configuration errors
67    #[error("Configuration error: {0}")]
68    Config(String),
69
70    /// Unauthorized (401)
71    #[error("Unauthorized: {0}")]
72    Unauthorized(String),
73
74    /// Forbidden (403)
75    #[error("Forbidden: {0}")]
76    Forbidden(String),
77
78    /// Generic internal errors (500)
79    #[error("Internal error: {0}")]
80    Internal(String),
81
82    /// Rate-limit exhausted (429). Carries the retry hint so the
83    /// HTTP layer can emit `Retry-After: <secs>`.
84    #[error("Rate limited: {message}")]
85    RateLimited {
86        /// Seconds until the next token refills (>= 1).
87        retry_after_secs: u64,
88        /// Human-readable detail for the response body.
89        message: String,
90    },
91
92    /// Compare-and-swap failures from `Table::put_if` / `put_confirmed`.
93    #[error("CAS failure: {reason}")]
94    Cas {
95        /// Why the CAS failed (mismatch, timeout, insufficient peers).
96        reason: CasReason,
97    },
98}
99
100/// Reason a `Table::put_if` or `put_confirmed` call failed.
101#[derive(Debug, Clone, thiserror::Error)]
102pub enum CasReason {
103    /// The on-disk value did not match the expected bytes (another writer won).
104    #[error("expected-value mismatch")]
105    Mismatch,
106    /// Quorum timed out before achieving enough acknowledgements.
107    #[error("quorum timeout (achieved {achieved})")]
108    Timeout {
109        /// Number of peers that did acknowledge before timeout.
110        achieved: u32,
111    },
112    /// Fewer peers are reachable than the quorum requires.
113    #[error("insufficient peers: needed {needed}, available {available}")]
114    InsufficientPeers {
115        /// Quorum needed.
116        needed: u32,
117        /// Peers available.
118        available: u32,
119    },
120}
121
122/// Convenient type alias for Result with `YetiError`.
123pub type Result<T, E = YetiError> = std::result::Result<T, E>;
124
125// ============================================================================
126// From impls for common error types
127// ============================================================================
128
129impl From<serde_json::Error> for YetiError {
130    fn from(err: serde_json::Error) -> Self {
131        Self::Encoding(EncodingError::Json(err.to_string()))
132    }
133}
134
135impl From<std::io::Error> for YetiError {
136    fn from(err: std::io::Error) -> Self {
137        Self::Storage(StorageError::Io(err))
138    }
139}
140
141impl From<http::Error> for YetiError {
142    fn from(err: http::Error) -> Self {
143        Self::Internal(format!("HTTP error: {err}"))
144    }
145}
146
147impl From<std::num::ParseIntError> for YetiError {
148    fn from(err: std::num::ParseIntError) -> Self {
149        Self::Validation(format!("Invalid integer: {err}"))
150    }
151}
152
153impl From<std::num::ParseFloatError> for YetiError {
154    fn from(err: std::num::ParseFloatError) -> Self {
155        Self::Validation(format!("Invalid float: {err}"))
156    }
157}
158
159impl From<glob::PatternError> for YetiError {
160    fn from(err: glob::PatternError) -> Self {
161        Self::Validation(format!("Invalid glob pattern: {err}"))
162    }
163}
164
165// ============================================================================
166// HTTP Error Metadata
167// ============================================================================
168
169/// Metadata for structured error responses.
170#[derive(Debug)]
171pub struct ErrorMetadata {
172    /// HTTP status code (e.g., 400, 404, 500)
173    pub status_code: u16,
174    /// Error category for metrics (e.g., "validation", "storage")
175    pub error_type: &'static str,
176    /// Machine-readable error code for API responses (e.g., "`NOT_FOUND`")
177    pub error_code: &'static str,
178}
179
180/// RFC 9457 Problem Details response body.
181///
182/// See: <https://www.rfc-editor.org/rfc/rfc9457.html>
183#[derive(Debug, Clone, serde::Serialize)]
184pub struct ProblemDetails {
185    /// URI reference identifying the problem type (e.g., "`urn:yeti:error:not_found`")
186    #[serde(rename = "type")]
187    pub problem_type: String,
188    /// Short human-readable summary (e.g., "Not Found")
189    pub title: &'static str,
190    /// HTTP status code
191    pub status: u16,
192    /// Human-readable explanation of this specific occurrence
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub detail: Option<String>,
195}
196
197impl YetiError {
198    /// Get all error metadata in one match (single source of truth).
199    #[must_use]
200    pub const fn metadata(&self) -> ErrorMetadata {
201        match self {
202            Self::NotFound { .. } => ErrorMetadata {
203                status_code: 404,
204                error_type: "not_found",
205                error_code: "NOT_FOUND",
206            },
207            Self::Validation(_) => ErrorMetadata {
208                status_code: 400,
209                error_type: "validation",
210                error_code: "VALIDATION_ERROR",
211            },
212            Self::Query(QueryError::ParseError(_)) => ErrorMetadata {
213                status_code: 400,
214                error_type: "query",
215                error_code: "QUERY_PARSE_ERROR",
216            },
217            Self::Query(_) => ErrorMetadata {
218                status_code: 400,
219                error_type: "query",
220                error_code: "QUERY_ERROR",
221            },
222            Self::Schema(_) => ErrorMetadata {
223                status_code: 400,
224                error_type: "schema",
225                error_code: "SCHEMA_ERROR",
226            },
227            Self::Encoding(_) => ErrorMetadata {
228                status_code: 400,
229                error_type: "encoding",
230                error_code: "ENCODING_ERROR",
231            },
232            Self::Storage(StorageError::WriteConflict(_)) => ErrorMetadata {
233                status_code: 409,
234                error_type: "storage",
235                error_code: "WRITE_CONFLICT",
236            },
237            Self::Storage(_) => ErrorMetadata {
238                status_code: 500,
239                error_type: "storage",
240                error_code: "STORAGE_ERROR",
241            },
242            Self::Backend(_) => ErrorMetadata {
243                status_code: 500,
244                error_type: "backend",
245                error_code: "BACKEND_ERROR",
246            },
247            Self::Index(_) => ErrorMetadata {
248                status_code: 500,
249                error_type: "index",
250                error_code: "INDEX_ERROR",
251            },
252            Self::Config(_) => ErrorMetadata {
253                status_code: 500,
254                error_type: "config",
255                error_code: "CONFIG_ERROR",
256            },
257            Self::Unauthorized(_) => ErrorMetadata {
258                status_code: 401,
259                error_type: "unauthorized",
260                error_code: "UNAUTHORIZED",
261            },
262            Self::Forbidden(_) => ErrorMetadata {
263                status_code: 403,
264                error_type: "forbidden",
265                error_code: "FORBIDDEN",
266            },
267            Self::Internal(_) => ErrorMetadata {
268                status_code: 500,
269                error_type: "internal",
270                error_code: "INTERNAL_ERROR",
271            },
272            Self::RateLimited { .. } => ErrorMetadata {
273                status_code: 429,
274                error_type: "rate_limited",
275                error_code: "RATE_LIMITED",
276            },
277            Self::Cas { reason } => match reason {
278                CasReason::Mismatch => ErrorMetadata {
279                    status_code: 409,
280                    error_type: "cas",
281                    error_code: "CAS_MISMATCH",
282                },
283                CasReason::Timeout { .. } => ErrorMetadata {
284                    status_code: 504,
285                    error_type: "cas",
286                    error_code: "CAS_TIMEOUT",
287                },
288                CasReason::InsufficientPeers { .. } => ErrorMetadata {
289                    status_code: 503,
290                    error_type: "cas",
291                    error_code: "CAS_INSUFFICIENT_PEERS",
292                },
293            },
294        }
295    }
296
297    /// Build an RFC 9457 Problem Details response body from this error.
298    #[must_use]
299    pub fn to_problem_details(&self) -> ProblemDetails {
300        let meta = self.metadata();
301        ProblemDetails {
302            problem_type: format!("urn:yeti:error:{}", meta.error_code.to_lowercase()),
303            title: meta.error_type,
304            status: meta.status_code,
305            detail: Some(self.to_string()),
306        }
307    }
308
309    /// Get the HTTP status code for this error.
310    #[inline]
311    #[must_use]
312    pub const fn status_code(&self) -> u16 {
313        self.metadata().status_code
314    }
315
316    /// Get error type string for metrics.
317    #[inline]
318    #[must_use]
319    pub const fn error_type(&self) -> &'static str {
320        self.metadata().error_type
321    }
322
323    /// Get a structured error code for API responses.
324    #[inline]
325    #[must_use]
326    pub const fn error_code(&self) -> &'static str {
327        self.metadata().error_code
328    }
329
330    /// Check if error is retryable.
331    #[must_use]
332    pub const fn is_retryable(&self) -> bool {
333        matches!(
334            self,
335            Self::Storage(StorageError::Io(_) | StorageError::WriteConflict(_))
336                | Self::Backend(BackendError::NotAvailable { .. })
337        )
338    }
339}
340
341// ============================================================================
342// Convenience Error Types
343// ============================================================================
344
345/// 400 Bad Request error (static string).
346#[derive(Debug, Clone)]
347pub struct BadRequest(pub &'static str);
348
349impl std::fmt::Display for BadRequest {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        write!(f, "{}", self.0)
352    }
353}
354
355impl std::error::Error for BadRequest {}
356
357impl From<BadRequest> for YetiError {
358    fn from(err: BadRequest) -> Self {
359        Self::Validation(err.0.to_owned())
360    }
361}
362
363/// 400 Bad Request error (owned string).
364#[derive(Debug, Clone)]
365pub struct BadRequestOwned(pub String);
366
367impl std::fmt::Display for BadRequestOwned {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        write!(f, "{}", self.0)
370    }
371}
372
373impl std::error::Error for BadRequestOwned {}
374
375impl From<BadRequestOwned> for YetiError {
376    fn from(err: BadRequestOwned) -> Self {
377        Self::Validation(err.0)
378    }
379}
380
381/// 401 Unauthorized error.
382#[derive(Debug, Clone)]
383pub struct Unauthorized(pub &'static str);
384
385impl std::fmt::Display for Unauthorized {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        write!(f, "{}", self.0)
388    }
389}
390
391impl std::error::Error for Unauthorized {}
392
393impl From<Unauthorized> for YetiError {
394    fn from(err: Unauthorized) -> Self {
395        Self::Unauthorized(err.0.to_owned())
396    }
397}
398
399/// 403 Forbidden error.
400#[derive(Debug, Clone)]
401pub struct Forbidden(pub &'static str);
402
403impl std::fmt::Display for Forbidden {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        write!(f, "{}", self.0)
406    }
407}
408
409impl std::error::Error for Forbidden {}
410
411impl From<Forbidden> for YetiError {
412    fn from(err: Forbidden) -> Self {
413        Self::Forbidden(err.0.to_owned())
414    }
415}
416
417/// 404 Not Found error.
418#[derive(Debug, Clone)]
419pub struct NotFoundError(pub &'static str);
420
421impl std::fmt::Display for NotFoundError {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        write!(f, "{}", self.0)
424    }
425}
426
427impl std::error::Error for NotFoundError {}
428
429impl From<NotFoundError> for YetiError {
430    fn from(err: NotFoundError) -> Self {
431        Self::NotFound {
432            resource_type: "Resource".to_owned(),
433            id: err.0.to_owned(),
434        }
435    }
436}
437
438// ============================================================================
439// Context Extension Trait
440// ============================================================================
441
442/// Extension trait for adding context to Result types.
443pub trait ResultExt<T> {
444    /// Add file read context to an error.
445    fn with_file_context(self, path: impl AsRef<Path>) -> Result<T>;
446
447    /// Add config parsing context to an error.
448    fn with_config_context(self, msg: &str) -> Result<T>;
449
450    /// Add generic context to an error with a custom message.
451    fn with_context<F>(self, f: F) -> Result<T>
452    where
453        F: FnOnce() -> String;
454
455    /// Add request building context to an error.
456    fn with_request_context(self) -> Result<T>;
457
458    /// Add JSON encoding/decoding context to an error.
459    fn with_json_context(self, context: &str) -> Result<T>;
460}
461
462impl<T, E: std::fmt::Display> ResultExt<T> for std::result::Result<T, E> {
463    fn with_file_context(self, path: impl AsRef<Path>) -> Result<T> {
464        self.map_err(|e| {
465            YetiError::Internal(format!(
466                "Failed to read file {}: {e}",
467                path.as_ref().display()
468            ))
469        })
470    }
471
472    fn with_config_context(self, msg: &str) -> Result<T> {
473        self.map_err(|e| YetiError::Config(format!("{msg}: {e}")))
474    }
475
476    fn with_context<F>(self, f: F) -> Result<T>
477    where
478        F: FnOnce() -> String,
479    {
480        self.map_err(|e| YetiError::Internal(format!("{}: {}", f(), e)))
481    }
482
483    fn with_request_context(self) -> Result<T> {
484        self.map_err(|e| YetiError::Internal(format!("Failed to build request: {e}")))
485    }
486
487    fn with_json_context(self, context: &str) -> Result<T> {
488        self.map_err(|e| YetiError::Encoding(EncodingError::Json(format!("{context}: {e}"))))
489    }
490}