Skip to main content

igdb_atlas/error/
mod.rs

1//! # Error Handling
2//!
3//! This module provides custom error types for all failure modes in the IGDB client.
4//!
5//! ## Error Hierarchy
6//!
7//! [`IGDBError`] is the top-level error type. It wraps:
8//!
9//! - Network/transport errors from [`reqwest`]
10//! - Authentication failures (token retrieval, expiry)
11//! - API errors returned by IGDB (validation, rate limits, server errors)
12//! - Serialization/deserialization failures
13//! - Query construction errors
14//! - Rate limiting with backoff metadata
15//!
16//! ## Example: Handling specific errors
17//!
18//! ```rust,no_run
19//! use igdb_atlas::{IGDBClient, ClientConfig, IGDBError};
20//! use igdb_atlas::endpoints::traits::Searchable;
21//!
22//! async fn handle_errors() {
23//!     let config = ClientConfig::new("id", "secret");
24//!     let client = IGDBClient::new(config).await.unwrap();
25//!
26//!     match client.games().search("test").execute().await {
27//!         Ok(games) => println!("Found {} games", games.len()),
28//!         Err(IGDBError::RateLimited { retry_after_ms, attempts }) => {
29//!             println!("Rate limited! Retry after {}ms (attempt {})", retry_after_ms, attempts);
30//!         }
31//!         Err(IGDBError::ApiError { status, message }) => {
32//!             println!("API error {}: {}", status, message);
33//!         }
34//!         Err(IGDBError::AuthenticationFailed(msg)) => {
35//!             println!("Auth failed: {}", msg);
36//!         }
37//!         Err(e) => {
38//!             println!("Other error: {}", e);
39//!         }
40//!     }
41//! }
42//! ```
43
44use std::fmt;
45
46use thiserror::Error;
47
48/// The primary error type for all IGDB client operations.
49///
50/// Every public method in this crate returns `Result<T, IGDBError>`,
51/// giving consumers fine-grained control over error handling.
52///
53/// # Examples
54///
55/// Matching on specific error variants:
56///
57/// ```rust
58/// use igdb_atlas::error::IGDBError;
59///
60/// fn describe_error(err: &IGDBError) -> &'static str {
61///     match err {
62///         IGDBError::AuthenticationFailed(_) => "auth problem",
63///         IGDBError::RateLimited { .. } => "too many requests",
64///         IGDBError::ApiError { .. } => "IGDB returned an error",
65///         IGDBError::NetworkError(_) => "network issue",
66///         IGDBError::DeserializationError(_) => "bad response format",
67///         IGDBError::QueryBuildError(_) => "invalid query",
68///         IGDBError::TokenExpired => "token needs refresh",
69///         IGDBError::InvalidConfiguration(_) => "bad config",
70///         IGDBError::Custom(_) => "custom error",
71///     }
72/// }
73/// ```
74#[derive(Error, Debug)]
75pub enum IGDBError {
76    /// Authentication with Twitch OAuth failed.
77    ///
78    /// This occurs when the client credentials are invalid,
79    /// the token endpoint is unreachable, or the response is malformed.
80    #[error("Authentication failed: {0}")]
81    AuthenticationFailed(String),
82
83    /// The API rate limit (4 req/s) was hit despite backoff attempts.
84    ///
85    /// Contains the last computed retry delay and how many attempts were made.
86    #[error("Rate limited after {attempts} attempts. Retry after {retry_after_ms}ms")]
87    RateLimited {
88        /// Milliseconds to wait before retrying
89        retry_after_ms: u64,
90        /// Number of backoff attempts made
91        attempts: u32,
92    },
93
94    /// IGDB returned an error response (4xx or 5xx).
95    #[error("API error (HTTP {status}): {message}")]
96    ApiError {
97        /// HTTP status code
98        status: u16,
99        /// Error message from the API
100        message: String,
101    },
102
103    /// A network/transport-level error from reqwest.
104    #[error("Network error: {0}")]
105    NetworkError(#[from] reqwest::Error),
106
107    /// Failed to deserialize the API response into a model.
108    #[error("Deserialization error: {0}")]
109    DeserializationError(#[from] serde_json::Error),
110
111    /// An error constructing or validating an Apicalypse query.
112    #[error("Query build error: {0}")]
113    QueryBuildError(String),
114
115    /// The cached OAuth token has expired and must be refreshed.
116    #[error("Token expired, refresh required")]
117    TokenExpired,
118
119    /// Invalid client configuration (missing credentials, bad URLs, etc.).
120    #[error("Invalid configuration: {0}")]
121    InvalidConfiguration(String),
122
123    /// A user-supplied custom error, useful for wrapping external errors.
124    #[error("Custom error: {0}")]
125    Custom(Box<dyn std::error::Error + Send + Sync>),
126}
127
128impl IGDBError {
129    /// Creates a custom error wrapping any type that implements `std::error::Error`.
130    ///
131    /// # Examples
132    ///
133    /// ```rust
134    /// use igdb_atlas::error::IGDBError;
135    ///
136    /// let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk full");
137    /// let igdb_err = IGDBError::from_custom(io_err);
138    /// assert!(igdb_err.to_string().contains("disk full"));
139    /// ```
140    pub fn from_custom<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
141        IGDBError::Custom(Box::new(err))
142    }
143
144    /// Returns `true` if this error represents a retriable condition.
145    ///
146    /// Currently, rate limit errors and certain network errors are considered retriable.
147    ///
148    /// # Examples
149    ///
150    /// ```rust
151    /// use igdb_atlas::error::IGDBError;
152    ///
153    /// let err = IGDBError::RateLimited { retry_after_ms: 1000, attempts: 2 };
154    /// assert!(err.is_retriable());
155    ///
156    /// let err = IGDBError::AuthenticationFailed("bad creds".into());
157    /// assert!(!err.is_retriable());
158    /// ```
159    pub fn is_retriable(&self) -> bool {
160        matches!(self, IGDBError::RateLimited { .. })
161    }
162
163    /// If this is a rate limit error, returns the recommended retry delay in milliseconds.
164    ///
165    /// # Examples
166    ///
167    /// ```rust
168    /// use igdb_atlas::error::IGDBError;
169    ///
170    /// let err = IGDBError::RateLimited { retry_after_ms: 2500, attempts: 3 };
171    /// assert_eq!(err.retry_after_ms(), Some(2500));
172    ///
173    /// let err = IGDBError::TokenExpired;
174    /// assert_eq!(err.retry_after_ms(), None);
175    /// ```
176    pub fn retry_after_ms(&self) -> Option<u64> {
177        match self {
178            IGDBError::RateLimited { retry_after_ms, .. } => Some(*retry_after_ms),
179            _ => None,
180        }
181    }
182}
183
184/// Display implementation is handled by thiserror derive.
185/// This `fmt::Debug` formatting gives a more structured representation
186/// useful during development.
187impl fmt::LowerHex for IGDBError {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        write!(f, "{:?}", self)
190    }
191}
192
193/// Result type alias for all IGDB operations.
194///
195/// # Examples
196///
197/// ```rust
198/// use igdb_atlas::error::{IGDBError, Result};
199///
200/// fn parse_id(input: &str) -> Result<u64> {
201///     input.parse::<u64>().map_err(|e| IGDBError::QueryBuildError(e.to_string()))
202/// }
203///
204/// assert!(parse_id("42").is_ok());
205/// assert!(parse_id("not_a_number").is_err());
206/// ```
207pub type Result<T> = std::result::Result<T, IGDBError>;