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 config_plane;
18mod extension;
19mod identifier;
20mod repository;
21#[cfg(feature = "web")]
22mod response;
23mod wire;
24
25use std::error::Error;
26
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use systemprompt_identifiers::TraceId;
31use systemprompt_traits::BoxedSource;
32
33#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ErrorCode {
36    NotFound,
37    BadRequest,
38    Unauthorized,
39    Forbidden,
40    InternalError,
41    ValidationError,
42    ConflictError,
43    RateLimited,
44    ServiceUnavailable,
45}
46
47impl ErrorCode {
48    #[must_use]
49    pub const fn is_server_error(self) -> bool {
50        matches!(self, Self::InternalError | Self::ServiceUnavailable)
51    }
52
53    #[must_use]
54    pub const fn public_server_message(self) -> &'static str {
55        match self {
56            Self::ServiceUnavailable => "Service temporarily unavailable",
57            _ => "Internal server error",
58        }
59    }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ValidationError {
64    pub field: String,
65    pub message: String,
66    pub code: String,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    // JSON: Validation context echoes the offending request fragment, whatever its shape.
69    pub context: Option<Value>,
70}
71
72/// The HTTP error envelope every non-protocol route answers with.
73#[derive(Debug, Deserialize)]
74pub struct ApiError {
75    pub code: ErrorCode,
76    pub message: String,
77    #[serde(default)]
78    pub details: Option<String>,
79    #[serde(default)]
80    pub error_key: Option<String>,
81    #[serde(default)]
82    pub path: Option<String>,
83    #[serde(default)]
84    pub validation_errors: Vec<ValidationError>,
85    pub timestamp: DateTime<Utc>,
86    #[serde(default)]
87    pub trace_id: Option<TraceId>,
88    #[serde(skip)]
89    source: Option<BoxedSource>,
90}
91
92impl ApiError {
93    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
94        Self {
95            code,
96            message: message.into(),
97            details: None,
98            error_key: None,
99            path: None,
100            validation_errors: Vec::new(),
101            timestamp: Utc::now(),
102            trace_id: None,
103            source: None,
104        }
105    }
106
107    #[must_use]
108    pub fn with_details(mut self, details: impl Into<String>) -> Self {
109        self.details = Some(details.into());
110        self
111    }
112
113    #[must_use]
114    pub fn with_error_key(mut self, key: impl Into<String>) -> Self {
115        self.error_key = Some(key.into());
116        self
117    }
118
119    #[must_use]
120    pub fn with_path(mut self, path: impl Into<String>) -> Self {
121        self.path = Some(path.into());
122        self
123    }
124
125    #[must_use]
126    pub fn with_validation_errors(mut self, errors: Vec<ValidationError>) -> Self {
127        self.validation_errors = errors;
128        self
129    }
130
131    #[must_use]
132    pub fn with_trace_id(mut self, id: TraceId) -> Self {
133        self.trace_id = Some(id);
134        self
135    }
136
137    #[must_use]
138    pub fn with_source(mut self, source: impl Into<BoxedSource>) -> Self {
139        self.source = Some(source.into());
140        self
141    }
142
143    pub fn source(&self) -> Option<&(dyn Error + Send + Sync + 'static)> {
144        self.source.as_deref()
145    }
146
147    pub fn not_found(message: impl Into<String>) -> Self {
148        Self::new(ErrorCode::NotFound, message)
149    }
150
151    pub fn bad_request(message: impl Into<String>) -> Self {
152        Self::new(ErrorCode::BadRequest, message)
153    }
154
155    pub fn unauthorized(message: impl Into<String>) -> Self {
156        Self::new(ErrorCode::Unauthorized, message)
157    }
158
159    pub fn forbidden(message: impl Into<String>) -> Self {
160        Self::new(ErrorCode::Forbidden, message)
161    }
162
163    pub fn conflict(message: impl Into<String>) -> Self {
164        Self::new(ErrorCode::ConflictError, message)
165    }
166
167    pub fn rate_limited(message: impl Into<String>) -> Self {
168        Self::new(ErrorCode::RateLimited, message)
169    }
170
171    pub fn validation_error(message: impl Into<String>, errors: Vec<ValidationError>) -> Self {
172        Self::new(ErrorCode::ValidationError, message).with_validation_errors(errors)
173    }
174
175    pub fn internal_error(context: &'static str) -> Self {
176        Self::new(ErrorCode::InternalError, context)
177    }
178
179    pub fn internal(context: &'static str, source: impl Into<BoxedSource>) -> Self {
180        Self::internal_error(context).with_source(source)
181    }
182
183    pub fn service_unavailable(context: &'static str) -> Self {
184        Self::new(ErrorCode::ServiceUnavailable, context)
185    }
186}