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