Skip to main content

github_bot_sdk/
error.rs

1//! Error types for GitHub Bot SDK operations.
2//!
3//! This module defines all error types used throughout the SDK, with proper
4//! classification for retry logic and comprehensive context for debugging.
5
6use chrono::{DateTime, Utc};
7use thiserror::Error;
8
9use crate::auth::InstallationId;
10
11/// Authentication-related errors with retry classification.
12///
13/// This error type covers all authentication failures including credential issues,
14/// token expiration, and GitHub API errors. Each variant includes metadata to
15/// support intelligent retry logic and detailed error reporting.
16#[derive(Debug, Error)]
17pub enum AuthError {
18    /// Invalid GitHub App credentials (non-retryable).
19    #[error("Invalid GitHub App credentials")]
20    InvalidCredentials,
21
22    /// Installation not found or access denied (non-retryable).
23    #[error("Installation {installation_id} not found or access denied")]
24    InstallationNotFound { installation_id: InstallationId },
25
26    /// Installation token has expired (retryable via refresh).
27    #[error("Installation token expired")]
28    TokenExpired,
29
30    /// Insufficient permissions for the requested operation (non-retryable).
31    #[error("Insufficient permissions for operation: {permission}")]
32    InsufficientPermissions { permission: String },
33
34    /// Invalid private key format or data (non-retryable).
35    #[error("Invalid private key: {message}")]
36    InvalidPrivateKey { message: String },
37
38    /// JWT generation failed (non-retryable).
39    #[error("JWT generation failed: {message}")]
40    JwtGenerationFailed { message: String },
41
42    /// Token generation failed (non-retryable).
43    #[error("Token generation failed: {message}")]
44    TokenGenerationFailed { message: String },
45
46    /// Token exchange with GitHub API failed.
47    #[error("Token exchange failed for installation {installation_id}: {message}")]
48    TokenExchangeFailed {
49        installation_id: InstallationId,
50        message: String,
51    },
52
53    /// GitHub API returned an error response.
54    #[error("GitHub API error: {status} - {message}")]
55    GitHubApiError { status: u16, message: String },
56
57    /// JWT signing operation failed.
58    #[error("JWT signing failed: {0}")]
59    SigningError(#[from] SigningError),
60
61    /// Secret retrieval from secure storage failed.
62    #[error("Secret retrieval failed: {0}")]
63    SecretError(#[from] SecretError),
64
65    /// Token cache operation failed.
66    #[error("Token cache error: {0}")]
67    CacheError(#[from] CacheError),
68
69    /// Network connectivity or transport error.
70    #[error("Network error: {0}")]
71    NetworkError(String),
72
73    /// GitHub API client error.
74    #[error("API error: {0}")]
75    ApiError(#[from] ApiError),
76}
77
78impl AuthError {
79    /// Check if this error represents a transient condition that may succeed if retried.
80    ///
81    /// Transient errors include:
82    /// - Network failures
83    /// - Server errors (5xx)
84    /// - Rate limiting (429)
85    /// - Token expiration (can refresh)
86    /// - Cache failures (can regenerate)
87    ///
88    /// Non-transient errors include:
89    /// - Invalid credentials
90    /// - Missing installations
91    /// - Insufficient permissions
92    /// - Client errors (4xx except 429)
93    pub fn is_transient(&self) -> bool {
94        match self {
95            Self::InvalidCredentials => false,
96            Self::InstallationNotFound { .. } => false,
97            Self::TokenExpired => true, // Can refresh token
98            Self::InsufficientPermissions { .. } => false,
99            Self::InvalidPrivateKey { .. } => false,
100            Self::JwtGenerationFailed { .. } => false,
101            Self::TokenGenerationFailed { .. } => false,
102            Self::TokenExchangeFailed { .. } => false,
103            Self::GitHubApiError { status, .. } => *status >= 500 || *status == 429,
104            Self::SigningError(_) => false,
105            Self::SecretError(e) => e.is_transient(),
106            Self::CacheError(_) => true, // Can fallback to fresh generation
107            Self::NetworkError(_) => true,
108            Self::ApiError(e) => e.is_transient(),
109        }
110    }
111
112    /// Determine if this error should trigger a retry attempt.
113    ///
114    /// Alias for `is_transient()` to support different retry policy conventions.
115    pub fn should_retry(&self) -> bool {
116        self.is_transient()
117    }
118
119    /// Get the recommended retry delay for this error.
120    ///
121    /// Returns `Some(Duration)` if a specific delay is recommended (e.g., rate limiting),
122    /// or `None` to use the default exponential backoff policy.
123    pub fn retry_after(&self) -> Option<chrono::Duration> {
124        match self {
125            Self::GitHubApiError { status, .. } if *status == 429 => {
126                Some(chrono::Duration::minutes(1))
127            }
128            Self::NetworkError(_) => Some(chrono::Duration::seconds(5)),
129            _ => None,
130        }
131    }
132}
133
134/// Errors during secret retrieval from secure storage.
135///
136/// These errors occur when accessing secrets from Key Vault, environment variables,
137/// or other secure storage mechanisms.
138#[derive(Debug, Error)]
139pub enum SecretError {
140    /// The requested secret was not found in the storage provider.
141    #[error("Secret not found: {key}")]
142    NotFound { key: String },
143
144    /// Access to the secret was denied due to permissions.
145    #[error("Access denied to secret: {key}")]
146    AccessDenied { key: String },
147
148    /// The secret storage provider is unavailable (retryable).
149    #[error("Secret provider unavailable: {0}")]
150    ProviderUnavailable(String),
151
152    /// The secret exists but has an invalid format.
153    #[error("Invalid secret format: {key}")]
154    InvalidFormat { key: String },
155}
156
157impl SecretError {
158    /// Check if this error represents a transient condition.
159    ///
160    /// Only `ProviderUnavailable` is considered transient.
161    pub fn is_transient(&self) -> bool {
162        matches!(self, Self::ProviderUnavailable(_))
163    }
164}
165
166/// Errors during token caching operations.
167///
168/// Cache errors are generally non-fatal and allow fallback to regenerating tokens.
169#[derive(Debug, Error)]
170pub enum CacheError {
171    /// A cache operation failed for a specific reason.
172    #[error("Cache operation failed: {message}")]
173    OperationFailed { message: String },
174
175    /// The cache is unavailable or unreachable.
176    #[error("Cache unavailable: {message}")]
177    Unavailable { message: String },
178
179    /// Failed to serialize or deserialize cached data.
180    #[error("Serialization failed: {0}")]
181    Serialization(#[from] serde_json::Error),
182}
183
184/// Errors during JWT signing operations.
185///
186/// These errors occur during cryptographic operations for JWT generation.
187#[derive(Debug, Error)]
188pub enum SigningError {
189    /// The private key is invalid or malformed.
190    #[error("Invalid private key: {message}")]
191    InvalidKey { message: String },
192
193    /// The signing operation failed.
194    #[error("Signing operation failed: {message}")]
195    SigningFailed { message: String },
196
197    /// Failed to encode the JWT token.
198    #[error("Token encoding failed: {message}")]
199    EncodingFailed { message: String },
200}
201
202/// Errors during GitHub API operations.
203///
204/// These errors represent failures when communicating with the GitHub API,
205/// including HTTP errors, rate limiting, and parsing failures.
206#[derive(Debug, Error)]
207pub enum ApiError {
208    /// HTTP error response from GitHub API.
209    #[error("HTTP error: {status} - {message}")]
210    HttpError { status: u16, message: String },
211
212    /// Rate limit exceeded. Operations should wait until reset time.
213    #[error("Rate limit exceeded. Reset at: {reset_at}")]
214    RateLimitExceeded { reset_at: DateTime<Utc> },
215
216    /// Secondary rate limit (abuse detection) exceeded.
217    /// Requires longer backoff period than primary rate limits.
218    #[error("Secondary rate limit exceeded (abuse detection). Retry after 60+ seconds")]
219    SecondaryRateLimit,
220
221    /// Request to GitHub API timed out.
222    #[error("Request timeout")]
223    Timeout,
224
225    /// The request was invalid (client error).
226    #[error("Invalid request: {message}")]
227    InvalidRequest { message: String },
228
229    /// Authentication to GitHub API failed.
230    #[error("Authentication failed")]
231    AuthenticationFailed,
232
233    /// Authorization check failed (insufficient permissions).
234    #[error("Authorization failed")]
235    AuthorizationFailed,
236
237    /// The requested resource was not found.
238    #[error("Resource not found")]
239    NotFound,
240
241    /// Failed to parse JSON response from GitHub API.
242    #[error("JSON parsing error: {0}")]
243    JsonError(#[from] serde_json::Error),
244
245    /// HTTP client error (network, TLS, etc.).
246    #[error("HTTP client error: {0}")]
247    HttpClientError(#[from] reqwest::Error),
248
249    /// Client configuration error.
250    #[error("Configuration error: {message}")]
251    Configuration { message: String },
252
253    /// Failed to generate authentication token.
254    #[error("Token generation failed: {message}")]
255    TokenGenerationFailed { message: String },
256
257    /// Failed to exchange token.
258    #[error("Token exchange failed: {message}")]
259    TokenExchangeFailed { message: String },
260
261    /// GraphQL API returned an application-level error.
262    ///
263    /// GitHub's GraphQL endpoint always returns HTTP 200; errors are reported
264    /// inside the response body under `.errors[].message`. This variant
265    /// captures the first such message. It is non-retryable because it
266    /// indicates a logic error in the query or variables, not a transient
267    /// infrastructure problem.
268    #[error("GraphQL error: {message}")]
269    GraphQlError { message: String },
270}
271
272impl ApiError {
273    /// Check if this error represents a transient condition that may succeed if retried.
274    ///
275    /// Transient conditions include:
276    /// - Server errors (5xx)
277    /// - Rate limiting (429)
278    /// - Request timeouts
279    /// - Network/transport errors
280    pub fn is_transient(&self) -> bool {
281        match self {
282            Self::HttpError { status, .. } => *status >= 500 || *status == 429,
283            Self::RateLimitExceeded { .. } => true,
284            Self::SecondaryRateLimit => true, // Transient, requires longer backoff
285            Self::Timeout => true,
286            Self::InvalidRequest { .. } => false,
287            Self::AuthenticationFailed => false,
288            Self::AuthorizationFailed => false,
289            Self::NotFound => false,
290            Self::JsonError(_) => false,
291            Self::HttpClientError(_) => true, // Network issues are transient
292            Self::Configuration { .. } => false, // Configuration errors are permanent
293            Self::TokenGenerationFailed { .. } => false, // Token generation errors are auth errors
294            Self::TokenExchangeFailed { .. } => false, // Token exchange errors are auth errors
295            Self::GraphQlError { .. } => false, // Logic error in query/variables, not retryable
296        }
297    }
298}
299
300/// Input validation errors.
301///
302/// These errors occur when validating user input or configuration data.
303#[derive(Debug, Error)]
304pub enum ValidationError {
305    /// A required field is missing.
306    #[error("Required field missing: {field}")]
307    Required { field: String },
308
309    /// A field has an invalid format.
310    #[error("Invalid format for {field}: {message}")]
311    InvalidFormat { field: String, message: String },
312
313    /// A field value is out of the acceptable range.
314    #[error("Value out of range for {field}: {message}")]
315    OutOfRange { field: String, message: String },
316
317    /// Webhook signature format is invalid.
318    #[error("Invalid signature format: {message}")]
319    InvalidSignatureFormat { message: String },
320
321    /// HMAC computation failed.
322    #[error("HMAC computation failed: {message}")]
323    HmacError { message: String },
324}
325
326/// Event processing errors.
327///
328/// These errors occur when processing GitHub webhook events, including
329/// parsing, validation, and normalization failures.
330#[derive(Debug, Error)]
331pub enum EventError {
332    /// The event payload is invalid or malformed.
333    #[error("Invalid event payload: {message}")]
334    InvalidPayload { message: String },
335
336    /// The event type is not supported.
337    #[error("Unsupported event type: {event_type}")]
338    UnsupportedEventType { event_type: String },
339
340    /// Webhook signature validation failed.
341    #[error("Signature validation failed")]
342    InvalidSignature,
343
344    /// A required field is missing from the payload.
345    #[error("Missing required field: {field}")]
346    MissingField { field: String },
347
348    /// The payload size exceeds the maximum allowed.
349    #[error("Payload too large: {size} bytes (max: {max})")]
350    PayloadTooLarge { size: usize, max: usize },
351
352    /// JSON parsing failed.
353    #[error("JSON parsing error: {0}")]
354    JsonParsing(#[from] serde_json::Error),
355
356    /// Secret provider error while validating signature.
357    #[error("Secret provider error: {0}")]
358    SecretProvider(#[source] Box<dyn std::error::Error + Send + Sync>),
359}
360
361impl EventError {
362    /// Check if this error represents a transient condition that may succeed if retried.
363    pub fn is_transient(&self) -> bool {
364        match self {
365            Self::InvalidPayload { .. } => false,
366            Self::UnsupportedEventType { .. } => false,
367            Self::InvalidSignature => false,
368            Self::MissingField { .. } => false,
369            Self::PayloadTooLarge { .. } => false,
370            Self::JsonParsing(_) => false,
371            Self::SecretProvider(_) => true, // Secret retrieval might be temporarily unavailable
372        }
373    }
374}
375
376#[cfg(test)]
377#[path = "error_tests.rs"]
378mod tests;