Skip to main content

systemprompt_ai/
error.rs

1//! Typed error hierarchy for the [`systemprompt-ai`](crate) crate.
2//!
3//! Two error families live here:
4//!
5//! - [`AiError`] — the top-level public error returned by [`crate::services`].
6//!   It composes repository-level failures ([`RepositoryError`]) via `#[from]`,
7//!   plus common transport / parsing errors ([`reqwest::Error`],
8//!   [`serde_json::Error`], [`sqlx::Error`]).
9//! - [`RepositoryError`] — the persistence-layer error returned by every
10//!   `*Repository` type in [`crate::repository`].
11//!
12//! All public service signatures use [`Result<T>`] (i.e. `Result<T, AiError>`).
13//! The dyn `AiProvider` seam returns
14//! [`AiInferenceError`](systemprompt_models::errors::AiInferenceError); the
15//! `From<AiError>` impl below is the single mapping onto it.
16//!
17//! Copyright (c) systemprompt.io — Business Source License 1.1.
18//! See <https://systemprompt.io> for licensing details.
19
20use std::time::Duration;
21
22use thiserror::Error;
23use uuid::Uuid;
24
25use systemprompt_database::resilience::Outcome;
26use systemprompt_identifiers::{AiRequestId, McpServerId};
27
28#[derive(Debug, Error)]
29pub enum AiError {
30    #[error("Model not specified and no default available for provider {provider}")]
31    ModelNotSpecified { provider: String },
32
33    #[error("Request metadata missing required field: {field}")]
34    MissingMetadata { field: String },
35
36    #[error("User context required for billing and audit trails")]
37    MissingUserContext,
38
39    #[error("Provider {provider} returned empty response")]
40    EmptyProviderResponse { provider: String },
41
42    #[error("Tool call schema validation failed: {reason}")]
43    InvalidToolSchema { reason: String },
44
45    #[error("Authentication required for service {service_id}")]
46    AuthenticationRequired { service_id: McpServerId },
47
48    #[error("Structured output validation failed after {retries} attempts: {details}")]
49    StructuredOutputFailed { retries: usize, details: String },
50
51    #[error("Provider {provider} error: {message}")]
52    ProviderError { provider: String, message: String },
53
54    #[error("No configured provider supports model {model}")]
55    NoProviderForModel { model: String },
56
57    #[error("Upstream provider could not be resolved: {0}")]
58    Upstream(#[from] crate::services::upstream::UpstreamTargetError),
59
60    #[error("Serialization failed: {0}")]
61    SerializationError(#[from] serde_json::Error),
62
63    #[error("HTTP request failed: {0}")]
64    Http(#[from] reqwest::Error),
65
66    #[error("I/O error: {0}")]
67    Io(#[from] std::io::Error),
68
69    #[error("Message history cannot be serialized to JSON")]
70    MessageSerializationFailed,
71
72    #[error("Tool {tool_name} missing required field: {field}")]
73    MissingToolField { tool_name: String, field: String },
74
75    #[error("Tool description cannot be empty for tool: {tool_name}")]
76    EmptyToolDescription { tool_name: String },
77
78    #[error("No tool calls found in provider response")]
79    NoToolCalls,
80
81    #[error("Rate limit exceeded for provider {provider}: {details}")]
82    RateLimit { provider: String, details: String },
83
84    #[error("Provider {provider} returned HTTP {status}: {body}")]
85    HttpStatus {
86        provider: String,
87        status: u16,
88        retry_after: Option<Duration>,
89        body: String,
90    },
91
92    #[error("Provider {provider} request timed out after {after_ms}ms")]
93    Timeout { provider: String, after_ms: u64 },
94
95    #[error("Circuit breaker open for provider {provider}; failing fast")]
96    CircuitOpen { provider: String },
97
98    #[error("Provider {provider} unavailable: concurrency limit reached")]
99    DependencyUnavailable { provider: String },
100
101    #[error("Invalid API credentials for provider {provider}")]
102    AuthenticationFailed { provider: String },
103
104    #[error("Configuration error: {message}")]
105    ConfigurationError { message: String },
106
107    #[error("Database operation failed: {message}")]
108    DatabaseError { message: String },
109
110    #[error("MCP service {service_id} not found or not configured")]
111    McpServiceNotFound { service_id: McpServerId },
112
113    #[error("MCP service {service_id} requires OAuth authentication but no token available")]
114    McpAuthenticationMissing { service_id: McpServerId },
115
116    #[error("Failed to determine service authentication requirements: {details}")]
117    ServiceAuthCheckFailed { details: String },
118
119    #[error("Storage operation failed: {message}")]
120    StorageError { message: String },
121
122    #[error("Invalid input: {0}")]
123    InvalidInput(String),
124
125    #[error("Regex error: {0}")]
126    Regex(#[from] regex::Error),
127
128    #[error(transparent)]
129    ToolProvider(#[from] systemprompt_traits::ToolProviderError),
130
131    #[error("tool discovery incomplete: {0}")]
132    ToolDiscovery(String),
133
134    #[error("provider {provider} has no pricing for model {model}; refusing to bill it")]
135    UnknownModel { provider: String, model: String },
136
137    #[error(transparent)]
138    Secrets(#[from] systemprompt_config::SecretsBootstrapError),
139
140    #[error(transparent)]
141    WireParse(#[from] systemprompt_models::wire::error::WireParseError),
142
143    #[error("internal: {0}")]
144    Internal(String),
145}
146
147impl From<AiError> for systemprompt_models::errors::AiInferenceError {
148    fn from(err: AiError) -> Self {
149        match err {
150            AiError::ModelNotSpecified { ref provider }
151            | AiError::EmptyProviderResponse { ref provider } => Self::Provider {
152                provider: provider.clone(),
153                message: err.to_string(),
154            },
155            AiError::ProviderError { provider, message } => Self::Provider { provider, message },
156            AiError::NoProviderForModel { model } => Self::NoProviderForModel { model },
157            AiError::RateLimit { provider, details } => Self::RateLimited { provider, details },
158            AiError::AuthenticationFailed { provider } => Self::AuthenticationFailed { provider },
159            AiError::HttpStatus { ref provider, .. }
160            | AiError::Timeout { ref provider, .. }
161            | AiError::CircuitOpen { ref provider }
162            | AiError::DependencyUnavailable { ref provider } => Self::Unavailable {
163                provider: provider.clone(),
164                message: err.to_string(),
165            },
166            AiError::MissingMetadata { .. }
167            | AiError::MissingUserContext
168            | AiError::InvalidToolSchema { .. }
169            | AiError::StructuredOutputFailed { .. }
170            | AiError::MessageSerializationFailed
171            | AiError::MissingToolField { .. }
172            | AiError::EmptyToolDescription { .. }
173            | AiError::InvalidInput(_)
174            | AiError::WireParse(_) => Self::InvalidRequest(err.to_string()),
175            AiError::NoToolCalls
176            | AiError::McpServiceNotFound { .. }
177            | AiError::McpAuthenticationMissing { .. }
178            | AiError::ServiceAuthCheckFailed { .. }
179            | AiError::ToolDiscovery(_)
180            | AiError::ToolProvider(_) => Self::Tool(err.to_string()),
181            AiError::UnknownModel { .. }
182            | AiError::AuthenticationRequired { .. }
183            | AiError::ConfigurationError { .. }
184            | AiError::Secrets(_)
185            | AiError::Upstream(_) => Self::Configuration(err.to_string()),
186            AiError::DatabaseError { .. } | AiError::StorageError { .. } => {
187                Self::Storage(err.to_string())
188            },
189            AiError::SerializationError(_)
190            | AiError::Http(_)
191            | AiError::Io(_)
192            | AiError::Regex(_)
193            | AiError::Internal(_) => Self::Internal(err.to_string()),
194        }
195    }
196}
197
198#[derive(Debug, Error)]
199pub enum RepositoryError {
200    #[error("AI request not found: {0}")]
201    NotFound(Uuid),
202
203    #[error("Database error: {0}")]
204    Database(#[from] sqlx::Error),
205
206    #[error("Invalid data: {field} - {reason}")]
207    InvalidData { field: String, reason: String },
208
209    #[error("Database pool initialization failed: {0}")]
210    PoolInitialization(String),
211
212    #[error("Settlement of AI request {request_id} rejected: {reason}")]
213    SettlementConflict {
214        request_id: AiRequestId,
215        reason: String,
216    },
217
218    #[error("AI request {0} already exists")]
219    AlreadyExists(AiRequestId),
220}
221
222impl AiError {
223    pub async fn from_error_response(provider: &str, response: reqwest::Response) -> Self {
224        let status = response.status().as_u16();
225        let retry_after = parse_retry_after(response.headers());
226        let body = response
227            .text()
228            .await
229            .unwrap_or_else(|e| format!("<unreadable body: {e}>"));
230        Self::HttpStatus {
231            provider: provider.to_owned(),
232            status,
233            retry_after,
234            body,
235        }
236    }
237
238    #[must_use]
239    pub fn classify(&self) -> Outcome {
240        match self {
241            Self::HttpStatus {
242                status,
243                retry_after,
244                ..
245            } => {
246                if matches!(*status, 408 | 425 | 429 | 500 | 502 | 503 | 504) {
247                    Outcome::Transient {
248                        retry_after: *retry_after,
249                    }
250                } else {
251                    Outcome::Permanent
252                }
253            },
254            Self::RateLimit { .. } | Self::Timeout { .. } => {
255                Outcome::Transient { retry_after: None }
256            },
257            Self::Http(err) if err.is_timeout() || err.is_connect() => {
258                Outcome::Transient { retry_after: None }
259            },
260            _ => Outcome::Permanent,
261        }
262    }
263}
264
265fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
266    headers
267        .get(reqwest::header::RETRY_AFTER)?
268        .to_str()
269        .ok()?
270        .trim()
271        .parse::<u64>()
272        .ok()
273        .map(Duration::from_secs)
274}
275
276pub type Result<T> = std::result::Result<T, AiError>;
277
278impl From<RepositoryError> for AiError {
279    fn from(error: RepositoryError) -> Self {
280        Self::DatabaseError {
281            message: error.to_string(),
282        }
283    }
284}