Skip to main content

systemprompt_models/api/errors/
mod.rs

1//! Public HTTP error envelope ([`ApiError`], [`ErrorCode`],
2//! [`ValidationError`], [`ErrorResponse`]) plus the internal
3//! `thiserror`-derived [`InternalApiError`] used by the application
4//! tier.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9mod internal;
10
11pub use internal::InternalApiError;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17#[cfg(feature = "web")]
18use axum::Json;
19#[cfg(feature = "web")]
20use axum::http::{StatusCode, header};
21#[cfg(feature = "web")]
22use axum::response::IntoResponse;
23
24#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ErrorCode {
27    NotFound,
28    BadRequest,
29    Unauthorized,
30    Forbidden,
31    InternalError,
32    ValidationError,
33    ConflictError,
34    RateLimited,
35    ServiceUnavailable,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ValidationError {
40    pub field: String,
41    pub message: String,
42    pub code: String,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub context: Option<Value>,
45}
46
47#[derive(Debug, Serialize, Deserialize)]
48pub struct ApiError {
49    pub code: ErrorCode,
50    pub message: String,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub details: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub error_key: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub path: Option<String>,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub validation_errors: Vec<ValidationError>,
59    pub timestamp: DateTime<Utc>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub trace_id: Option<String>,
62}
63
64impl ApiError {
65    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
66        Self {
67            code,
68            message: message.into(),
69            details: None,
70            error_key: None,
71            path: None,
72            validation_errors: Vec::new(),
73            timestamp: Utc::now(),
74            trace_id: None,
75        }
76    }
77
78    #[must_use]
79    pub fn with_details(mut self, details: impl Into<String>) -> Self {
80        self.details = Some(details.into());
81        self
82    }
83
84    #[must_use]
85    pub fn with_error_key(mut self, key: impl Into<String>) -> Self {
86        self.error_key = Some(key.into());
87        self
88    }
89
90    #[must_use]
91    pub fn with_path(mut self, path: impl Into<String>) -> Self {
92        self.path = Some(path.into());
93        self
94    }
95
96    #[must_use]
97    pub fn with_validation_errors(mut self, errors: Vec<ValidationError>) -> Self {
98        self.validation_errors = errors;
99        self
100    }
101
102    #[must_use]
103    pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
104        self.trace_id = Some(id.into());
105        self
106    }
107
108    pub fn not_found(message: impl Into<String>) -> Self {
109        Self::new(ErrorCode::NotFound, message)
110    }
111
112    pub fn bad_request(message: impl Into<String>) -> Self {
113        Self::new(ErrorCode::BadRequest, message)
114    }
115
116    pub fn unauthorized(message: impl Into<String>) -> Self {
117        Self::new(ErrorCode::Unauthorized, message)
118    }
119
120    pub fn forbidden(message: impl Into<String>) -> Self {
121        Self::new(ErrorCode::Forbidden, message)
122    }
123
124    pub fn internal_error(message: impl Into<String>) -> Self {
125        Self::new(ErrorCode::InternalError, message)
126    }
127
128    pub fn validation_error(message: impl Into<String>, errors: Vec<ValidationError>) -> Self {
129        Self::new(ErrorCode::ValidationError, message).with_validation_errors(errors)
130    }
131
132    pub fn conflict(message: impl Into<String>) -> Self {
133        Self::new(ErrorCode::ConflictError, message)
134    }
135}
136
137#[derive(Debug, Serialize, Deserialize)]
138pub struct ErrorResponse {
139    pub error: ApiError,
140    pub api_version: String,
141}
142
143#[cfg(feature = "web")]
144impl ErrorCode {
145    #[must_use]
146    pub const fn status_code(&self) -> StatusCode {
147        match self {
148            Self::NotFound => StatusCode::NOT_FOUND,
149            Self::BadRequest => StatusCode::BAD_REQUEST,
150            Self::Unauthorized => StatusCode::UNAUTHORIZED,
151            Self::Forbidden => StatusCode::FORBIDDEN,
152            Self::ValidationError => StatusCode::UNPROCESSABLE_ENTITY,
153            Self::ConflictError => StatusCode::CONFLICT,
154            Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
155            Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
156            Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
157        }
158    }
159}
160
161#[cfg(feature = "web")]
162impl IntoResponse for ApiError {
163    fn into_response(self) -> axum::response::Response {
164        let status = self.code.status_code();
165
166        if status.is_server_error() {
167            tracing::error!(
168                error_code = ?self.code,
169                message = %self.message,
170                path = ?self.path,
171                trace_id = ?self.trace_id,
172                "API server error response"
173            );
174        } else if status.is_client_error() {
175            tracing::warn!(
176                error_code = ?self.code,
177                message = %self.message,
178                path = ?self.path,
179                trace_id = ?self.trace_id,
180                "API client error response"
181            );
182        }
183
184        let mut response = (status, Json(self)).into_response();
185
186        if status == StatusCode::UNAUTHORIZED
187            && let Ok(header_value) =
188                "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"".parse()
189        {
190            response
191                .headers_mut()
192                .insert(header::WWW_AUTHENTICATE, header_value);
193        }
194
195        response
196    }
197}