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>;