Skip to main content

systemprompt_api/services/proxy/
errors.rs

1//! Proxy error types and their HTTP status mapping.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use axum::body::Body;
7use axum::http::StatusCode;
8use axum::response::{IntoResponse, Response};
9use systemprompt_identifiers::error::IdValidationError;
10use systemprompt_mcp::McpDomainError;
11use systemprompt_models::api::ApiError;
12use systemprompt_traits::RegistryError;
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16pub enum ProxyError {
17    #[error("Service '{service}' not found in inventory")]
18    ServiceNotFound { service: String },
19
20    #[error("Service '{service}' is not running (status: {status})")]
21    ServiceNotRunning { service: String, status: String },
22
23    #[error("Failed to connect to {service} at {url}: {source}")]
24    ConnectionFailed {
25        service: String,
26        url: String,
27        #[source]
28        source: reqwest::Error,
29    },
30
31    #[error("Request to {service} timed out")]
32    Timeout { service: String },
33
34    #[error("Invalid response from {service}")]
35    InvalidResponse {
36        service: String,
37        #[source]
38        source: ResponseBuildError,
39    },
40
41    #[error("Failed to build URL for {service}: {reason}")]
42    UrlConstructionFailed { service: String, reason: String },
43
44    #[error("Failed to read the request body")]
45    BodyExtractionFailed {
46        #[source]
47        source: axum::Error,
48    },
49
50    #[error("Invalid HTTP method '{method}'")]
51    InvalidMethod {
52        method: String,
53        #[source]
54        source: http::method::InvalidMethod,
55    },
56
57    #[error("Database error when looking up service '{service}': {source}")]
58    DatabaseError {
59        service: String,
60        #[source]
61        source: systemprompt_traits::RepositoryError,
62    },
63
64    #[error("Authentication required for service '{service}'")]
65    AuthenticationRequired { service: String },
66
67    #[error("OAuth challenge response")]
68    AuthChallenge(Box<Response<Body>>),
69
70    #[error("Access forbidden for service '{service}'")]
71    Forbidden { service: String },
72
73    #[error("Missing request context: {message}")]
74    MissingContext { message: String },
75
76    #[error("Service name '{service}' is not a valid agent name")]
77    InvalidServiceName {
78        service: String,
79        #[source]
80        source: IdValidationError,
81    },
82
83    #[error(transparent)]
84    InvalidIdentifier(#[from] IdValidationError),
85
86    #[error("Service registry lookup failed for '{service}'")]
87    RegistryLookupFailed {
88        service: String,
89        #[source]
90        source: RegistryError,
91    },
92
93    #[error("Service '{service}' could not be restarted")]
94    RestartFailed {
95        service: String,
96        #[source]
97        source: McpDomainError,
98    },
99
100    #[error("Connect your account for '{service}' before using this server")]
101    ProviderNotConnected { service: String },
102
103    #[error("External MCP server '{service}' could not be resolved")]
104    ExternalResolveFailed {
105        service: String,
106        #[source]
107        source: McpDomainError,
108    },
109
110    #[error("MCP registry could not be read while resolving '{service}': {source}")]
111    RegistryUnavailable {
112        service: String,
113        #[source]
114        source: McpDomainError,
115    },
116}
117
118#[derive(Debug, Error)]
119pub enum ResponseBuildError {
120    #[error("upstream returned an invalid HTTP status {status}")]
121    Status {
122        status: u16,
123        #[source]
124        source: http::status::InvalidStatusCode,
125    },
126    #[error("failed to read the upstream response body")]
127    Body(#[source] reqwest::Error),
128    #[error("failed to assemble the proxied response")]
129    Assemble(#[source] Box<http::Error>),
130}
131
132impl ProxyError {
133    pub fn to_status_code(&self) -> StatusCode {
134        match self {
135            Self::ServiceNotFound { .. } => StatusCode::NOT_FOUND,
136            Self::ServiceNotRunning { .. } | Self::RestartFailed { .. } => {
137                StatusCode::SERVICE_UNAVAILABLE
138            },
139            Self::ConnectionFailed { .. }
140            | Self::InvalidResponse { .. }
141            | Self::ExternalResolveFailed { .. } => StatusCode::BAD_GATEWAY,
142            Self::Timeout { .. } => StatusCode::GATEWAY_TIMEOUT,
143            Self::UrlConstructionFailed { .. }
144            | Self::DatabaseError { .. }
145            | Self::RegistryUnavailable { .. }
146            | Self::RegistryLookupFailed { .. } => StatusCode::INTERNAL_SERVER_ERROR,
147            Self::BodyExtractionFailed { .. }
148            | Self::InvalidMethod { .. }
149            | Self::InvalidServiceName { .. }
150            | Self::InvalidIdentifier(_) => StatusCode::BAD_REQUEST,
151            Self::AuthenticationRequired { .. } | Self::MissingContext { .. } => {
152                StatusCode::UNAUTHORIZED
153            },
154            Self::AuthChallenge(response) => response.status(),
155            Self::Forbidden { .. } => StatusCode::FORBIDDEN,
156            Self::ProviderNotConnected { .. } => StatusCode::CONFLICT,
157        }
158    }
159
160    pub const fn error_key(&self) -> &'static str {
161        match self {
162            Self::ServiceNotFound { .. } => "service_not_found",
163            Self::ServiceNotRunning { .. } => "service_not_running",
164            Self::RestartFailed { .. } => "restart_failed",
165            Self::ConnectionFailed { .. } => "connection_failed",
166            Self::Timeout { .. } => "timeout",
167            Self::InvalidResponse { .. } => "invalid_response",
168            Self::UrlConstructionFailed { .. } => "url_construction_failed",
169            Self::BodyExtractionFailed { .. } => "body_extraction_failed",
170            Self::InvalidMethod { .. } => "invalid_method",
171            Self::DatabaseError { .. } => "database_error",
172            Self::AuthenticationRequired { .. } => "authentication_required",
173            Self::AuthChallenge(_) => "auth_challenge",
174            Self::Forbidden { .. } => "forbidden",
175            Self::MissingContext { .. } => "missing_context",
176            Self::InvalidServiceName { .. } => "invalid_service_name",
177            Self::InvalidIdentifier(_) => "invalid_identifier",
178            Self::RegistryUnavailable { .. } => "registry_unavailable",
179            Self::RegistryLookupFailed { .. } => "registry_lookup_failed",
180            Self::ProviderNotConnected { .. } => "provider_not_connected",
181            Self::ExternalResolveFailed { .. } => "external_resolve_failed",
182        }
183    }
184}
185
186impl From<ProxyError> for StatusCode {
187    fn from(error: ProxyError) -> Self {
188        error.to_status_code()
189    }
190}
191
192impl IntoResponse for ProxyError {
193    fn into_response(self) -> Response {
194        if let Self::AuthChallenge(response) = self {
195            return *response;
196        }
197        let status = self.to_status_code();
198        let error_key = self.error_key();
199        let api_error = match status {
200            StatusCode::NOT_FOUND => ApiError::not_found(self.to_string()),
201            StatusCode::UNAUTHORIZED => ApiError::unauthorized(self.to_string()),
202            StatusCode::FORBIDDEN => ApiError::forbidden(self.to_string()),
203            StatusCode::BAD_REQUEST => ApiError::bad_request(self.to_string()),
204            StatusCode::CONFLICT => ApiError::conflict(self.to_string()),
205            StatusCode::SERVICE_UNAVAILABLE
206            | StatusCode::BAD_GATEWAY
207            | StatusCode::GATEWAY_TIMEOUT => ApiError::service_unavailable("Proxy request failed")
208                .with_details(cause_chain(&self)),
209            _ => ApiError::internal_error("Proxy request failed").with_details(cause_chain(&self)),
210        };
211        api_error.with_error_key(error_key).into_response()
212    }
213}
214
215// Why: ProxyError owns an axum response (AuthChallenge), which is not Sync, so
216// it cannot ride as an ApiError source; the chain goes to the 5xx log as
217// details.
218fn cause_chain(error: &dyn std::error::Error) -> String {
219    let mut chain = error.to_string();
220    let mut next = error.source();
221    while let Some(cause) = next {
222        chain.push_str(": ");
223        chain.push_str(&cause.to_string());
224        next = cause.source();
225    }
226    chain
227}