Skip to main content

composio_sdk/
error.rs

1use serde::Deserialize;
2use thiserror::Error;
3
4/// Main error type for the Composio SDK
5#[derive(Debug, Error)]
6pub enum ComposioError {
7    /// API error returned from Composio backend
8    #[error("API error: {message} (status: {status})")]
9    ApiError {
10        status: u16,
11        message: String,
12        code: Option<String>,
13        slug: Option<String>,
14        request_id: Option<String>,
15        suggested_fix: Option<String>,
16        errors: Option<Vec<ErrorDetail>>,
17    },
18
19    /// Network error from HTTP client
20    #[error("Network error: {0}")]
21    NetworkError(#[from] reqwest::Error),
22
23    /// JSON serialization error
24    #[error("Serialization error: {0}")]
25    SerializationError(#[from] serde_json::Error),
26
27    /// Invalid input provided by user
28    #[error("Invalid input: {0}")]
29    InvalidInput(String),
30
31    /// Configuration error
32    #[error("Configuration error: {0}")]
33    ConfigError(String),
34}
35
36/// Detailed error information for individual field errors
37#[derive(Debug, Clone, PartialEq, Deserialize)]
38pub struct ErrorDetail {
39    /// Field name that caused the error (optional)
40    pub field: Option<String>,
41    /// Error message for this field
42    pub message: String,
43}
44
45/// Error response structure from Composio API
46#[derive(Debug, Clone, Deserialize)]
47pub struct ErrorResponse {
48    pub message: String,
49    pub code: Option<String>,
50    pub slug: Option<String>,
51    pub status: u16,
52    pub request_id: Option<String>,
53    pub suggested_fix: Option<String>,
54    pub errors: Option<Vec<ErrorDetail>>,
55}
56
57impl ComposioError {
58    /// Create an ApiError from an HTTP response
59    ///
60    /// This method attempts to parse the response body as an ErrorResponse.
61    /// If parsing fails, it creates a generic ApiError with the status code.
62    pub async fn from_response(response: reqwest::Response) -> Self {
63        let status = response.status().as_u16();
64
65        match response.json::<ErrorResponse>().await {
66            Ok(err_resp) => ComposioError::ApiError {
67                status,
68                message: err_resp.message,
69                code: err_resp.code,
70                slug: err_resp.slug,
71                request_id: err_resp.request_id,
72                suggested_fix: err_resp.suggested_fix,
73                errors: err_resp.errors,
74            },
75            Err(_) => ComposioError::ApiError {
76                status,
77                message: format!("HTTP error {}", status),
78                code: None,
79                slug: None,
80                request_id: None,
81                suggested_fix: None,
82                errors: None,
83            },
84        }
85    }
86
87    /// Check if this error should be retried
88    ///
89    /// Returns true for transient errors that may succeed on retry:
90    /// - 429 (Rate Limited)
91    /// - 500 (Internal Server Error)
92    /// - 502 (Bad Gateway)
93    /// - 503 (Service Unavailable)
94    /// - 504 (Gateway Timeout)
95    /// - Network errors
96    ///
97    /// Returns false for client errors (4xx except 429) that won't succeed on retry.
98    pub fn is_retryable(&self) -> bool {
99        match self {
100            ComposioError::ApiError { status, .. } => {
101                matches!(status, 429 | 500 | 502 | 503 | 504)
102            }
103            ComposioError::NetworkError(_) => true,
104            _ => false,
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn test_api_error_display() {
115        let error = ComposioError::ApiError {
116            status: 404,
117            message: "Resource not found".to_string(),
118            code: Some("NOT_FOUND".to_string()),
119            slug: Some("resource-not-found".to_string()),
120            request_id: Some("req_123".to_string()),
121            suggested_fix: Some("Check the resource ID".to_string()),
122            errors: None,
123        };
124
125        let display = format!("{}", error);
126        assert!(display.contains("API error"));
127        assert!(display.contains("Resource not found"));
128        assert!(display.contains("404"));
129    }
130
131    #[test]
132    fn test_invalid_input_error() {
133        let error = ComposioError::InvalidInput("Invalid API key".to_string());
134        let display = format!("{}", error);
135        assert!(display.contains("Invalid input"));
136        assert!(display.contains("Invalid API key"));
137    }
138
139    #[test]
140    fn test_config_error() {
141        let error = ComposioError::ConfigError("Invalid base URL".to_string());
142        let display = format!("{}", error);
143        assert!(display.contains("Configuration error"));
144        assert!(display.contains("Invalid base URL"));
145    }
146
147    #[test]
148    fn test_serialization_error_conversion() {
149        let json_error = serde_json::from_str::<serde_json::Value>("invalid json")
150            .unwrap_err();
151        let error: ComposioError = json_error.into();
152
153        match error {
154            ComposioError::SerializationError(_) => (),
155            _ => panic!("Expected SerializationError"),
156        }
157    }
158
159    #[test]
160    fn test_is_retryable_for_rate_limit() {
161        let error = ComposioError::ApiError {
162            status: 429,
163            message: "Rate limited".to_string(),
164            code: None,
165            slug: None,
166            request_id: None,
167            suggested_fix: None,
168            errors: None,
169        };
170
171        assert!(error.is_retryable());
172    }
173
174    #[test]
175    fn test_is_retryable_for_server_errors() {
176        for status in [500, 502, 503, 504] {
177            let error = ComposioError::ApiError {
178                status,
179                message: "Server error".to_string(),
180                code: None,
181                slug: None,
182                request_id: None,
183                suggested_fix: None,
184                errors: None,
185            };
186
187            assert!(
188                error.is_retryable(),
189                "Status {} should be retryable",
190                status
191            );
192        }
193    }
194
195    #[test]
196    fn test_is_not_retryable_for_client_errors() {
197        for status in [400, 401, 403, 404] {
198            let error = ComposioError::ApiError {
199                status,
200                message: "Client error".to_string(),
201                code: None,
202                slug: None,
203                request_id: None,
204                suggested_fix: None,
205                errors: None,
206            };
207
208            assert!(
209                !error.is_retryable(),
210                "Status {} should not be retryable",
211                status
212            );
213        }
214    }
215
216    #[test]
217    fn test_serialization_error_is_not_retryable() {
218        let json_error = serde_json::from_str::<serde_json::Value>("invalid json")
219            .unwrap_err();
220        let error: ComposioError = json_error.into();
221
222        assert!(!error.is_retryable());
223    }
224
225    #[test]
226    fn test_invalid_input_not_retryable() {
227        let error = ComposioError::InvalidInput("Invalid API key".to_string());
228        assert!(!error.is_retryable());
229    }
230
231    #[test]
232    fn test_config_error_not_retryable() {
233        let error = ComposioError::ConfigError("Invalid base URL".to_string());
234        assert!(!error.is_retryable());
235    }
236
237    #[test]
238    fn test_error_detail_deserialization() {
239        let json = r#"{
240            "field": "email",
241            "message": "Invalid email format"
242        }"#;
243
244        let detail: ErrorDetail = serde_json::from_str(json).unwrap();
245        assert_eq!(detail.field, Some("email".to_string()));
246        assert_eq!(detail.message, "Invalid email format");
247    }
248
249    #[test]
250    fn test_error_response_deserialization() {
251        let json = r#"{
252            "message": "Validation failed",
253            "code": "VALIDATION_ERROR",
254            "slug": "validation-failed",
255            "status": 400,
256            "request_id": "req_abc123",
257            "suggested_fix": "Check your input parameters",
258            "errors": [
259                {
260                    "field": "user_id",
261                    "message": "User ID is required"
262                }
263            ]
264        }"#;
265
266        let response: ErrorResponse = serde_json::from_str(json).unwrap();
267        assert_eq!(response.message, "Validation failed");
268        assert_eq!(response.code, Some("VALIDATION_ERROR".to_string()));
269        assert_eq!(response.status, 400);
270        assert!(response.errors.is_some());
271        assert_eq!(response.errors.as_ref().unwrap().len(), 1);
272    }
273
274    #[test]
275    fn test_error_response_minimal_deserialization() {
276        let json = r#"{
277            "message": "Internal server error",
278            "status": 500
279        }"#;
280
281        let response: ErrorResponse = serde_json::from_str(json).unwrap();
282        assert_eq!(response.message, "Internal server error");
283        assert_eq!(response.status, 500);
284        assert!(response.code.is_none());
285        assert!(response.errors.is_none());
286    }
287}