Skip to main content

a3s_code_core/llm/
error.rs

1//! Error markers shared by LLM clients and the agent retry loop.
2
3use thiserror::Error;
4
5/// A provider error that cannot succeed through an immediate retry.
6///
7/// The message must be safe to show directly to an end user. Provider clients
8/// should use this only for precise terminal conditions, such as an exhausted
9/// account quota, and not for ordinary transient rate limits.
10const 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    /// Construct a terminal provider response without relying on rendered
30    /// error text for retry decisions. The response body is bounded because
31    /// provider gateways may return arbitrarily large diagnostic payloads.
32    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    /// Provider label, when the error came from an HTTP response.
44    pub fn provider(&self) -> Option<&str> {
45        self.provider.as_deref()
46    }
47
48    /// HTTP status, when the error came from an HTTP response.
49    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}