a3s_code_core/llm/
error.rs1use thiserror::Error;
4
5const MAX_PROVIDER_ERROR_MESSAGE_BYTES: usize = 4 * 1024;
11
12#[derive(Debug, Error)]
13#[error("{message}")]
14pub struct NonRetryableLlmError {
15 message: String,
16 provider: Option<String>,
17 status: Option<u16>,
18}
19
20impl NonRetryableLlmError {
21 pub fn new(message: impl Into<String>) -> Self {
22 Self {
23 message: bound_message(message.into()),
24 provider: None,
25 status: None,
26 }
27 }
28
29 pub fn from_status(provider: impl Into<String>, status: u16, body: impl Into<String>) -> Self {
33 let provider = provider.into();
34 let mut error = Self::new(format!(
35 "{provider} API returned HTTP {status}: {}",
36 body.into()
37 ));
38 error.provider = Some(provider);
39 error.status = Some(status);
40 error
41 }
42
43 pub fn provider(&self) -> Option<&str> {
45 self.provider.as_deref()
46 }
47
48 pub fn status(&self) -> Option<u16> {
50 self.status
51 }
52}
53
54fn bound_message(message: String) -> String {
55 if message.len() <= MAX_PROVIDER_ERROR_MESSAGE_BYTES {
56 return message;
57 }
58 let mut bounded = String::with_capacity(MAX_PROVIDER_ERROR_MESSAGE_BYTES);
59 for character in message.chars() {
60 if bounded.len() + character.len_utf8() + 3 > MAX_PROVIDER_ERROR_MESSAGE_BYTES {
61 break;
62 }
63 bounded.push(character);
64 }
65 bounded.push('…');
66 bounded
67}
68
69pub(crate) fn non_retryable_llm_error_message(error: &anyhow::Error) -> Option<&str> {
70 if let Some(error) = error.downcast_ref::<NonRetryableLlmError>() {
71 return Some(error.message.as_str());
72 }
73 error
74 .downcast_ref::<crate::retry::RetryExhaustedError>()
75 .map(crate::retry::RetryExhaustedError::non_retryable_message)
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 #[test]
83 fn marker_survives_anyhow_context() {
84 let error = anyhow::Error::new(NonRetryableLlmError::new("quota exhausted"))
85 .context("LLM call failed");
86
87 assert_eq!(
88 non_retryable_llm_error_message(&error),
89 Some("quota exhausted")
90 );
91 }
92
93 #[test]
94 fn provider_status_is_typed_and_message_is_bounded() {
95 let error = NonRetryableLlmError::from_status("deepseek", 402, "x".repeat(10_000));
96 assert_eq!(error.provider(), Some("deepseek"));
97 assert_eq!(error.status(), Some(402));
98 assert!(error.to_string().len() <= MAX_PROVIDER_ERROR_MESSAGE_BYTES);
99 assert!(error.to_string().ends_with('…'));
100 }
101
102 #[test]
103 fn retry_exhaustion_is_terminal_at_the_agent_boundary() {
104 let error = anyhow::Error::new(crate::retry::RetryExhaustedError::new(
105 3,
106 reqwest::StatusCode::TOO_MANY_REQUESTS,
107 "rate limited",
108 ));
109 let message = non_retryable_llm_error_message(&error)
110 .expect("retry exhaustion must be terminal at the Agent boundary");
111 assert!(message.contains("LLM API request failed after 3 attempts"));
112 assert!(message.contains("429"));
113 assert!(message.contains("rate limited"));
114 }
115}