lmrc_gitlab/
error.rs

1//! Error types for the GitLab client library.
2//!
3//! This module provides a comprehensive error type [`GitLabError`] that covers
4//! all possible error scenarios when interacting with the GitLab API.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use lmrc_gitlab::{GitLabClient, GitLabError};
10//!
11//! async fn example() -> Result<(), GitLabError> {
12//!     let client = GitLabClient::new("https://gitlab.com", "invalid-token")?;
13//!     // This will return GitLabError::Authentication if token is invalid
14//!     Ok(())
15//! }
16//! ```
17
18use std::fmt;
19
20/// Result type alias using [`GitLabError`] as the error type.
21pub type Result<T> = std::result::Result<T, GitLabError>;
22
23/// The main error type for all GitLab client operations.
24///
25/// This enum covers all possible error scenarios including authentication,
26/// API errors, network issues, and data validation problems.
27#[derive(Debug, thiserror::Error)]
28pub enum GitLabError {
29    /// Authentication failed due to invalid or missing credentials.
30    ///
31    /// # Examples
32    ///
33    /// ```no_run
34    /// # use lmrc_gitlab::GitLabError;
35    /// let error = GitLabError::Authentication("Invalid token".to_string());
36    /// assert_eq!(error.to_string(), "Authentication failed: Invalid token");
37    /// ```
38    #[error("Authentication failed: {0}")]
39    Authentication(String),
40
41    /// GitLab API returned an error response.
42    ///
43    /// This includes HTTP error codes, API-specific errors, and malformed responses.
44    #[error("GitLab API error: {0}")]
45    Api(String),
46
47    /// HTTP request failed due to network or connection issues.
48    #[error("HTTP request failed: {0}")]
49    Http(#[from] reqwest::Error),
50
51    /// Requested resource was not found (HTTP 404).
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// # use lmrc_gitlab::GitLabError;
57    /// let error = GitLabError::NotFound {
58    ///     resource: "pipeline".to_string(),
59    ///     id: "12345".to_string(),
60    /// };
61    /// ```
62    #[error("Resource not found: {resource} with id {id}")]
63    NotFound {
64        /// The type of resource (e.g., "pipeline", "job", "project")
65        resource: String,
66        /// The identifier for the resource
67        id: String,
68    },
69
70    /// Invalid configuration provided to the client.
71    ///
72    /// This includes invalid URLs, missing required fields, or incompatible settings.
73    #[error("Invalid configuration: {0}")]
74    Config(String),
75
76    /// Failed to serialize or deserialize data.
77    #[error("Serialization error: {0}")]
78    Serialization(#[from] serde_json::Error),
79
80    /// Rate limit exceeded for API requests.
81    ///
82    /// GitLab enforces rate limits on API calls. When exceeded, this error is returned.
83    #[error("Rate limit exceeded. Retry after {retry_after:?} seconds")]
84    RateLimit {
85        /// Number of seconds to wait before retrying (if provided by GitLab)
86        retry_after: Option<u64>,
87    },
88
89    /// Operation is not permitted due to insufficient permissions.
90    #[error("Permission denied: {0}")]
91    PermissionDenied(String),
92
93    /// Invalid input or parameters provided.
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// # use lmrc_gitlab::GitLabError;
99    /// let error = GitLabError::InvalidInput {
100    ///     field: "status".to_string(),
101    ///     message: "Must be one of: success, failed, running".to_string(),
102    /// };
103    /// ```
104    #[error("Invalid input for field '{field}': {message}")]
105    InvalidInput {
106        /// The field name that has invalid input
107        field: String,
108        /// Description of why the input is invalid
109        message: String,
110    },
111
112    /// Operation timed out.
113    ///
114    /// This can occur when waiting for a pipeline to complete, polling for status, etc.
115    #[error("Operation timed out after {seconds} seconds")]
116    Timeout {
117        /// Number of seconds elapsed before timeout
118        seconds: u64,
119    },
120
121    /// The underlying GitLab API returned a status that indicates a conflict (HTTP 409).
122    ///
123    /// Common scenarios include attempting to create a resource that already exists.
124    #[error("Conflict: {0}")]
125    Conflict(String),
126
127    /// The GitLab server is unavailable or returned an error (HTTP 5xx).
128    #[error("GitLab server error: {0}")]
129    ServerError(String),
130
131    /// An unexpected or unknown error occurred.
132    ///
133    /// This is used as a catch-all for errors that don't fit other categories.
134    #[error("Unexpected error: {0}")]
135    Unexpected(String),
136}
137
138impl GitLabError {
139    /// Creates an authentication error.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// # use lmrc_gitlab::GitLabError;
145    /// let error = GitLabError::authentication("Token expired");
146    /// ```
147    pub fn authentication<S: Into<String>>(msg: S) -> Self {
148        Self::Authentication(msg.into())
149    }
150
151    /// Creates an API error.
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// # use lmrc_gitlab::GitLabError;
157    /// let error = GitLabError::api("Invalid project path");
158    /// ```
159    pub fn api<S: Into<String>>(msg: S) -> Self {
160        Self::Api(msg.into())
161    }
162
163    /// Creates a not found error.
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// # use lmrc_gitlab::GitLabError;
169    /// let error = GitLabError::not_found("pipeline", 12345);
170    /// ```
171    pub fn not_found<S: Into<String>, I: fmt::Display>(resource: S, id: I) -> Self {
172        Self::NotFound {
173            resource: resource.into(),
174            id: id.to_string(),
175        }
176    }
177
178    /// Creates a configuration error.
179    ///
180    /// # Examples
181    ///
182    /// ```
183    /// # use lmrc_gitlab::GitLabError;
184    /// let error = GitLabError::config("Missing API token");
185    /// ```
186    pub fn config<S: Into<String>>(msg: S) -> Self {
187        Self::Config(msg.into())
188    }
189
190    /// Creates a rate limit error.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// # use lmrc_gitlab::GitLabError;
196    /// let error = GitLabError::rate_limit(Some(60));
197    /// ```
198    pub fn rate_limit(retry_after: Option<u64>) -> Self {
199        Self::RateLimit { retry_after }
200    }
201
202    /// Creates a permission denied error.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// # use lmrc_gitlab::GitLabError;
208    /// let error = GitLabError::permission_denied("Cannot delete protected branch");
209    /// ```
210    pub fn permission_denied<S: Into<String>>(msg: S) -> Self {
211        Self::PermissionDenied(msg.into())
212    }
213
214    /// Creates an invalid input error.
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// # use lmrc_gitlab::GitLabError;
220    /// let error = GitLabError::invalid_input("ref", "Branch name cannot be empty");
221    /// ```
222    pub fn invalid_input<S: Into<String>, M: Into<String>>(field: S, message: M) -> Self {
223        Self::InvalidInput {
224            field: field.into(),
225            message: message.into(),
226        }
227    }
228
229    /// Creates a timeout error.
230    ///
231    /// # Examples
232    ///
233    /// ```
234    /// # use lmrc_gitlab::GitLabError;
235    /// let error = GitLabError::timeout(300);
236    /// ```
237    pub fn timeout(seconds: u64) -> Self {
238        Self::Timeout { seconds }
239    }
240
241    /// Creates a conflict error.
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// # use lmrc_gitlab::GitLabError;
247    /// let error = GitLabError::conflict("Pipeline already exists");
248    /// ```
249    pub fn conflict<S: Into<String>>(msg: S) -> Self {
250        Self::Conflict(msg.into())
251    }
252
253    /// Creates a server error.
254    ///
255    /// # Examples
256    ///
257    /// ```
258    /// # use lmrc_gitlab::GitLabError;
259    /// let error = GitLabError::server_error("Internal server error");
260    /// ```
261    pub fn server_error<S: Into<String>>(msg: S) -> Self {
262        Self::ServerError(msg.into())
263    }
264
265    /// Creates an unexpected error.
266    ///
267    /// # Examples
268    ///
269    /// ```
270    /// # use lmrc_gitlab::GitLabError;
271    /// let error = GitLabError::unexpected("Unknown error occurred");
272    /// ```
273    pub fn unexpected<S: Into<String>>(msg: S) -> Self {
274        Self::Unexpected(msg.into())
275    }
276
277    /// Returns `true` if this error is retryable.
278    ///
279    /// Retryable errors include network issues, rate limits, and server errors.
280    ///
281    /// # Examples
282    ///
283    /// ```
284    /// # use lmrc_gitlab::GitLabError;
285    /// let error = GitLabError::rate_limit(Some(60));
286    /// assert!(error.is_retryable());
287    ///
288    /// let error = GitLabError::not_found("pipeline", 123);
289    /// assert!(!error.is_retryable());
290    /// ```
291    pub fn is_retryable(&self) -> bool {
292        matches!(
293            self,
294            Self::RateLimit { .. } | Self::ServerError(_) | Self::Timeout { .. }
295        )
296    }
297
298    /// Returns `true` if this is a client error (4xx status codes).
299    ///
300    /// Client errors indicate problems with the request that cannot be
301    /// resolved by retrying.
302    pub fn is_client_error(&self) -> bool {
303        matches!(
304            self,
305            Self::Authentication(_)
306                | Self::NotFound { .. }
307                | Self::PermissionDenied(_)
308                | Self::InvalidInput { .. }
309                | Self::Conflict(_)
310        )
311    }
312
313    /// Returns `true` if this is a server error (5xx status codes).
314    pub fn is_server_error(&self) -> bool {
315        matches!(self, Self::ServerError(_))
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_error_display() {
325        let error = GitLabError::authentication("Invalid token");
326        assert_eq!(error.to_string(), "Authentication failed: Invalid token");
327
328        let error = GitLabError::not_found("pipeline", 12345);
329        assert_eq!(
330            error.to_string(),
331            "Resource not found: pipeline with id 12345"
332        );
333    }
334
335    #[test]
336    fn test_is_retryable() {
337        assert!(GitLabError::rate_limit(Some(60)).is_retryable());
338        assert!(GitLabError::server_error("Error").is_retryable());
339        assert!(GitLabError::timeout(300).is_retryable());
340
341        assert!(!GitLabError::not_found("job", 123).is_retryable());
342        assert!(!GitLabError::authentication("Invalid").is_retryable());
343    }
344
345    #[test]
346    fn test_is_client_error() {
347        assert!(GitLabError::authentication("Invalid").is_client_error());
348        assert!(GitLabError::not_found("job", 123).is_client_error());
349        assert!(GitLabError::permission_denied("Denied").is_client_error());
350
351        assert!(!GitLabError::server_error("Error").is_client_error());
352        assert!(!GitLabError::rate_limit(None).is_client_error());
353    }
354
355    #[test]
356    fn test_is_server_error() {
357        assert!(GitLabError::server_error("Error").is_server_error());
358
359        assert!(!GitLabError::not_found("job", 123).is_server_error());
360        assert!(!GitLabError::authentication("Invalid").is_server_error());
361    }
362}