Skip to main content

systemprompt_models/api/errors/
mod.rs

1//! The single HTTP error model: [`ApiError`] with its [`ErrorCode`] status
2//! class, plus [`ValidationError`] field detail.
3//!
4//! An [`ApiError`] carries a stable machine code, a public message and,
5//! optionally, the internal cause as a source that is logged and never
6//! serialised. The wire shape enforces the redaction rule itself: a 5xx
7//! serialises the fixed public message of its code with no details or
8//! validation errors, whatever text the error was built with, so internal
9//! error text cannot reach a response body. Repository errors convert through
10//! the one canonical `From<RepositoryError>` mapping in the `repository`
11//! module; an identifier that fails to parse converts through
12//! `From<IdValidationError>` (400) in the `identifier` module.
13//!
14//! Copyright (c) systemprompt.io — Business Source License 1.1.
15//! See <https://systemprompt.io> for licensing details.
16
17mod extension;
18mod identifier;
19mod repository;
20#[cfg(feature = "web")]
21mod response;
22mod wire;
23
24use std::error::Error;
25
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29use systemprompt_identifiers::TraceId;
30use systemprompt_traits::BoxedSource;
31
32#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ErrorCode {
35    NotFound,
36    BadRequest,
37    Unauthorized,
38    Forbidden,
39    InternalError,
40    ValidationError,
41    ConflictError,
42    RateLimited,
43    ServiceUnavailable,
44}
45
46impl ErrorCode {
47    #[must_use]
48    pub const fn is_server_error(self) -> bool {
49        matches!(self, Self::InternalError | Self::ServiceUnavailable)
50    }
51
52    #[must_use]
53    pub const fn public_server_message(self) -> &'static str {
54        match self {
55            Self::ServiceUnavailable => "Service temporarily unavailable",
56            _ => "Internal server error",
57        }
58    }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ValidationError {
63    pub field: String,
64    pub message: String,
65    pub code: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    // JSON: Validation context echoes the offending request fragment, whatever its shape.
68    pub context: Option<Value>,
69}
70
71/// The HTTP error envelope every non-protocol route answers with.
72#[derive(Debug, Deserialize)]
73pub struct ApiError {
74    pub code: ErrorCode,
75    pub message: String,
76    #[serde(default)]
77    pub details: Option<String>,
78    #[serde(default)]
79    pub error_key: Option<String>,
80    #[serde(default)]
81    pub path: Option<String>,
82    #[serde(default)]
83    pub validation_errors: Vec<ValidationError>,
84    pub timestamp: DateTime<Utc>,
85    #[serde(default)]
86    pub trace_id: Option<TraceId>,
87    #[serde(skip)]
88    source: Option<BoxedSource>,
89}
90
91impl ApiError {
92    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
93        Self {
94            code,
95            message: message.into(),
96            details: None,
97            error_key: None,
98            path: None,
99            validation_errors: Vec::new(),
100            timestamp: Utc::now(),
101            trace_id: None,
102            source: None,
103        }
104    }
105
106    #[must_use]
107    pub fn with_details(mut self, details: impl Into<String>) -> Self {
108        self.details = Some(details.into());
109        self
110    }
111
112    #[must_use]
113    pub fn with_error_key(mut self, key: impl Into<String>) -> Self {
114        self.error_key = Some(key.into());
115        self
116    }
117
118    #[must_use]
119    pub fn with_path(mut self, path: impl Into<String>) -> Self {
120        self.path = Some(path.into());
121        self
122    }
123
124    #[must_use]
125    pub fn with_validation_errors(mut self, errors: Vec<ValidationError>) -> Self {
126        self.validation_errors = errors;
127        self
128    }
129
130    #[must_use]
131    pub fn with_trace_id(mut self, id: TraceId) -> Self {
132        self.trace_id = Some(id);
133        self
134    }
135
136    #[must_use]
137    pub fn with_source(mut self, source: impl Into<BoxedSource>) -> Self {
138        self.source = Some(source.into());
139        self
140    }
141
142    pub fn source(&self) -> Option<&(dyn Error + Send + Sync + 'static)> {
143        self.source.as_deref()
144    }
145
146    pub fn not_found(message: impl Into<String>) -> Self {
147        Self::new(ErrorCode::NotFound, message)
148    }
149
150    pub fn bad_request(message: impl Into<String>) -> Self {
151        Self::new(ErrorCode::BadRequest, message)
152    }
153
154    pub fn unauthorized(message: impl Into<String>) -> Self {
155        Self::new(ErrorCode::Unauthorized, message)
156    }
157
158    pub fn forbidden(message: impl Into<String>) -> Self {
159        Self::new(ErrorCode::Forbidden, message)
160    }
161
162    pub fn conflict(message: impl Into<String>) -> Self {
163        Self::new(ErrorCode::ConflictError, message)
164    }
165
166    pub fn rate_limited(message: impl Into<String>) -> Self {
167        Self::new(ErrorCode::RateLimited, message)
168    }
169
170    pub fn validation_error(message: impl Into<String>, errors: Vec<ValidationError>) -> Self {
171        Self::new(ErrorCode::ValidationError, message).with_validation_errors(errors)
172    }
173
174    pub fn internal_error(context: &'static str) -> Self {
175        Self::new(ErrorCode::InternalError, context)
176    }
177
178    pub fn internal(context: &'static str, source: impl Into<BoxedSource>) -> Self {
179        Self::internal_error(context).with_source(source)
180    }
181
182    pub fn service_unavailable(context: &'static str) -> Self {
183        Self::new(ErrorCode::ServiceUnavailable, context)
184    }
185}