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 provider-level failures ([`LlmProviderError`]) and
7//!   repository-level failures ([`RepositoryError`]) via `#[from]`, plus common
8//!   transport / parsing errors ([`reqwest::Error`], [`serde_json::Error`],
9//!   [`sqlx::Error`]).
10//! - [`RepositoryError`] — the persistence-layer error returned by every
11//!   `*Repository` type in [`crate::repository`].
12//!
13//! All public service signatures use [`Result<T>`] (i.e. `Result<T, AiError>`).
14//! Provider-trait signatures continue to use the boxed
15//! [`systemprompt_models::errors::ProviderResult`] and bridge through
16//! `AiProvider for AiService` in
17//! `crate::services::core::ai_service` (the `provider_impl` submodule).
18//!
19//! Copyright (c) systemprompt.io — Business Source License 1.1.
20//! See <https://systemprompt.io> for licensing details.
21
22use std::time::Duration;
23
24use thiserror::Error;
25use uuid::Uuid;
26
27use systemprompt_database::resilience::Outcome;
28use systemprompt_identifiers::McpServerId;
29use systemprompt_provider_contracts::LlmProviderError;
30
31#[derive(Debug, Error)]
32pub enum AiError {
33    #[error("Model not specified and no default available for provider {provider}")]
34    ModelNotSpecified { provider: String },
35
36    #[error("Request metadata missing required field: {field}")]
37    MissingMetadata { field: String },
38
39    #[error("User context required for billing and audit trails")]
40    MissingUserContext,
41
42    #[error("Provider {provider} returned empty response")]
43    EmptyProviderResponse { provider: String },
44
45    #[error("Tool call schema validation failed: {reason}")]
46    InvalidToolSchema { reason: String },
47
48    #[error("Authentication required for service {service_id}")]
49    AuthenticationRequired { service_id: McpServerId },
50
51    #[error("Structured output validation failed after {retries} attempts: {details}")]
52    StructuredOutputFailed { retries: usize, details: String },
53
54    #[error("Provider {provider} error: {message}")]
55    ProviderError { provider: String, message: String },
56
57    #[error("No configured provider supports model {model}")]
58    NoProviderForModel { model: String },
59
60    #[error(transparent)]
61    Provider(#[from] LlmProviderError),
62
63    #[error("Serialization failed: {0}")]
64    SerializationError(#[from] serde_json::Error),
65
66    #[error("HTTP request failed: {0}")]
67    Http(#[from] reqwest::Error),
68
69    #[error("I/O error: {0}")]
70    Io(#[from] std::io::Error),
71
72    #[error("Message history cannot be serialized to JSON")]
73    MessageSerializationFailed,
74
75    #[error("Tool {tool_name} missing required field: {field}")]
76    MissingToolField { tool_name: String, field: String },
77
78    #[error("Tool description cannot be empty for tool: {tool_name}")]
79    EmptyToolDescription { tool_name: String },
80
81    #[error("No tool calls found in provider response")]
82    NoToolCalls,
83
84    #[error("Rate limit exceeded for provider {provider}: {details}")]
85    RateLimit { provider: String, details: String },
86
87    #[error("Provider {provider} returned HTTP {status}: {body}")]
88    HttpStatus {
89        provider: String,
90        status: u16,
91        retry_after: Option<Duration>,
92        body: String,
93    },
94
95    #[error("Provider {provider} request timed out after {after_ms}ms")]
96    Timeout { provider: String, after_ms: u64 },
97
98    #[error("Circuit breaker open for provider {provider}; failing fast")]
99    CircuitOpen { provider: String },
100
101    #[error("Provider {provider} unavailable: concurrency limit reached")]
102    DependencyUnavailable { provider: String },
103
104    #[error("Invalid API credentials for provider {provider}")]
105    AuthenticationFailed { provider: String },
106
107    #[error("Configuration error: {message}")]
108    ConfigurationError { message: String },
109
110    #[error("Database operation failed: {message}")]
111    DatabaseError { message: String },
112
113    #[error("MCP service {service_id} not found or not configured")]
114    McpServiceNotFound { service_id: McpServerId },
115
116    #[error("MCP service {service_id} requires OAuth authentication but no token available")]
117    McpAuthenticationMissing { service_id: McpServerId },
118
119    #[error("Failed to determine service authentication requirements: {details}")]
120    ServiceAuthCheckFailed { details: String },
121
122    #[error("Storage operation failed: {message}")]
123    StorageError { message: String },
124
125    #[error("Invalid input: {0}")]
126    InvalidInput(String),
127
128    #[error("Regex error: {0}")]
129    Regex(#[from] regex::Error),
130
131    #[error(transparent)]
132    ToolProvider(#[from] systemprompt_traits::ToolProviderError),
133
134    #[error(transparent)]
135    Secrets(#[from] systemprompt_config::SecretsBootstrapError),
136
137    #[error(transparent)]
138    WireParse(#[from] systemprompt_models::wire::error::WireParseError),
139
140    #[error("internal: {0}")]
141    Internal(String),
142}
143
144#[derive(Debug, Error)]
145pub enum RepositoryError {
146    #[error("AI request not found: {0}")]
147    NotFound(Uuid),
148
149    #[error("Database error: {0}")]
150    Database(#[from] sqlx::Error),
151
152    #[error("Invalid data: {field} - {reason}")]
153    InvalidData { field: String, reason: String },
154
155    #[error("Database pool initialization failed: {0}")]
156    PoolInitialization(String),
157}
158
159impl AiError {
160    pub async fn from_error_response(provider: &str, response: reqwest::Response) -> Self {
161        let status = response.status().as_u16();
162        let retry_after = parse_retry_after(response.headers());
163        let body = response.text().await.unwrap_or_default();
164        Self::HttpStatus {
165            provider: provider.to_owned(),
166            status,
167            retry_after,
168            body,
169        }
170    }
171
172    #[must_use]
173    pub fn classify(&self) -> Outcome {
174        match self {
175            Self::HttpStatus {
176                status,
177                retry_after,
178                ..
179            } => {
180                if matches!(*status, 408 | 425 | 429 | 500 | 502 | 503 | 504) {
181                    Outcome::Transient {
182                        retry_after: *retry_after,
183                    }
184                } else {
185                    Outcome::Permanent
186                }
187            },
188            Self::RateLimit { .. } | Self::Timeout { .. } => {
189                Outcome::Transient { retry_after: None }
190            },
191            Self::Http(err) if err.is_timeout() || err.is_connect() => {
192                Outcome::Transient { retry_after: None }
193            },
194            _ => Outcome::Permanent,
195        }
196    }
197}
198
199fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
200    headers
201        .get(reqwest::header::RETRY_AFTER)?
202        .to_str()
203        .ok()?
204        .trim()
205        .parse::<u64>()
206        .ok()
207        .map(Duration::from_secs)
208}
209
210pub type Result<T> = std::result::Result<T, AiError>;
211
212impl From<RepositoryError> for AiError {
213    fn from(error: RepositoryError) -> Self {
214        Self::DatabaseError {
215            message: error.to_string(),
216        }
217    }
218}