Skip to main content

forge_core/
error.rs

1use std::time::Duration;
2
3use thiserror::Error;
4
5/// Core error type for Forge operations.
6///
7/// Each variant maps to an HTTP status code and error code for consistent client handling.
8#[derive(Error, Debug)]
9pub enum ForgeError {
10    /// Configuration file parsing or validation failed.
11    #[error("Configuration error: {0}")]
12    Config(String),
13
14    /// Database operation failed.
15    #[error("Database error: {0}")]
16    Database(String),
17
18    /// Function execution failed.
19    #[error("Function error: {0}")]
20    Function(String),
21
22    /// Job execution failed.
23    #[error("Job error: {0}")]
24    Job(String),
25
26    /// Job was cancelled before completion.
27    #[error("Job cancelled: {0}")]
28    JobCancelled(String),
29
30    /// Cluster coordination failed.
31    #[error("Cluster error: {0}")]
32    Cluster(String),
33
34    /// Failed to serialize data to JSON.
35    #[error("Serialization error: {0}")]
36    Serialization(String),
37
38    /// Failed to deserialize JSON input.
39    #[error("Deserialization error: {0}")]
40    Deserialization(String),
41
42    /// File system operation failed.
43    #[error("IO error: {0}")]
44    Io(#[from] std::io::Error),
45
46    /// SQL execution failed.
47    #[error("SQL error: {0}")]
48    Sql(#[from] sqlx::Error),
49
50    /// Invalid argument provided (400).
51    #[error("Invalid argument: {0}")]
52    InvalidArgument(String),
53
54    /// Requested resource not found (404).
55    #[error("Not found: {0}")]
56    NotFound(String),
57
58    /// Authentication required or failed (401).
59    #[error("Unauthorized: {0}")]
60    Unauthorized(String),
61
62    /// Permission denied (403).
63    #[error("Forbidden: {0}")]
64    Forbidden(String),
65
66    /// Input validation failed (400).
67    #[error("Validation error: {0}")]
68    Validation(String),
69
70    /// Operation timed out (504).
71    #[error("Timeout: {0}")]
72    Timeout(String),
73
74    /// Unexpected internal error (500).
75    #[error("Internal error: {0}")]
76    Internal(String),
77
78    /// Invalid state transition attempted.
79    #[error("Invalid state: {0}")]
80    InvalidState(String),
81
82    /// Internal signal for workflow suspension. Never returned to clients.
83    #[error("Workflow suspended")]
84    WorkflowSuspended,
85
86    /// Rate limit exceeded (429).
87    #[error("Rate limit exceeded: retry after {retry_after:?}")]
88    RateLimitExceeded {
89        /// How long to wait before retrying.
90        retry_after: Duration,
91        /// The configured request limit.
92        limit: u32,
93        /// Remaining requests (always 0 when exceeded).
94        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
118/// Result type alias using ForgeError.
119pub 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    // --- Display / error messages ---
127
128    #[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    // --- From implementations ---
209
210    #[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    // --- Variant matching (critical for downstream error handling) ---
249
250    #[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        // Each variant must match only its own pattern
263        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        // ForgeError must be Send+Sync for use across async task boundaries
305        assert_send::<ForgeError>();
306        assert_sync::<ForgeError>();
307    }
308}