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
84impl OperationError {
85 pub fn deserialize<T>(error: impl fmt::Display) -> Self {
87 Self::Deserialize {
88 target_type: any::type_name::<T>().to_string(),
89 reason: error.to_string(),
90 }
91 }
92}
93
94#[derive(Debug, Default)]
100pub struct PartialUsage {
101 pub cost_usd: Option<f64>,
103 pub duration_ms: Option<u64>,
105 pub input_tokens: Option<u64>,
107 pub output_tokens: Option<u64>,
109}
110
111#[derive(Debug, Error)]
116pub enum AgentError {
117 #[error("claude process exited with code {exit_code}: {stderr}")]
119 ProcessFailed {
120 exit_code: i32,
122 stderr: String,
124 },
125
126 #[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())]
128 SchemaValidation {
129 expected: String,
131 got: String,
133 debug_messages: Vec<DebugMessage>,
138 partial_usage: Box<PartialUsage>,
142 raw_response: Option<String>,
148 },
149
150 #[error("agent budget exceeded: spent ${spent_usd:.4} of ${limit_usd:.4} limit")]
157 BudgetExceeded {
158 spent_usd: f64,
160 limit_usd: f64,
162 debug_messages: Vec<DebugMessage>,
164 partial_usage: Box<PartialUsage>,
167 },
168
169 #[error(
178 "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
179 )]
180 PromptTooLarge {
181 chars: usize,
183 estimated_tokens: usize,
185 model_limit: usize,
187 },
188
189 #[error("agent timed out after {limit:?}")]
191 Timeout {
192 limit: Duration,
194 },
195
196 #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
198 RateLimited {
199 provider: String,
201 retry_after_secs: Option<u64>,
203 },
204
205 #[error("{provider} HTTP {status_code}: {message}")]
210 HttpProvider {
211 provider: String,
213 status_code: u16,
215 message: String,
217 },
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn shell_display_format() {
226 let err = OperationError::Shell {
227 exit_code: 127,
228 stderr: "command not found".to_string(),
229 };
230 assert_eq!(
231 err.to_string(),
232 "shell exited with code 127: command not found"
233 );
234 }
235
236 #[test]
237 fn agent_display_delegates_to_agent_error() {
238 let inner = AgentError::ProcessFailed {
239 exit_code: 1,
240 stderr: "boom".to_string(),
241 };
242 let err = OperationError::Agent(inner);
243 assert_eq!(
244 err.to_string(),
245 "agent error: claude process exited with code 1: boom"
246 );
247 }
248
249 #[test]
250 fn timeout_display_format() {
251 let err = OperationError::Timeout {
252 step: "build".to_string(),
253 limit: Duration::from_secs(30),
254 };
255 assert_eq!(err.to_string(), "step 'build' timed out after 30s");
256 }
257
258 #[test]
259 fn agent_error_process_failed_display_zero_exit_code() {
260 let err = AgentError::ProcessFailed {
261 exit_code: 0,
262 stderr: "unexpected".to_string(),
263 };
264 assert_eq!(
265 err.to_string(),
266 "claude process exited with code 0: unexpected"
267 );
268 }
269
270 #[test]
271 fn agent_error_process_failed_display_negative_exit_code() {
272 let err = AgentError::ProcessFailed {
273 exit_code: -1,
274 stderr: "killed".to_string(),
275 };
276 assert!(err.to_string().contains("-1"));
277 }
278
279 #[test]
280 fn agent_error_schema_validation_display() {
281 let err = AgentError::SchemaValidation {
282 expected: "object".to_string(),
283 got: "string".to_string(),
284 debug_messages: Vec::new(),
285 partial_usage: Box::default(),
286 raw_response: None,
287 };
288 assert_eq!(
289 err.to_string(),
290 "schema validation failed: expected object, got string"
291 );
292 }
293
294 #[test]
295 fn agent_error_timeout_display() {
296 let err = AgentError::Timeout {
297 limit: Duration::from_secs(300),
298 };
299 assert_eq!(err.to_string(), "agent timed out after 300s");
300 }
301
302 #[test]
303 fn from_agent_error_process_failed() {
304 let agent_err = AgentError::ProcessFailed {
305 exit_code: 42,
306 stderr: "fail".to_string(),
307 };
308 let op_err: OperationError = agent_err.into();
309 assert!(matches!(
310 op_err,
311 OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
312 ));
313 }
314
315 #[test]
316 fn from_agent_error_schema_validation() {
317 let agent_err = AgentError::SchemaValidation {
318 expected: "a".to_string(),
319 got: "b".to_string(),
320 debug_messages: Vec::new(),
321 partial_usage: Box::default(),
322 raw_response: None,
323 };
324 let op_err: OperationError = agent_err.into();
325 assert!(matches!(
326 op_err,
327 OperationError::Agent(AgentError::SchemaValidation { .. })
328 ));
329 }
330
331 #[test]
332 fn from_agent_error_timeout() {
333 let agent_err = AgentError::Timeout {
334 limit: Duration::from_secs(60),
335 };
336 let op_err: OperationError = agent_err.into();
337 assert!(matches!(
338 op_err,
339 OperationError::Agent(AgentError::Timeout { .. })
340 ));
341 }
342
343 #[test]
344 fn operation_error_implements_std_error() {
345 use std::error::Error;
346 let err = OperationError::Shell {
347 exit_code: 1,
348 stderr: "x".to_string(),
349 };
350 let _: &dyn Error = &err;
351 }
352
353 #[test]
354 fn agent_error_implements_std_error() {
355 use std::error::Error;
356 let err = AgentError::Timeout {
357 limit: Duration::from_secs(60),
358 };
359 let _: &dyn Error = &err;
360 }
361
362 #[test]
363 fn empty_stderr_edge_case() {
364 let err = OperationError::Shell {
365 exit_code: 1,
366 stderr: String::new(),
367 };
368 assert_eq!(err.to_string(), "shell exited with code 1: ");
369 }
370
371 #[test]
372 fn multiline_stderr() {
373 let err = AgentError::ProcessFailed {
374 exit_code: 1,
375 stderr: "line1\nline2\nline3".to_string(),
376 };
377 assert!(err.to_string().contains("line1\nline2\nline3"));
378 }
379
380 #[test]
381 fn unicode_in_stderr() {
382 let err = OperationError::Shell {
383 exit_code: 1,
384 stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
385 };
386 assert!(err.to_string().contains("\u{1F4A5}"));
387 }
388
389 #[test]
390 fn http_error_with_status_display() {
391 let err = OperationError::Http {
392 status: Some(500),
393 message: "internal server error".to_string(),
394 };
395 assert_eq!(
396 err.to_string(),
397 "http error (status 500): internal server error"
398 );
399 }
400
401 #[test]
402 fn http_error_without_status_display() {
403 let err = OperationError::Http {
404 status: None,
405 message: "connection refused".to_string(),
406 };
407 assert_eq!(err.to_string(), "http error: connection refused");
408 }
409
410 #[test]
411 fn http_error_empty_message() {
412 let err = OperationError::Http {
413 status: Some(404),
414 message: String::new(),
415 };
416 assert_eq!(err.to_string(), "http error (status 404): ");
417 }
418
419 #[test]
420 fn subsecond_duration_in_timeout_display() {
421 let err = OperationError::Timeout {
422 step: "fast".to_string(),
423 limit: Duration::from_millis(500),
424 };
425 assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
426 }
427
428 #[test]
429 fn source_chains_agent_error() {
430 use std::error::Error;
431 let err = OperationError::Agent(AgentError::Timeout {
432 limit: Duration::from_secs(60),
433 });
434 assert!(err.source().is_some());
435 }
436
437 #[test]
438 fn source_none_for_shell() {
439 use std::error::Error;
440 let err = OperationError::Shell {
441 exit_code: 1,
442 stderr: "x".to_string(),
443 };
444 assert!(err.source().is_none());
445 }
446
447 #[test]
448 fn deserialize_helper_formats_correctly() {
449 let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
450 match &err {
451 OperationError::Deserialize {
452 target_type,
453 reason,
454 } => {
455 assert!(target_type.contains("Vec"));
456 assert!(target_type.contains("String"));
457 assert_eq!(reason, "missing field");
458 }
459 _ => panic!("expected Deserialize variant"),
460 }
461 }
462
463 #[test]
464 fn deserialize_display_format() {
465 let err = OperationError::Deserialize {
466 target_type: "MyStruct".to_string(),
467 reason: "bad input".to_string(),
468 };
469 assert_eq!(
470 err.to_string(),
471 "failed to deserialize into MyStruct: bad input"
472 );
473 }
474
475 #[test]
476 fn agent_error_prompt_too_large_display() {
477 let err = AgentError::PromptTooLarge {
478 chars: 966_007,
479 estimated_tokens: 241_501,
480 model_limit: 200_000,
481 };
482 let msg = err.to_string();
483 assert!(msg.contains("966007 chars"));
484 assert!(msg.contains("241501 tokens"));
485 assert!(msg.contains("200000 tokens"));
486 }
487
488 #[test]
489 fn from_agent_error_prompt_too_large() {
490 let agent_err = AgentError::PromptTooLarge {
491 chars: 1_000_000,
492 estimated_tokens: 250_000,
493 model_limit: 200_000,
494 };
495 let op_err: OperationError = agent_err.into();
496 assert!(matches!(
497 op_err,
498 OperationError::Agent(AgentError::PromptTooLarge {
499 model_limit: 200_000,
500 ..
501 })
502 ));
503 }
504
505 #[test]
506 fn source_none_for_http_timeout_deserialize() {
507 use std::error::Error;
508 let http = OperationError::Http {
509 status: Some(500),
510 message: "x".to_string(),
511 };
512 assert!(http.source().is_none());
513
514 let timeout = OperationError::Timeout {
515 step: "x".to_string(),
516 limit: Duration::from_secs(1),
517 };
518 assert!(timeout.source().is_none());
519
520 let deser = OperationError::Deserialize {
521 target_type: "T".to_string(),
522 reason: "r".to_string(),
523 };
524 assert!(deser.source().is_none());
525 }
526
527 #[test]
528 fn schema_validation_raw_response_preserved() {
529 let err = AgentError::SchemaValidation {
530 expected: "structured_output field".to_string(),
531 got: "null".to_string(),
532 debug_messages: Vec::new(),
533 partial_usage: Box::default(),
534 raw_response: Some("The model said something useful".to_string()),
535 };
536 match err {
537 AgentError::SchemaValidation { raw_response, .. } => {
538 assert_eq!(
539 raw_response.as_deref(),
540 Some("The model said something useful")
541 );
542 }
543 _ => panic!("expected SchemaValidation"),
544 }
545 }
546
547 #[test]
548 fn schema_validation_raw_response_none_by_default() {
549 let err = AgentError::SchemaValidation {
550 expected: "a".to_string(),
551 got: "b".to_string(),
552 debug_messages: Vec::new(),
553 partial_usage: Box::default(),
554 raw_response: None,
555 };
556 match err {
557 AgentError::SchemaValidation { raw_response, .. } => {
558 assert!(raw_response.is_none());
559 }
560 _ => panic!("expected SchemaValidation"),
561 }
562 }
563}