Skip to main content

elph_ai/utils/
error_body.rs

1use serde_json::Value;
2
3pub const MAX_PROVIDER_ERROR_BODY_CHARS: usize = 4000;
4
5#[derive(Debug, Clone)]
6pub struct NormalizedProviderError {
7    pub status: Option<u16>,
8    pub body: Option<String>,
9    pub message: String,
10    pub message_carries_body: bool,
11}
12
13/// SDK-shaped HTTP error used by provider catch blocks and tests.
14#[derive(Debug)]
15pub struct ProviderSdkError {
16    pub message: String,
17    pub status_code: Option<u16>,
18    pub status: Option<u16>,
19    pub body: Option<String>,
20    pub parsed_error: Option<Value>,
21    pub bedrock_metadata_http_status: Option<u16>,
22    pub bedrock_response_status_code: Option<u16>,
23    pub bedrock_response_body: Option<String>,
24}
25
26impl std::fmt::Display for ProviderSdkError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "{}", self.message)
29    }
30}
31
32impl std::error::Error for ProviderSdkError {}
33
34/// Non-`Error` thrown value serialized into the normalized message.
35#[derive(Debug)]
36pub struct ThrownValue(pub Value);
37
38impl std::fmt::Display for ThrownValue {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", safe_json_stringify(&self.0))
41    }
42}
43
44impl std::error::Error for ThrownValue {}
45
46pub fn normalize_provider_error(error: &anyhow::Error) -> NormalizedProviderError {
47    if let Some(thrown) = error.downcast_ref::<ThrownValue>() {
48        return NormalizedProviderError {
49            status: None,
50            body: None,
51            message: safe_json_stringify(&thrown.0),
52            message_carries_body: false,
53        };
54    }
55
56    if let Some(sdk) = error.downcast_ref::<ProviderSdkError>() {
57        let status = extract_status(sdk);
58        let body = extract_body(sdk);
59        let message_carries_body = body.as_ref().is_none_or(|b| sdk.message.contains(b));
60        return NormalizedProviderError {
61            status,
62            body,
63            message: sdk.message.clone(),
64            message_carries_body,
65        };
66    }
67
68    let message = error.to_string();
69    let status = error
70        .downcast_ref::<reqwest::Error>()
71        .and_then(|e| e.status())
72        .map(|s| s.as_u16());
73
74    NormalizedProviderError {
75        status,
76        body: None,
77        message,
78        message_carries_body: false,
79    }
80}
81
82fn extract_status(error: &ProviderSdkError) -> Option<u16> {
83    error
84        .status_code
85        .or(error.status)
86        .or(error.bedrock_metadata_http_status)
87        .or(error.bedrock_response_status_code)
88}
89
90fn extract_body(error: &ProviderSdkError) -> Option<String> {
91    let body_text = pick_body_text(error)?;
92    let trimmed = body_text.trim();
93    if trimmed.is_empty() {
94        return None;
95    }
96    Some(truncate_error_text(trimmed, MAX_PROVIDER_ERROR_BODY_CHARS))
97}
98
99fn pick_body_text(error: &ProviderSdkError) -> Option<String> {
100    if let Some(body) = &error.body {
101        return Some(body.clone());
102    }
103    if is_non_empty_object(error.parsed_error.as_ref()) {
104        return Some(safe_json_stringify(error.parsed_error.as_ref().unwrap()));
105    }
106    if let Some(body) = &error.bedrock_response_body {
107        return Some(body.clone());
108    }
109    None
110}
111
112fn is_non_empty_object(value: Option<&Value>) -> bool {
113    match value {
114        Some(Value::Object(map)) => !map.is_empty(),
115        _ => false,
116    }
117}
118
119pub fn format_provider_error(norm: &NormalizedProviderError, prefix: Option<&str>) -> String {
120    if norm.message_carries_body || norm.status.is_none() || norm.body.is_none() {
121        if let (Some(prefix), Some(status)) = (prefix, norm.status) {
122            return format!("{prefix} ({status}): {}", norm.message);
123        }
124        return norm.message.clone();
125    }
126    if let Some(prefix) = prefix {
127        format!(
128            "{prefix} ({}): {}",
129            norm.status.unwrap_or(0),
130            norm.body.as_deref().unwrap_or("")
131        )
132    } else {
133        format!("{}: {}", norm.status.unwrap_or(0), norm.body.as_deref().unwrap_or(""))
134    }
135}
136
137pub fn truncate_error_text(text: &str, max_chars: usize) -> String {
138    if text.len() <= max_chars {
139        return text.to_string();
140    }
141    format!("{}... [truncated {} chars]", &text[..max_chars], text.len() - max_chars)
142}
143
144pub fn safe_json_stringify(value: &Value) -> String {
145    serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
146}
147
148pub async fn error_body_from_response(response: reqwest::Response) -> String {
149    let status = response.status();
150    let text = response.text().await.unwrap_or_default();
151    if text.trim().is_empty() {
152        format!("{status}")
153    } else {
154        truncate_error_text(&text, MAX_PROVIDER_ERROR_BODY_CHARS)
155    }
156}