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    // JSON: Validation context echoes the offending request fragment, whatever its shape.
45    pub context: Option<Value>,
46}
47
48#[derive(Debug, Serialize, Deserialize)]
49pub struct ApiError {
50    pub code: ErrorCode,
51    pub message: String,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub details: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub error_key: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub path: Option<String>,
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub validation_errors: Vec<ValidationError>,
60    pub timestamp: DateTime<Utc>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub trace_id: Option<String>,
63}
64
65impl ApiError {
66    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
67        Self {
68            code,
69            message: message.into(),
70            details: None,
71            error_key: None,
72            path: None,
73            validation_errors: Vec::new(),
74            timestamp: Utc::now(),
75            trace_id: None,
76        }
77    }
78
79    #[must_use]
80    pub fn with_details(mut self, details: impl Into<String>) -> Self {
81        self.details = Some(details.into());
82        self
83    }
84
85    #[must_use]
86    pub fn with_error_key(mut self, key: impl Into<String>) -> Self {
87        self.error_key = Some(key.into());
88        self
89    }
90
91    #[must_use]
92    pub fn with_path(mut self, path: impl Into<String>) -> Self {
93        self.path = Some(path.into());
94        self
95    }
96
97    #[must_use]
98    pub fn with_validation_errors(mut self, errors: Vec<ValidationError>) -> Self {
99        self.validation_errors = errors;
100        self
101    }
102
103    #[must_use]
104    pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
105        self.trace_id = Some(id.into());
106        self
107    }
108
109    pub fn not_found(message: impl Into<String>) -> Self {
110        Self::new(ErrorCode::NotFound, message)
111    }
112
113    pub fn bad_request(message: impl Into<String>) -> Self {
114        Self::new(ErrorCode::BadRequest, message)
115    }
116
117    pub fn unauthorized(message: impl Into<String>) -> Self {
118        Self::new(ErrorCode::Unauthorized, message)
119    }
120
121    pub fn forbidden(message: impl Into<String>) -> Self {
122        Self::new(ErrorCode::Forbidden, message)
123    }
124
125    pub fn internal_error(message: impl Into<String>) -> Self {
126        Self::new(ErrorCode::InternalError, message)
127    }
128
129    pub fn validation_error(message: impl Into<String>, errors: Vec<ValidationError>) -> Self {
130        Self::new(ErrorCode::ValidationError, message).with_validation_errors(errors)
131    }
132
133    pub fn conflict(message: impl Into<String>) -> Self {
134        Self::new(ErrorCode::ConflictError, message)
135    }
136}
137
138#[derive(Debug, Serialize, Deserialize)]
139pub struct ErrorResponse {
140    pub error: ApiError,
141    pub api_version: String,
142}
143
144#[cfg(feature = "web")]
145impl ErrorCode {
146    #[must_use]
147    pub const fn status_code(&self) -> StatusCode {
148        match self {
149            Self::NotFound => StatusCode::NOT_FOUND,
150            Self::BadRequest => StatusCode::BAD_REQUEST,
151            Self::Unauthorized => StatusCode::UNAUTHORIZED,
152            Self::Forbidden => StatusCode::FORBIDDEN,
153            Self::ValidationError => StatusCode::UNPROCESSABLE_ENTITY,
154            Self::ConflictError => StatusCode::CONFLICT,
155            Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
156            Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
157            Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
158        }
159    }
160}
161
162#[cfg(feature = "web")]
163impl IntoResponse for ApiError {
164    fn into_response(self) -> axum::response::Response {
165        let status = self.code.status_code();
166
167        if status.is_server_error() {
168            tracing::error!(
169                error_code = ?self.code,
170                message = %self.message,
171                path = ?self.path,
172                trace_id = ?self.trace_id,
173                "API server error response"
174            );
175        } else if status.is_client_error() {
176            tracing::warn!(
177                error_code = ?self.code,
178                message = %self.message,
179                path = ?self.path,
180                trace_id = ?self.trace_id,
181                "API client error response"
182            );
183        }
184
185        let mut response = (status, Json(self)).into_response();
186
187        if status == StatusCode::UNAUTHORIZED
188            && let Ok(header_value) =
189                "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"".parse()
190        {
191            response
192                .headers_mut()
193                .insert(header::WWW_AUTHENTICATE, header_value);
194        }
195
196        response
197    }
198}