Skip to main content

agentics_error/
lib.rs

1use std::borrow::Cow;
2
3use serde::{Deserialize, Serialize};
4
5/// Stable API-facing error code derived from transport-neutral service errors.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
7#[serde(rename_all = "snake_case")]
8pub enum ServiceErrorCode {
9    BadRequest,
10    ValidationFailed,
11    Unauthorized,
12    Forbidden,
13    NotFound,
14    Conflict,
15    TooManyRequests,
16    PayloadTooLarge,
17    InternalError,
18}
19
20/// Optional structured validation detail for one request problem.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
22pub struct ErrorDetail {
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub field: Option<String>,
25    pub message: String,
26}
27
28#[derive(Debug, thiserror::Error)]
29/// Transport-neutral backend error used across shared services and workflows.
30pub enum ServiceError {
31    #[error("database error: {0}")]
32    Database(#[from] sqlx::Error),
33    #[error("not found")]
34    NotFound,
35    #[error("conflict")]
36    Conflict,
37    #[error("{message}")]
38    ConflictDetails {
39        message: String,
40        details: Vec<ErrorDetail>,
41    },
42    #[error("bad request: {0}")]
43    BadRequest(String),
44    #[error("too many requests: {0}")]
45    TooManyRequests(String),
46    #[error("unauthorized")]
47    Unauthorized,
48    #[error("unauthorized: {0}")]
49    UnauthorizedMessage(String),
50    #[error("forbidden: {0}")]
51    Forbidden(String),
52    #[error("internal error: {0}")]
53    Internal(String),
54    #[error("validation error: {0}")]
55    Validation(String),
56    #[error("io error: {0}")]
57    Io(#[from] std::io::Error),
58    #[error("zip error: {0}")]
59    Zip(#[from] zip::result::ZipError),
60    #[error("docker error: {0}")]
61    Docker(String),
62    #[error("runner error: {0}")]
63    Runner(String),
64    #[error("runner capacity unavailable: {0}")]
65    RunnerCapacity(String),
66    #[error("base64 decode error")]
67    Base64,
68    #[error("{message}")]
69    ValidationDetails {
70        message: String,
71        details: Vec<ErrorDetail>,
72    },
73    #[error("payload too large: {0}")]
74    PayloadTooLarge(String),
75}
76
77impl ServiceErrorCode {
78    /// Returns the stable snake_case string for this public error code.
79    pub const fn as_str(self) -> &'static str {
80        match self {
81            ServiceErrorCode::BadRequest => "bad_request",
82            ServiceErrorCode::ValidationFailed => "validation_failed",
83            ServiceErrorCode::Unauthorized => "unauthorized",
84            ServiceErrorCode::Forbidden => "forbidden",
85            ServiceErrorCode::NotFound => "not_found",
86            ServiceErrorCode::Conflict => "conflict",
87            ServiceErrorCode::TooManyRequests => "too_many_requests",
88            ServiceErrorCode::PayloadTooLarge => "payload_too_large",
89            ServiceErrorCode::InternalError => "internal_error",
90        }
91    }
92}
93
94impl std::fmt::Display for ServiceErrorCode {
95    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        formatter.write_str(self.as_str())
97    }
98}
99
100impl ServiceError {
101    /// Builds a bad request error with a public message.
102    pub fn bad_request(message: impl Into<String>) -> Self {
103        Self::BadRequest(message.into())
104    }
105
106    /// Builds a field validation error with structured details.
107    pub fn validation_failed(
108        message: impl Into<String>,
109        details: impl Into<Vec<ErrorDetail>>,
110    ) -> Self {
111        Self::ValidationDetails {
112            message: message.into(),
113            details: details.into(),
114        }
115    }
116
117    /// Builds a not found error.
118    pub fn not_found() -> Self {
119        Self::NotFound
120    }
121
122    /// Builds a conflict error.
123    pub fn conflict() -> Self {
124        Self::Conflict
125    }
126
127    /// Builds a conflict error with structured field details.
128    pub fn conflict_with_details(
129        message: impl Into<String>,
130        details: impl Into<Vec<ErrorDetail>>,
131    ) -> Self {
132        Self::ConflictDetails {
133            message: message.into(),
134            details: details.into(),
135        }
136    }
137
138    /// Builds a quota/rate-limit error with a public message.
139    pub fn too_many_requests(message: impl Into<String>) -> Self {
140        Self::TooManyRequests(message.into())
141    }
142
143    /// Builds an unauthorized error with a public message.
144    pub fn unauthorized(message: impl Into<String>) -> Self {
145        Self::UnauthorizedMessage(message.into())
146    }
147
148    /// Builds an internal error whose message must be redacted at HTTP boundaries.
149    pub fn internal(message: impl Into<String>) -> Self {
150        Self::Internal(message.into())
151    }
152
153    /// Returns the stable public error code.
154    pub fn code(&self) -> ServiceErrorCode {
155        match self {
156            ServiceError::BadRequest(_) | ServiceError::Base64 | ServiceError::Zip(_) => {
157                ServiceErrorCode::BadRequest
158            }
159            ServiceError::Validation(_) | ServiceError::ValidationDetails { .. } => {
160                ServiceErrorCode::ValidationFailed
161            }
162            ServiceError::Unauthorized | ServiceError::UnauthorizedMessage(_) => {
163                ServiceErrorCode::Unauthorized
164            }
165            ServiceError::Forbidden(_) => ServiceErrorCode::Forbidden,
166            ServiceError::NotFound => ServiceErrorCode::NotFound,
167            ServiceError::Conflict | ServiceError::ConflictDetails { .. } => {
168                ServiceErrorCode::Conflict
169            }
170            ServiceError::TooManyRequests(_) => ServiceErrorCode::TooManyRequests,
171            ServiceError::PayloadTooLarge(_) => ServiceErrorCode::PayloadTooLarge,
172            ServiceError::Database(_)
173            | ServiceError::Internal(_)
174            | ServiceError::Io(_)
175            | ServiceError::Docker(_)
176            | ServiceError::Runner(_)
177            | ServiceError::RunnerCapacity(_) => ServiceErrorCode::InternalError,
178        }
179    }
180
181    /// Returns the safe public message for API clients.
182    pub fn public_message(&self) -> Cow<'_, str> {
183        match self {
184            ServiceError::BadRequest(message)
185            | ServiceError::TooManyRequests(message)
186            | ServiceError::Forbidden(message)
187            | ServiceError::Validation(message)
188            | ServiceError::PayloadTooLarge(message) => Cow::Borrowed(message),
189            ServiceError::ValidationDetails { message, .. } => Cow::Borrowed(message),
190            ServiceError::Unauthorized => Cow::Borrowed("unauthorized"),
191            ServiceError::UnauthorizedMessage(message) => Cow::Borrowed(message),
192            ServiceError::NotFound => Cow::Borrowed("not found"),
193            ServiceError::Conflict => Cow::Borrowed("conflict"),
194            ServiceError::ConflictDetails { message, .. } => Cow::Borrowed(message),
195            ServiceError::Base64 => Cow::Borrowed("invalid_base64"),
196            ServiceError::Zip(_) => Cow::Borrowed("invalid_zip"),
197            ServiceError::Database(_)
198            | ServiceError::Internal(_)
199            | ServiceError::Io(_)
200            | ServiceError::Docker(_)
201            | ServiceError::Runner(_)
202            | ServiceError::RunnerCapacity(_) => Cow::Borrowed("internal server error"),
203        }
204    }
205
206    /// Returns structured validation details for API clients.
207    pub fn details(&self) -> &[ErrorDetail] {
208        match self {
209            ServiceError::ValidationDetails { details, .. }
210            | ServiceError::ConflictDetails { details, .. } => details,
211            _ => &[],
212        }
213    }
214
215    /// Returns whether this error should be logged as an internal application failure.
216    pub fn is_internal(&self) -> bool {
217        matches!(self.code(), ServiceErrorCode::InternalError)
218    }
219
220    /// Maps a raw SQL unique-constraint failure into the domain conflict kind.
221    pub fn unique_violation_as_conflict(self) -> Self {
222        match self {
223            ServiceError::Database(sqlx::Error::Database(db_err))
224                if db_err.is_unique_violation() =>
225            {
226                ServiceError::Conflict
227            }
228            error => error,
229        }
230    }
231}
232
233pub type Result<T> = std::result::Result<T, ServiceError>;
234
235#[cfg(test)]
236mod tests {
237    use super::{ErrorDetail, ServiceError, ServiceErrorCode};
238
239    #[test]
240    fn constructors_preserve_public_error_data() {
241        let error = ServiceError::validation_failed(
242            "request validation failed",
243            [ErrorDetail {
244                field: Some("name".to_string()),
245                message: "required".to_string(),
246            }],
247        );
248
249        assert_eq!(error.code(), ServiceErrorCode::ValidationFailed);
250        assert_eq!(error.public_message(), "request validation failed");
251        assert_eq!(error.details().len(), 1);
252    }
253
254    #[test]
255    fn conflict_details_preserve_conflict_code_and_field_data() {
256        let error = ServiceError::conflict_with_details(
257            "duplicate token label",
258            [ErrorDetail {
259                field: Some("label".to_string()),
260                message: "already used".to_string(),
261            }],
262        );
263
264        assert_eq!(error.code(), ServiceErrorCode::Conflict);
265        assert_eq!(error.public_message(), "duplicate token label");
266        assert_eq!(
267            error.details(),
268            &[ErrorDetail {
269                field: Some("label".to_string()),
270                message: "already used".to_string(),
271            }]
272        );
273    }
274
275    #[test]
276    fn internal_errors_are_redacted() {
277        let error = ServiceError::internal("database password leaked here");
278
279        assert_eq!(error.code(), ServiceErrorCode::InternalError);
280        assert_eq!(error.public_message(), "internal server error");
281        assert!(error.details().is_empty());
282    }
283}