Skip to main content

fd_core/
error.rs

1//! Error types for FerrumDeck
2
3/// Result type alias using FerrumDeck Error
4pub type Result<T> = std::result::Result<T, Error>;
5
6/// FerrumDeck error types
7#[derive(Debug, thiserror::Error)]
8pub enum Error {
9    // ==========================================================================
10    // Client errors (4xx)
11    // ==========================================================================
12    #[error("not found: {entity} with id {id}")]
13    NotFound { entity: &'static str, id: String },
14
15    #[error("validation error: {message}")]
16    Validation {
17        message: String,
18        field: Option<String>,
19    },
20
21    #[error("unauthorized: {message}")]
22    Unauthorized { message: String },
23
24    #[error("forbidden: {message}")]
25    Forbidden { message: String },
26
27    #[error("conflict: {message}")]
28    Conflict { message: String },
29
30    #[error("rate limited: retry after {retry_after_secs}s")]
31    RateLimited { retry_after_secs: u64 },
32
33    // ==========================================================================
34    // Policy errors
35    // ==========================================================================
36    #[error("policy denied: {reason}")]
37    PolicyDenied {
38        reason: String,
39        rule_id: Option<String>,
40    },
41
42    #[error("budget exceeded: {resource} limit of {limit} reached")]
43    BudgetExceeded { resource: String, limit: String },
44
45    #[error("approval required for action: {action}")]
46    ApprovalRequired { action: String, request_id: String },
47
48    // ==========================================================================
49    // Internal errors (5xx)
50    // ==========================================================================
51    #[error("database error: {0}")]
52    Database(String),
53
54    #[error("queue error: {0}")]
55    Queue(String),
56
57    #[error("external service error: {service} - {message}")]
58    ExternalService { service: String, message: String },
59
60    #[error("internal error: {0}")]
61    Internal(String),
62
63    #[error("configuration error: {0}")]
64    Config(String),
65}
66
67impl Error {
68    /// HTTP status code for this error
69    pub fn status_code(&self) -> u16 {
70        match self {
71            Error::NotFound { .. } => 404,
72            Error::Validation { .. } => 400,
73            Error::Unauthorized { .. } => 401,
74            Error::Forbidden { .. } => 403,
75            Error::Conflict { .. } => 409,
76            Error::RateLimited { .. } => 429,
77            Error::PolicyDenied { .. } => 403,
78            Error::BudgetExceeded { .. } => 402,
79            Error::ApprovalRequired { .. } => 202,
80            Error::Database(_) => 500,
81            Error::Queue(_) => 500,
82            Error::ExternalService { .. } => 502,
83            Error::Internal(_) => 500,
84            Error::Config(_) => 500,
85        }
86    }
87
88    /// Error code for API responses
89    pub fn error_code(&self) -> &'static str {
90        match self {
91            Error::NotFound { .. } => "NOT_FOUND",
92            Error::Validation { .. } => "VALIDATION_ERROR",
93            Error::Unauthorized { .. } => "UNAUTHORIZED",
94            Error::Forbidden { .. } => "FORBIDDEN",
95            Error::Conflict { .. } => "CONFLICT",
96            Error::RateLimited { .. } => "RATE_LIMITED",
97            Error::PolicyDenied { .. } => "POLICY_DENIED",
98            Error::BudgetExceeded { .. } => "BUDGET_EXCEEDED",
99            Error::ApprovalRequired { .. } => "APPROVAL_REQUIRED",
100            Error::Database(_) => "DATABASE_ERROR",
101            Error::Queue(_) => "QUEUE_ERROR",
102            Error::ExternalService { .. } => "EXTERNAL_SERVICE_ERROR",
103            Error::Internal(_) => "INTERNAL_ERROR",
104            Error::Config(_) => "CONFIG_ERROR",
105        }
106    }
107
108    /// Whether this error should be retried
109    pub fn is_retryable(&self) -> bool {
110        matches!(
111            self,
112            Error::RateLimited { .. }
113                | Error::Database(_)
114                | Error::Queue(_)
115                | Error::ExternalService { .. }
116        )
117    }
118}
119
120/// Validation error builder
121pub struct ValidationError {
122    message: String,
123    field: Option<String>,
124}
125
126impl ValidationError {
127    pub fn new(message: impl Into<String>) -> Self {
128        Self {
129            message: message.into(),
130            field: None,
131        }
132    }
133
134    pub fn field(mut self, field: impl Into<String>) -> Self {
135        self.field = Some(field.into());
136        self
137    }
138
139    pub fn build(self) -> Error {
140        Error::Validation {
141            message: self.message,
142            field: self.field,
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    // ==========================================================================
152    // CORE-ERR-001: Status codes map correctly
153    // ==========================================================================
154    #[test]
155    fn test_not_found_status_code() {
156        let err = Error::NotFound {
157            entity: "Run",
158            id: "run_123".to_string(),
159        };
160        assert_eq!(err.status_code(), 404);
161    }
162
163    #[test]
164    fn test_validation_status_code() {
165        let err = Error::Validation {
166            message: "Invalid input".to_string(),
167            field: None,
168        };
169        assert_eq!(err.status_code(), 400);
170    }
171
172    #[test]
173    fn test_unauthorized_status_code() {
174        let err = Error::Unauthorized {
175            message: "Invalid token".to_string(),
176        };
177        assert_eq!(err.status_code(), 401);
178    }
179
180    #[test]
181    fn test_forbidden_status_code() {
182        let err = Error::Forbidden {
183            message: "Access denied".to_string(),
184        };
185        assert_eq!(err.status_code(), 403);
186    }
187
188    #[test]
189    fn test_conflict_status_code() {
190        let err = Error::Conflict {
191            message: "Resource already exists".to_string(),
192        };
193        assert_eq!(err.status_code(), 409);
194    }
195
196    #[test]
197    fn test_rate_limited_status_code() {
198        let err = Error::RateLimited {
199            retry_after_secs: 60,
200        };
201        assert_eq!(err.status_code(), 429);
202    }
203
204    #[test]
205    fn test_policy_denied_status_code() {
206        let err = Error::PolicyDenied {
207            reason: "Tool not allowed".to_string(),
208            rule_id: None,
209        };
210        assert_eq!(err.status_code(), 403);
211    }
212
213    #[test]
214    fn test_budget_exceeded_status_code() {
215        let err = Error::BudgetExceeded {
216            resource: "tokens".to_string(),
217            limit: "10000".to_string(),
218        };
219        assert_eq!(err.status_code(), 402);
220    }
221
222    #[test]
223    fn test_approval_required_status_code() {
224        let err = Error::ApprovalRequired {
225            action: "deploy".to_string(),
226            request_id: "req_123".to_string(),
227        };
228        assert_eq!(err.status_code(), 202);
229    }
230
231    #[test]
232    fn test_database_status_code() {
233        let err = Error::Database("Connection failed".to_string());
234        assert_eq!(err.status_code(), 500);
235    }
236
237    #[test]
238    fn test_queue_status_code() {
239        let err = Error::Queue("Redis unavailable".to_string());
240        assert_eq!(err.status_code(), 500);
241    }
242
243    #[test]
244    fn test_external_service_status_code() {
245        let err = Error::ExternalService {
246            service: "LLM".to_string(),
247            message: "Timeout".to_string(),
248        };
249        assert_eq!(err.status_code(), 502);
250    }
251
252    #[test]
253    fn test_internal_status_code() {
254        let err = Error::Internal("Unexpected state".to_string());
255        assert_eq!(err.status_code(), 500);
256    }
257
258    #[test]
259    fn test_config_status_code() {
260        let err = Error::Config("Missing required setting".to_string());
261        assert_eq!(err.status_code(), 500);
262    }
263
264    // ==========================================================================
265    // CORE-ERR-002: Error codes map correctly
266    // ==========================================================================
267    #[test]
268    fn test_error_code_not_found() {
269        let err = Error::NotFound {
270            entity: "Run",
271            id: "123".to_string(),
272        };
273        assert_eq!(err.error_code(), "NOT_FOUND");
274    }
275
276    #[test]
277    fn test_error_code_validation() {
278        let err = Error::Validation {
279            message: "test".to_string(),
280            field: None,
281        };
282        assert_eq!(err.error_code(), "VALIDATION_ERROR");
283    }
284
285    #[test]
286    fn test_error_code_unauthorized() {
287        let err = Error::Unauthorized {
288            message: "test".to_string(),
289        };
290        assert_eq!(err.error_code(), "UNAUTHORIZED");
291    }
292
293    #[test]
294    fn test_error_code_forbidden() {
295        let err = Error::Forbidden {
296            message: "test".to_string(),
297        };
298        assert_eq!(err.error_code(), "FORBIDDEN");
299    }
300
301    #[test]
302    fn test_error_code_conflict() {
303        let err = Error::Conflict {
304            message: "test".to_string(),
305        };
306        assert_eq!(err.error_code(), "CONFLICT");
307    }
308
309    #[test]
310    fn test_error_code_rate_limited() {
311        let err = Error::RateLimited {
312            retry_after_secs: 30,
313        };
314        assert_eq!(err.error_code(), "RATE_LIMITED");
315    }
316
317    #[test]
318    fn test_error_code_policy_denied() {
319        let err = Error::PolicyDenied {
320            reason: "test".to_string(),
321            rule_id: None,
322        };
323        assert_eq!(err.error_code(), "POLICY_DENIED");
324    }
325
326    #[test]
327    fn test_error_code_budget_exceeded() {
328        let err = Error::BudgetExceeded {
329            resource: "test".to_string(),
330            limit: "100".to_string(),
331        };
332        assert_eq!(err.error_code(), "BUDGET_EXCEEDED");
333    }
334
335    #[test]
336    fn test_error_code_approval_required() {
337        let err = Error::ApprovalRequired {
338            action: "test".to_string(),
339            request_id: "req_1".to_string(),
340        };
341        assert_eq!(err.error_code(), "APPROVAL_REQUIRED");
342    }
343
344    #[test]
345    fn test_error_code_database() {
346        let err = Error::Database("test".to_string());
347        assert_eq!(err.error_code(), "DATABASE_ERROR");
348    }
349
350    #[test]
351    fn test_error_code_queue() {
352        let err = Error::Queue("test".to_string());
353        assert_eq!(err.error_code(), "QUEUE_ERROR");
354    }
355
356    #[test]
357    fn test_error_code_external_service() {
358        let err = Error::ExternalService {
359            service: "test".to_string(),
360            message: "msg".to_string(),
361        };
362        assert_eq!(err.error_code(), "EXTERNAL_SERVICE_ERROR");
363    }
364
365    #[test]
366    fn test_error_code_internal() {
367        let err = Error::Internal("test".to_string());
368        assert_eq!(err.error_code(), "INTERNAL_ERROR");
369    }
370
371    #[test]
372    fn test_error_code_config() {
373        let err = Error::Config("test".to_string());
374        assert_eq!(err.error_code(), "CONFIG_ERROR");
375    }
376
377    // ==========================================================================
378    // CORE-ERR-003: is_retryable() identifies transient errors
379    // ==========================================================================
380    #[test]
381    fn test_rate_limited_is_retryable() {
382        let err = Error::RateLimited {
383            retry_after_secs: 60,
384        };
385        assert!(err.is_retryable());
386    }
387
388    #[test]
389    fn test_database_is_retryable() {
390        let err = Error::Database("Connection lost".to_string());
391        assert!(err.is_retryable());
392    }
393
394    #[test]
395    fn test_queue_is_retryable() {
396        let err = Error::Queue("Timeout".to_string());
397        assert!(err.is_retryable());
398    }
399
400    #[test]
401    fn test_external_service_is_retryable() {
402        let err = Error::ExternalService {
403            service: "LLM".to_string(),
404            message: "Rate limited".to_string(),
405        };
406        assert!(err.is_retryable());
407    }
408
409    #[test]
410    fn test_not_found_is_not_retryable() {
411        let err = Error::NotFound {
412            entity: "Run",
413            id: "123".to_string(),
414        };
415        assert!(!err.is_retryable());
416    }
417
418    #[test]
419    fn test_validation_is_not_retryable() {
420        let err = Error::Validation {
421            message: "Invalid".to_string(),
422            field: None,
423        };
424        assert!(!err.is_retryable());
425    }
426
427    #[test]
428    fn test_unauthorized_is_not_retryable() {
429        let err = Error::Unauthorized {
430            message: "Bad token".to_string(),
431        };
432        assert!(!err.is_retryable());
433    }
434
435    #[test]
436    fn test_forbidden_is_not_retryable() {
437        let err = Error::Forbidden {
438            message: "Access denied".to_string(),
439        };
440        assert!(!err.is_retryable());
441    }
442
443    #[test]
444    fn test_policy_denied_is_not_retryable() {
445        let err = Error::PolicyDenied {
446            reason: "Tool blocked".to_string(),
447            rule_id: None,
448        };
449        assert!(!err.is_retryable());
450    }
451
452    #[test]
453    fn test_budget_exceeded_is_not_retryable() {
454        let err = Error::BudgetExceeded {
455            resource: "tokens".to_string(),
456            limit: "1000".to_string(),
457        };
458        assert!(!err.is_retryable());
459    }
460
461    #[test]
462    fn test_internal_is_not_retryable() {
463        let err = Error::Internal("Bug".to_string());
464        assert!(!err.is_retryable());
465    }
466
467    #[test]
468    fn test_config_is_not_retryable() {
469        let err = Error::Config("Missing key".to_string());
470        assert!(!err.is_retryable());
471    }
472
473    // ==========================================================================
474    // CORE-ERR-004: Error display messages are correct
475    // ==========================================================================
476    #[test]
477    fn test_not_found_display() {
478        let err = Error::NotFound {
479            entity: "Run",
480            id: "run_abc123".to_string(),
481        };
482        let msg = err.to_string();
483        assert!(msg.contains("not found"));
484        assert!(msg.contains("Run"));
485        assert!(msg.contains("run_abc123"));
486    }
487
488    #[test]
489    fn test_validation_display() {
490        let err = Error::Validation {
491            message: "Invalid email format".to_string(),
492            field: Some("email".to_string()),
493        };
494        let msg = err.to_string();
495        assert!(msg.contains("validation error"));
496        assert!(msg.contains("Invalid email format"));
497    }
498
499    #[test]
500    fn test_rate_limited_display() {
501        let err = Error::RateLimited {
502            retry_after_secs: 120,
503        };
504        let msg = err.to_string();
505        assert!(msg.contains("rate limited"));
506        assert!(msg.contains("120"));
507    }
508
509    #[test]
510    fn test_policy_denied_display() {
511        let err = Error::PolicyDenied {
512            reason: "Tool not in allowlist".to_string(),
513            rule_id: Some("pol_xyz".to_string()),
514        };
515        let msg = err.to_string();
516        assert!(msg.contains("policy denied"));
517        assert!(msg.contains("Tool not in allowlist"));
518    }
519
520    #[test]
521    fn test_budget_exceeded_display() {
522        let err = Error::BudgetExceeded {
523            resource: "API calls".to_string(),
524            limit: "1000".to_string(),
525        };
526        let msg = err.to_string();
527        assert!(msg.contains("budget exceeded"));
528        assert!(msg.contains("API calls"));
529        assert!(msg.contains("1000"));
530    }
531
532    #[test]
533    fn test_approval_required_display() {
534        let err = Error::ApprovalRequired {
535            action: "delete_production".to_string(),
536            request_id: "req_999".to_string(),
537        };
538        let msg = err.to_string();
539        assert!(msg.contains("approval required"));
540        assert!(msg.contains("delete_production"));
541    }
542
543    #[test]
544    fn test_external_service_display() {
545        let err = Error::ExternalService {
546            service: "OpenAI".to_string(),
547            message: "API quota exceeded".to_string(),
548        };
549        let msg = err.to_string();
550        assert!(msg.contains("external service error"));
551        assert!(msg.contains("OpenAI"));
552        assert!(msg.contains("API quota exceeded"));
553    }
554
555    // ==========================================================================
556    // CORE-ERR-005: ValidationError builder works
557    // ==========================================================================
558    #[test]
559    fn test_validation_error_builder_simple() {
560        let err = ValidationError::new("Invalid value").build();
561        match err {
562            Error::Validation { message, field } => {
563                assert_eq!(message, "Invalid value");
564                assert!(field.is_none());
565            }
566            _ => panic!("Expected Validation error"),
567        }
568    }
569
570    #[test]
571    fn test_validation_error_builder_with_field() {
572        let err = ValidationError::new("Must be positive")
573            .field("amount")
574            .build();
575        match err {
576            Error::Validation { message, field } => {
577                assert_eq!(message, "Must be positive");
578                assert_eq!(field, Some("amount".to_string()));
579            }
580            _ => panic!("Expected Validation error"),
581        }
582    }
583
584    #[test]
585    fn test_validation_error_builder_string_ownership() {
586        let msg = String::from("Dynamic error");
587        let field_name = String::from("dynamic_field");
588        let err = ValidationError::new(msg).field(field_name).build();
589        match err {
590            Error::Validation { message, field } => {
591                assert_eq!(message, "Dynamic error");
592                assert_eq!(field, Some("dynamic_field".to_string()));
593            }
594            _ => panic!("Expected Validation error"),
595        }
596    }
597
598    #[test]
599    fn test_validation_error_builder_status_code() {
600        let err = ValidationError::new("test").build();
601        assert_eq!(err.status_code(), 400);
602    }
603
604    #[test]
605    fn test_validation_error_builder_error_code() {
606        let err = ValidationError::new("test").build();
607        assert_eq!(err.error_code(), "VALIDATION_ERROR");
608    }
609
610    // ==========================================================================
611    // CORE-ERR-006: Policy error variants work correctly
612    // ==========================================================================
613    #[test]
614    fn test_policy_denied_without_rule_id() {
615        let err = Error::PolicyDenied {
616            reason: "Tool not allowed".to_string(),
617            rule_id: None,
618        };
619        assert_eq!(err.status_code(), 403);
620        assert_eq!(err.error_code(), "POLICY_DENIED");
621        assert!(!err.is_retryable());
622    }
623
624    #[test]
625    fn test_policy_denied_with_rule_id() {
626        let err = Error::PolicyDenied {
627            reason: "Blocked by rule".to_string(),
628            rule_id: Some("pol_123".to_string()),
629        };
630        let msg = err.to_string();
631        assert!(msg.contains("Blocked by rule"));
632    }
633
634    #[test]
635    fn test_approval_required_full() {
636        let err = Error::ApprovalRequired {
637            action: "production_deploy".to_string(),
638            request_id: "apr_456".to_string(),
639        };
640        assert_eq!(err.status_code(), 202); // Accepted (pending approval)
641        assert_eq!(err.error_code(), "APPROVAL_REQUIRED");
642        assert!(!err.is_retryable());
643    }
644
645    #[test]
646    fn test_budget_exceeded_full() {
647        let err = Error::BudgetExceeded {
648            resource: "input_tokens".to_string(),
649            limit: "100000".to_string(),
650        };
651        assert_eq!(err.status_code(), 402); // Payment Required
652        assert_eq!(err.error_code(), "BUDGET_EXCEEDED");
653        assert!(!err.is_retryable());
654    }
655
656    // ==========================================================================
657    // Additional edge case tests
658    // ==========================================================================
659    #[test]
660    fn test_error_debug_format() {
661        let err = Error::NotFound {
662            entity: "Agent",
663            id: "agt_xyz".to_string(),
664        };
665        let debug = format!("{:?}", err);
666        assert!(debug.contains("NotFound"));
667        assert!(debug.contains("Agent"));
668        assert!(debug.contains("agt_xyz"));
669    }
670
671    #[test]
672    fn test_result_type_alias() {
673        fn returns_error() -> Result<()> {
674            Err(Error::Internal("test".to_string()))
675        }
676
677        let result = returns_error();
678        assert!(result.is_err());
679    }
680
681    #[test]
682    fn test_error_can_be_matched() {
683        let err = Error::NotFound {
684            entity: "Step",
685            id: "stp_123".to_string(),
686        };
687
688        let is_not_found = matches!(err, Error::NotFound { .. });
689        assert!(is_not_found);
690    }
691}