1pub type Result<T> = std::result::Result<T, Error>;
5
6#[derive(Debug, thiserror::Error)]
8pub enum Error {
9 #[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 #[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 #[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 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 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 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
120pub 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 #[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 #[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 #[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 #[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 #[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 #[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); 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); assert_eq!(err.error_code(), "BUDGET_EXCEEDED");
653 assert!(!err.is_retryable());
654 }
655
656 #[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}