1use std::any;
12use std::fmt;
13use std::time::Duration;
14
15use thiserror::Error;
16
17use crate::provider::DebugMessage;
18
19#[derive(Debug, Error)]
23pub enum OperationError {
24 #[error("shell exited with code {exit_code}: {stderr}")]
26 Shell {
27 exit_code: i32,
29 stderr: String,
31 },
32
33 #[error("agent error: {0}")]
37 Agent(#[from] AgentError),
38
39 #[error("step '{step}' timed out after {limit:?}")]
41 Timeout {
42 step: String,
44 limit: Duration,
46 },
47
48 #[error("{}", match status {
51 Some(code) => format!("http error (status {code}): {message}"),
52 None => format!("http error: {message}"),
53 })]
54 Http {
55 status: Option<u16>,
57 message: String,
59 },
60
61 #[error("failed to deserialize into {target_type}: {reason}")]
66 Deserialize {
67 target_type: String,
69 reason: String,
71 },
72
73 #[error("secret error: {message}")]
78 Secret {
79 message: String,
81 },
82
83 #[error("{origin} error: {message}")]
100 External {
101 origin: String,
103 message: String,
105 },
106}
107
108impl OperationError {
109 pub fn deserialize<T>(error: impl fmt::Display) -> Self {
111 Self::Deserialize {
112 target_type: any::type_name::<T>().to_string(),
113 reason: error.to_string(),
114 }
115 }
116}
117
118#[derive(Debug, Default)]
124pub struct PartialUsage {
125 pub cost_usd: Option<f64>,
127 pub duration_ms: Option<u64>,
129 pub input_tokens: Option<u64>,
131 pub output_tokens: Option<u64>,
133}
134
135#[derive(Debug, Error)]
140pub enum AgentError {
141 #[error("claude process exited with code {exit_code}: {stderr}")]
143 ProcessFailed {
144 exit_code: i32,
146 stderr: String,
148 },
149
150 #[error("schema validation failed: expected {expected}, got {got}{}", raw_response.as_ref().map(|r| { let end = r.floor_char_boundary(200); format!(" (raw response: {}...)", &r[..end]) }).unwrap_or_default())]
152 SchemaValidation {
153 expected: String,
155 got: String,
157 debug_messages: Vec<DebugMessage>,
162 partial_usage: Box<PartialUsage>,
166 raw_response: Option<String>,
172 },
173
174 #[error("agent budget exceeded: spent ${spent_usd:.4} of ${limit_usd:.4} limit")]
181 BudgetExceeded {
182 spent_usd: f64,
184 limit_usd: f64,
186 debug_messages: Vec<DebugMessage>,
188 partial_usage: Box<PartialUsage>,
191 },
192
193 #[error(
202 "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
203 )]
204 PromptTooLarge {
205 chars: usize,
207 estimated_tokens: usize,
209 model_limit: usize,
211 },
212
213 #[error("agent timed out after {limit:?}")]
215 Timeout {
216 limit: Duration,
218 },
219
220 #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
222 RateLimited {
223 provider: String,
225 retry_after_secs: Option<u64>,
227 },
228
229 #[error("{provider} HTTP {status_code}: {message}")]
234 HttpProvider {
235 provider: String,
237 status_code: u16,
239 message: String,
241 },
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn shell_display_format() {
250 let err = OperationError::Shell {
251 exit_code: 127,
252 stderr: "command not found".to_string(),
253 };
254 assert_eq!(
255 err.to_string(),
256 "shell exited with code 127: command not found"
257 );
258 }
259
260 #[test]
261 fn agent_display_delegates_to_agent_error() {
262 let inner = AgentError::ProcessFailed {
263 exit_code: 1,
264 stderr: "boom".to_string(),
265 };
266 let err = OperationError::Agent(inner);
267 assert_eq!(
268 err.to_string(),
269 "agent error: claude process exited with code 1: boom"
270 );
271 }
272
273 #[test]
274 fn timeout_display_format() {
275 let err = OperationError::Timeout {
276 step: "build".to_string(),
277 limit: Duration::from_secs(30),
278 };
279 assert_eq!(err.to_string(), "step 'build' timed out after 30s");
280 }
281
282 #[test]
283 fn agent_error_process_failed_display_zero_exit_code() {
284 let err = AgentError::ProcessFailed {
285 exit_code: 0,
286 stderr: "unexpected".to_string(),
287 };
288 assert_eq!(
289 err.to_string(),
290 "claude process exited with code 0: unexpected"
291 );
292 }
293
294 #[test]
295 fn agent_error_process_failed_display_negative_exit_code() {
296 let err = AgentError::ProcessFailed {
297 exit_code: -1,
298 stderr: "killed".to_string(),
299 };
300 assert!(err.to_string().contains("-1"));
301 }
302
303 #[test]
304 fn agent_error_schema_validation_display() {
305 let err = AgentError::SchemaValidation {
306 expected: "object".to_string(),
307 got: "string".to_string(),
308 debug_messages: Vec::new(),
309 partial_usage: Box::default(),
310 raw_response: None,
311 };
312 assert_eq!(
313 err.to_string(),
314 "schema validation failed: expected object, got string"
315 );
316 }
317
318 #[test]
319 fn agent_error_timeout_display() {
320 let err = AgentError::Timeout {
321 limit: Duration::from_secs(300),
322 };
323 assert_eq!(err.to_string(), "agent timed out after 300s");
324 }
325
326 #[test]
327 fn from_agent_error_process_failed() {
328 let agent_err = AgentError::ProcessFailed {
329 exit_code: 42,
330 stderr: "fail".to_string(),
331 };
332 let op_err: OperationError = agent_err.into();
333 assert!(matches!(
334 op_err,
335 OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
336 ));
337 }
338
339 #[test]
340 fn from_agent_error_schema_validation() {
341 let agent_err = AgentError::SchemaValidation {
342 expected: "a".to_string(),
343 got: "b".to_string(),
344 debug_messages: Vec::new(),
345 partial_usage: Box::default(),
346 raw_response: None,
347 };
348 let op_err: OperationError = agent_err.into();
349 assert!(matches!(
350 op_err,
351 OperationError::Agent(AgentError::SchemaValidation { .. })
352 ));
353 }
354
355 #[test]
356 fn from_agent_error_timeout() {
357 let agent_err = AgentError::Timeout {
358 limit: Duration::from_secs(60),
359 };
360 let op_err: OperationError = agent_err.into();
361 assert!(matches!(
362 op_err,
363 OperationError::Agent(AgentError::Timeout { .. })
364 ));
365 }
366
367 #[test]
368 fn operation_error_implements_std_error() {
369 use std::error::Error;
370 let err = OperationError::Shell {
371 exit_code: 1,
372 stderr: "x".to_string(),
373 };
374 let _: &dyn Error = &err;
375 }
376
377 #[test]
378 fn agent_error_implements_std_error() {
379 use std::error::Error;
380 let err = AgentError::Timeout {
381 limit: Duration::from_secs(60),
382 };
383 let _: &dyn Error = &err;
384 }
385
386 #[test]
387 fn empty_stderr_edge_case() {
388 let err = OperationError::Shell {
389 exit_code: 1,
390 stderr: String::new(),
391 };
392 assert_eq!(err.to_string(), "shell exited with code 1: ");
393 }
394
395 #[test]
396 fn multiline_stderr() {
397 let err = AgentError::ProcessFailed {
398 exit_code: 1,
399 stderr: "line1\nline2\nline3".to_string(),
400 };
401 assert!(err.to_string().contains("line1\nline2\nline3"));
402 }
403
404 #[test]
405 fn unicode_in_stderr() {
406 let err = OperationError::Shell {
407 exit_code: 1,
408 stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
409 };
410 assert!(err.to_string().contains("\u{1F4A5}"));
411 }
412
413 #[test]
414 fn http_error_with_status_display() {
415 let err = OperationError::Http {
416 status: Some(500),
417 message: "internal server error".to_string(),
418 };
419 assert_eq!(
420 err.to_string(),
421 "http error (status 500): internal server error"
422 );
423 }
424
425 #[test]
426 fn http_error_without_status_display() {
427 let err = OperationError::Http {
428 status: None,
429 message: "connection refused".to_string(),
430 };
431 assert_eq!(err.to_string(), "http error: connection refused");
432 }
433
434 #[test]
435 fn http_error_empty_message() {
436 let err = OperationError::Http {
437 status: Some(404),
438 message: String::new(),
439 };
440 assert_eq!(err.to_string(), "http error (status 404): ");
441 }
442
443 #[test]
444 fn subsecond_duration_in_timeout_display() {
445 let err = OperationError::Timeout {
446 step: "fast".to_string(),
447 limit: Duration::from_millis(500),
448 };
449 assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
450 }
451
452 #[test]
453 fn source_chains_agent_error() {
454 use std::error::Error;
455 let err = OperationError::Agent(AgentError::Timeout {
456 limit: Duration::from_secs(60),
457 });
458 assert!(err.source().is_some());
459 }
460
461 #[test]
462 fn source_none_for_shell() {
463 use std::error::Error;
464 let err = OperationError::Shell {
465 exit_code: 1,
466 stderr: "x".to_string(),
467 };
468 assert!(err.source().is_none());
469 }
470
471 #[test]
472 fn deserialize_helper_formats_correctly() {
473 let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
474 match &err {
475 OperationError::Deserialize {
476 target_type,
477 reason,
478 } => {
479 assert!(target_type.contains("Vec"));
480 assert!(target_type.contains("String"));
481 assert_eq!(reason, "missing field");
482 }
483 _ => panic!("expected Deserialize variant"),
484 }
485 }
486
487 #[test]
488 fn deserialize_display_format() {
489 let err = OperationError::Deserialize {
490 target_type: "MyStruct".to_string(),
491 reason: "bad input".to_string(),
492 };
493 assert_eq!(
494 err.to_string(),
495 "failed to deserialize into MyStruct: bad input"
496 );
497 }
498
499 #[test]
500 fn agent_error_prompt_too_large_display() {
501 let err = AgentError::PromptTooLarge {
502 chars: 966_007,
503 estimated_tokens: 241_501,
504 model_limit: 200_000,
505 };
506 let msg = err.to_string();
507 assert!(msg.contains("966007 chars"));
508 assert!(msg.contains("241501 tokens"));
509 assert!(msg.contains("200000 tokens"));
510 }
511
512 #[test]
513 fn from_agent_error_prompt_too_large() {
514 let agent_err = AgentError::PromptTooLarge {
515 chars: 1_000_000,
516 estimated_tokens: 250_000,
517 model_limit: 200_000,
518 };
519 let op_err: OperationError = agent_err.into();
520 assert!(matches!(
521 op_err,
522 OperationError::Agent(AgentError::PromptTooLarge {
523 model_limit: 200_000,
524 ..
525 })
526 ));
527 }
528
529 #[test]
530 fn source_none_for_http_timeout_deserialize() {
531 use std::error::Error;
532 let http = OperationError::Http {
533 status: Some(500),
534 message: "x".to_string(),
535 };
536 assert!(http.source().is_none());
537
538 let timeout = OperationError::Timeout {
539 step: "x".to_string(),
540 limit: Duration::from_secs(1),
541 };
542 assert!(timeout.source().is_none());
543
544 let deser = OperationError::Deserialize {
545 target_type: "T".to_string(),
546 reason: "r".to_string(),
547 };
548 assert!(deser.source().is_none());
549 }
550
551 #[test]
552 fn schema_validation_raw_response_preserved() {
553 let err = AgentError::SchemaValidation {
554 expected: "structured_output field".to_string(),
555 got: "null".to_string(),
556 debug_messages: Vec::new(),
557 partial_usage: Box::default(),
558 raw_response: Some("The model said something useful".to_string()),
559 };
560 match err {
561 AgentError::SchemaValidation { raw_response, .. } => {
562 assert_eq!(
563 raw_response.as_deref(),
564 Some("The model said something useful")
565 );
566 }
567 _ => panic!("expected SchemaValidation"),
568 }
569 }
570
571 #[test]
572 fn external_error_display() {
573 let err = OperationError::External {
574 origin: "git".to_string(),
575 message: "reference not found".to_string(),
576 };
577 assert_eq!(err.to_string(), "git error: reference not found");
578 }
579
580 #[test]
581 fn schema_validation_raw_response_none_by_default() {
582 let err = AgentError::SchemaValidation {
583 expected: "a".to_string(),
584 got: "b".to_string(),
585 debug_messages: Vec::new(),
586 partial_usage: Box::default(),
587 raw_response: None,
588 };
589 match err {
590 AgentError::SchemaValidation { raw_response, .. } => {
591 assert!(raw_response.is_none());
592 }
593 _ => panic!("expected SchemaValidation"),
594 }
595 }
596}