Skip to main content

camel_component_llm/
error.rs

1use std::time::Duration;
2
3use camel_api::CamelError;
4
5/// Error taxonomy for LLM component operations.
6///
7/// Provider-agnostic — no provider-specific variants. Use the `Provider(String)`
8/// catch-all for provider-specific errors; the constructor truncates messages
9/// to 200 bytes with UTF-8 safety.
10#[derive(Debug, Clone, thiserror::Error)]
11#[non_exhaustive]
12pub enum LlmError {
13    /// Rate limited by provider; optionally carries a retry-after duration.
14    #[error("rate limited by provider")]
15    RateLimit {
16        /// Optional duration to wait before retrying.
17        retry_after: Option<Duration>,
18    },
19
20    /// Usage quota exceeded (e.g., billing limit reached).
21    #[error("quota exceeded: {detail}")]
22    QuotaExceeded {
23        /// Human-readable detail.
24        detail: String,
25    },
26
27    /// Request exceeds provider context window.
28    #[error("context window exceeded: {max_tokens} tokens max")]
29    ContextLengthExceeded {
30        /// Maximum allowed tokens for the model.
31        max_tokens: u32,
32    },
33
34    /// Authentication / authorization failure.
35    #[error("authentication failed: {detail}")]
36    AuthFailed {
37        /// Human-readable detail.
38        detail: String,
39    },
40
41    /// Requested model does not exist.
42    #[error("model not found: {0}")]
43    ModelNotFound(String),
44
45    /// Model is temporarily unavailable (e.g., overloaded).
46    #[error("model unavailable: {0}")]
47    ModelUnavailable(String),
48
49    /// Provider endpoint is unreachable or returning server errors.
50    #[error("provider unavailable: {0}")]
51    ProviderUnavailable(String),
52
53    /// Response was blocked by the provider's content safety policy.
54    #[error("content filtered by safety policy: {detail}")]
55    ContentFiltered {
56        /// Human-readable detail.
57        detail: String,
58    },
59
60    /// Transient network error (connection reset, DNS failure, etc.).
61    #[error("network error: {0}")]
62    Network(String),
63
64    /// Request exceeded the configured timeout.
65    #[error("timeout after {0:?}")]
66    Timeout(Duration),
67
68    /// Request was malformed or violated provider constraints.
69    #[error("invalid request: {0}")]
70    InvalidRequest(String),
71
72    /// The operation is not supported by this provider.
73    #[error("capability not supported: {0}")]
74    UnsupportedCapability(String),
75
76    /// Response could not be decoded or parsed.
77    #[error("malformed provider response: {0}")]
78    Protocol(String),
79
80    /// Stream was terminated before completion.
81    #[error("stream interrupted: {0}")]
82    StreamInterrupted(String),
83
84    /// Provider-specific error (catch-all). Message is automatically truncated
85    /// to 200 bytes with UTF-8 safety via [`LlmError::provider`].
86    #[error("provider error: {0}")]
87    Provider(String),
88}
89
90/// Maximum length for provider error display strings.
91const MAX_PROVIDER_ERROR_BYTES: usize = 200;
92
93/// Truncate a string for display, ensuring it fits within
94/// `MAX_PROVIDER_ERROR_BYTES` at a UTF-8 boundary.
95fn truncate_for_display(msg: &str) -> String {
96    if msg.len() <= MAX_PROVIDER_ERROR_BYTES {
97        msg.to_string()
98    } else {
99        let cut = msg.floor_char_boundary(MAX_PROVIDER_ERROR_BYTES);
100        format!("{}...[truncated]", &msg[..cut])
101    }
102}
103
104impl LlmError {
105    /// Create a provider error with automatic message truncation.
106    ///
107    /// The message is truncated to 200 bytes at a UTF-8 safe boundary,
108    /// preventing unbounded error messages from propagating.
109    pub fn provider(msg: impl Into<String>) -> Self {
110        LlmError::Provider(truncate_for_display(&msg.into()))
111    }
112}
113
114/// Returns `true` if the error is transient and the operation may succeed
115/// on retry.
116///
117/// Retryable errors:
118/// - [`LlmError::RateLimit`] — provider asks us to back off
119/// - [`LlmError::Network`] — transient connection issues
120/// - [`LlmError::ProviderUnavailable`] — server-side transient
121/// - [`LlmError::ModelUnavailable`] — model overloaded
122/// - [`LlmError::Timeout`] — request timed out
123pub fn is_retryable(err: &LlmError) -> bool {
124    matches!(
125        err,
126        LlmError::RateLimit { .. }
127            | LlmError::Network(_)
128            | LlmError::ProviderUnavailable(_)
129            | LlmError::ModelUnavailable(_)
130            | LlmError::Timeout(_)
131    )
132}
133
134impl From<LlmError> for CamelError {
135    fn from(e: LlmError) -> Self {
136        match &e {
137            LlmError::AuthFailed { .. } => CamelError::Unauthenticated(e.to_string()),
138            LlmError::Network(_) | LlmError::ProviderUnavailable(_) => {
139                CamelError::Io(e.to_string())
140            }
141            _ => CamelError::ProcessorError(e.to_string()),
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::time::Duration;
150
151    #[test]
152    fn retryable_errors() {
153        assert!(is_retryable(&LlmError::Network("conn reset".into())));
154        assert!(is_retryable(&LlmError::Timeout(Duration::from_secs(30))));
155        assert!(is_retryable(&LlmError::RateLimit { retry_after: None }));
156        assert!(is_retryable(&LlmError::ProviderUnavailable("503".into())));
157        assert!(is_retryable(&LlmError::ModelUnavailable(
158            "overloaded".into()
159        )));
160    }
161
162    #[test]
163    fn non_retryable_errors() {
164        assert!(!is_retryable(&LlmError::AuthFailed {
165            detail: "bad key".into()
166        }));
167        assert!(!is_retryable(&LlmError::QuotaExceeded {
168            detail: "billing".into()
169        }));
170        assert!(!is_retryable(&LlmError::ContextLengthExceeded {
171            max_tokens: 4096
172        }));
173        assert!(!is_retryable(&LlmError::ModelNotFound("gpt-99".into())));
174        assert!(!is_retryable(&LlmError::ContentFiltered {
175            detail: "safety".into()
176        }));
177        assert!(!is_retryable(&LlmError::InvalidRequest("bad json".into())));
178        assert!(!is_retryable(&LlmError::Protocol("decode".into())));
179        assert!(!is_retryable(&LlmError::StreamInterrupted(
180            "dropped".into()
181        )));
182        assert!(!is_retryable(&LlmError::UnsupportedCapability(
183            "embed".into()
184        )));
185        assert!(!is_retryable(&LlmError::Provider("generic error".into())));
186    }
187
188    #[test]
189    fn converts_to_camel_error() {
190        let err: camel_api::CamelError = LlmError::Timeout(Duration::from_secs(5)).into();
191        assert!(err.to_string().contains("timeout"));
192
193        // Verify specific mapping arms
194        assert!(matches!(
195            CamelError::from(LlmError::Network("conn".into())),
196            CamelError::Io(_)
197        ));
198        assert!(matches!(
199            CamelError::from(LlmError::ProviderUnavailable("503".into())),
200            CamelError::Io(_)
201        ));
202        assert!(matches!(
203            CamelError::from(LlmError::AuthFailed {
204                detail: "bad key".into()
205            }),
206            CamelError::Unauthenticated(_)
207        ));
208        // Catch-all arm
209        assert!(matches!(
210            CamelError::from(LlmError::InvalidRequest("bad json".into())),
211            CamelError::ProcessorError(_)
212        ));
213    }
214
215    #[test]
216    fn provider_constructor_truncates_long_messages() {
217        let long = "x".repeat(300);
218        let err = LlmError::provider(long);
219        let display = err.to_string();
220        assert!(display.contains("provider error"));
221        assert!(display.contains("[truncated]"));
222        // 200 bytes + "provider error: " prefix + "...[truncated]" suffix
223        assert!(
224            display.len() <= 200 + 40,
225            "display too long: {} bytes",
226            display.len()
227        );
228    }
229}