1use std::time::Duration;
2
3use thiserror::Error;
4
5#[derive(Error, Debug)]
9pub enum ForgeError {
10 #[error("Configuration error: {0}")]
12 Config(String),
13
14 #[error("Database error: {0}")]
16 Database(String),
17
18 #[error("Function error: {0}")]
20 Function(String),
21
22 #[error("Job error: {0}")]
24 Job(String),
25
26 #[error("Job cancelled: {0}")]
28 JobCancelled(String),
29
30 #[error("Cluster error: {0}")]
32 Cluster(String),
33
34 #[error("Serialization error: {0}")]
36 Serialization(String),
37
38 #[error("Deserialization error: {0}")]
40 Deserialization(String),
41
42 #[error("IO error: {0}")]
44 Io(#[from] std::io::Error),
45
46 #[error("SQL error: {0}")]
48 Sql(#[from] sqlx::Error),
49
50 #[error("Invalid argument: {0}")]
52 InvalidArgument(String),
53
54 #[error("Not found: {0}")]
56 NotFound(String),
57
58 #[error("Unauthorized: {0}")]
60 Unauthorized(String),
61
62 #[error("Forbidden: {0}")]
64 Forbidden(String),
65
66 #[error("Validation error: {0}")]
68 Validation(String),
69
70 #[error("Timeout: {0}")]
72 Timeout(String),
73
74 #[error("Internal error: {0}")]
76 Internal(String),
77
78 #[error("Invalid state: {0}")]
80 InvalidState(String),
81
82 #[error("Workflow suspended")]
84 WorkflowSuspended,
85
86 #[error("Rate limit exceeded: retry after {retry_after:?}")]
88 RateLimitExceeded {
89 retry_after: Duration,
91 limit: u32,
93 remaining: u32,
95 },
96}
97
98impl From<serde_json::Error> for ForgeError {
99 fn from(e: serde_json::Error) -> Self {
100 ForgeError::Serialization(e.to_string())
101 }
102}
103
104impl From<crate::http::CircuitBreakerError> for ForgeError {
105 fn from(e: crate::http::CircuitBreakerError) -> Self {
106 match e {
107 crate::http::CircuitBreakerError::CircuitOpen(open) => {
108 ForgeError::Timeout(open.to_string())
109 }
110 crate::http::CircuitBreakerError::Request(err) if err.is_timeout() => {
111 ForgeError::Timeout(err.to_string())
112 }
113 crate::http::CircuitBreakerError::Request(err) => ForgeError::Internal(err.to_string()),
114 }
115 }
116}
117
118pub type Result<T> = std::result::Result<T, ForgeError>;
120
121#[cfg(test)]
122#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
123mod tests {
124 use super::*;
125
126 #[test]
129 fn display_preserves_inner_message() {
130 let cases: Vec<(ForgeError, &str)> = vec![
131 (
132 ForgeError::Config("bad toml".into()),
133 "Configuration error: bad toml",
134 ),
135 (
136 ForgeError::Database("conn refused".into()),
137 "Database error: conn refused",
138 ),
139 (
140 ForgeError::Function("handler panic".into()),
141 "Function error: handler panic",
142 ),
143 (ForgeError::Job("timeout".into()), "Job error: timeout"),
144 (
145 ForgeError::JobCancelled("user request".into()),
146 "Job cancelled: user request",
147 ),
148 (
149 ForgeError::Cluster("split brain".into()),
150 "Cluster error: split brain",
151 ),
152 (
153 ForgeError::Serialization("bad json".into()),
154 "Serialization error: bad json",
155 ),
156 (
157 ForgeError::Deserialization("missing field".into()),
158 "Deserialization error: missing field",
159 ),
160 (
161 ForgeError::InvalidArgument("negative id".into()),
162 "Invalid argument: negative id",
163 ),
164 (ForgeError::NotFound("user 42".into()), "Not found: user 42"),
165 (
166 ForgeError::Unauthorized("expired token".into()),
167 "Unauthorized: expired token",
168 ),
169 (
170 ForgeError::Forbidden("admin only".into()),
171 "Forbidden: admin only",
172 ),
173 (
174 ForgeError::Validation("email required".into()),
175 "Validation error: email required",
176 ),
177 (
178 ForgeError::Timeout("5s exceeded".into()),
179 "Timeout: 5s exceeded",
180 ),
181 (
182 ForgeError::Internal("null pointer".into()),
183 "Internal error: null pointer",
184 ),
185 (
186 ForgeError::InvalidState("already completed".into()),
187 "Invalid state: already completed",
188 ),
189 (ForgeError::WorkflowSuspended, "Workflow suspended"),
190 ];
191
192 for (error, expected) in cases {
193 assert_eq!(error.to_string(), expected, "Display mismatch for variant");
194 }
195 }
196
197 #[test]
198 fn display_rate_limit_includes_retry_after() {
199 let err = ForgeError::RateLimitExceeded {
200 retry_after: Duration::from_secs(30),
201 limit: 100,
202 remaining: 0,
203 };
204 let msg = err.to_string();
205 assert!(msg.contains("30"), "Expected retry_after in message: {msg}");
206 }
207
208 #[test]
211 fn from_serde_json_error_maps_to_serialization() {
212 let bad_json = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
213 let forge_err: ForgeError = bad_json.into();
214 match forge_err {
215 ForgeError::Serialization(msg) => assert!(!msg.is_empty()),
216 other => panic!("Expected Serialization, got: {other:?}"),
217 }
218 }
219
220 #[test]
221 fn from_io_error_maps_to_io() {
222 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
223 let forge_err: ForgeError = io_err.into();
224 match forge_err {
225 ForgeError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
226 other => panic!("Expected Io, got: {other:?}"),
227 }
228 }
229
230 #[test]
231 fn from_circuit_breaker_open_maps_to_timeout() {
232 let open = crate::http::CircuitBreakerError::CircuitOpen(crate::http::CircuitBreakerOpen {
233 host: "api.example.com".into(),
234 retry_after: Duration::from_secs(60),
235 });
236 let forge_err: ForgeError = open.into();
237 match forge_err {
238 ForgeError::Timeout(msg) => {
239 assert!(
240 msg.contains("api.example.com"),
241 "Expected host in message: {msg}"
242 );
243 }
244 other => panic!("Expected Timeout, got: {other:?}"),
245 }
246 }
247
248 #[test]
251 fn variants_are_distinguishable_via_pattern_match() {
252 let errors: Vec<ForgeError> = vec![
253 ForgeError::NotFound("x".into()),
254 ForgeError::Unauthorized("x".into()),
255 ForgeError::Forbidden("x".into()),
256 ForgeError::Validation("x".into()),
257 ForgeError::InvalidArgument("x".into()),
258 ForgeError::Timeout("x".into()),
259 ForgeError::Internal("x".into()),
260 ];
261
262 for (i, err) in errors.iter().enumerate() {
264 let matched = match err {
265 ForgeError::NotFound(_) => 0,
266 ForgeError::Unauthorized(_) => 1,
267 ForgeError::Forbidden(_) => 2,
268 ForgeError::Validation(_) => 3,
269 ForgeError::InvalidArgument(_) => 4,
270 ForgeError::Timeout(_) => 5,
271 ForgeError::Internal(_) => 6,
272 _ => usize::MAX,
273 };
274 assert_eq!(matched, i, "Variant at index {i} matched wrong pattern");
275 }
276 }
277
278 #[test]
279 fn rate_limit_fields_accessible() {
280 let err = ForgeError::RateLimitExceeded {
281 retry_after: Duration::from_secs(60),
282 limit: 100,
283 remaining: 0,
284 };
285
286 match err {
287 ForgeError::RateLimitExceeded {
288 retry_after,
289 limit,
290 remaining,
291 } => {
292 assert_eq!(retry_after, Duration::from_secs(60));
293 assert_eq!(limit, 100);
294 assert_eq!(remaining, 0);
295 }
296 _ => panic!("Expected RateLimitExceeded"),
297 }
298 }
299
300 #[test]
301 fn error_is_send_and_sync() {
302 fn assert_send<T: Send>() {}
303 fn assert_sync<T: Sync>() {}
304 assert_send::<ForgeError>();
306 assert_sync::<ForgeError>();
307 }
308}