1#![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#[derive(Debug, thiserror::Error)]
28pub enum YetiError {
29 #[error("Storage error: {0}")]
31 Storage(#[from] StorageError),
32
33 #[error("Query error: {0}")]
35 Query(#[from] QueryError),
36
37 #[error("Schema error: {0}")]
39 Schema(#[from] SchemaError),
40
41 #[error("Resource not found: {resource_type} with id '{id}'")]
43 NotFound {
44 resource_type: String,
46 id: String,
48 },
49
50 #[error("Validation error: {0}")]
52 Validation(String),
53
54 #[error("Encoding error: {0}")]
56 Encoding(#[from] EncodingError),
57
58 #[error("Backend error: {0}")]
60 Backend(#[from] BackendError),
61
62 #[error("Index error: {0}")]
64 Index(#[from] IndexError),
65
66 #[error("Configuration error: {0}")]
68 Config(String),
69
70 #[error("Unauthorized: {0}")]
72 Unauthorized(String),
73
74 #[error("Forbidden: {0}")]
76 Forbidden(String),
77
78 #[error("Internal error: {0}")]
80 Internal(String),
81
82 #[error("Rate limited: {message}")]
85 RateLimited {
86 retry_after_secs: u64,
88 message: String,
90 },
91
92 #[error("CAS failure: {reason}")]
94 Cas {
95 reason: CasReason,
97 },
98}
99
100#[derive(Debug, Clone, thiserror::Error)]
102pub enum CasReason {
103 #[error("expected-value mismatch")]
105 Mismatch,
106 #[error("quorum timeout (achieved {achieved})")]
108 Timeout {
109 achieved: u32,
111 },
112 #[error("insufficient peers: needed {needed}, available {available}")]
114 InsufficientPeers {
115 needed: u32,
117 available: u32,
119 },
120}
121
122pub type Result<T, E = YetiError> = std::result::Result<T, E>;
124
125impl 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#[derive(Debug)]
171pub struct ErrorMetadata {
172 pub status_code: u16,
174 pub error_type: &'static str,
176 pub error_code: &'static str,
178}
179
180#[derive(Debug, Clone, serde::Serialize)]
184pub struct ProblemDetails {
185 #[serde(rename = "type")]
187 pub problem_type: String,
188 pub title: &'static str,
190 pub status: u16,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub detail: Option<String>,
195}
196
197impl YetiError {
198 #[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 #[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 #[inline]
311 #[must_use]
312 pub const fn status_code(&self) -> u16 {
313 self.metadata().status_code
314 }
315
316 #[inline]
318 #[must_use]
319 pub const fn error_type(&self) -> &'static str {
320 self.metadata().error_type
321 }
322
323 #[inline]
325 #[must_use]
326 pub const fn error_code(&self) -> &'static str {
327 self.metadata().error_code
328 }
329
330 #[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#[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#[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#[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#[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#[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
438pub trait ResultExt<T> {
444 fn with_file_context(self, path: impl AsRef<Path>) -> Result<T>;
446
447 fn with_config_context(self, msg: &str) -> Result<T>;
449
450 fn with_context<F>(self, f: F) -> Result<T>
452 where
453 F: FnOnce() -> String;
454
455 fn with_request_context(self) -> Result<T>;
457
458 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}