1include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
23
24use alien_error::{AlienError, GenericError, HumanLayerPresentation};
25
26pub trait SdkResultExt<T> {
53 fn into_sdk_error(self) -> Result<T, AlienError<GenericError>>;
55}
56
57impl<T> SdkResultExt<ResponseValue<T>> for Result<ResponseValue<T>, Error<types::ApiError>> {
58 fn into_sdk_error(self) -> Result<ResponseValue<T>, AlienError<GenericError>> {
59 self.map_err(convert_sdk_error)
60 }
61}
62
63pub fn convert_sdk_error(err: Error<types::ApiError>) -> AlienError<GenericError> {
65 match err {
66 Error::ErrorResponse(response) => {
69 let status = response.status().as_u16();
70 let api_error = response.into_inner();
71 let context = context_with_request_id(api_error.context, None);
72
73 AlienError {
74 code: api_error.code.to_string(),
75 message: api_error.message.to_string(),
76 context,
77 hint: None,
78 retryable: api_error.retryable,
79 internal: false, http_status_code: Some(status),
81 source: api_error.source.and_then(parse_source_error),
82 human_layer_presentation: HumanLayerPresentation::Normal,
83 error: Some(GenericError {
84 message: api_error.message.to_string(),
85 }),
86 }
87 }
88
89 Error::CommunicationError(reqwest_err) => {
91 let retryable =
92 reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
93 let message = reqwest_failure_message("HTTP request", &reqwest_err);
94
95 AlienError {
96 code: "COMMUNICATION_ERROR".to_string(),
97 message: message.clone(),
98 context: reqwest_failure_context(&reqwest_err),
99 hint: None,
100 retryable,
101 internal: false,
102 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
103 source: build_reqwest_source(&reqwest_err),
104 human_layer_presentation: HumanLayerPresentation::Normal,
105 error: Some(GenericError { message }),
106 }
107 }
108
109 Error::InvalidRequest(msg) => AlienError {
111 code: "INVALID_REQUEST".to_string(),
112 message: format!("Invalid Request: {}", msg),
113 context: None,
114 hint: None,
115 retryable: false,
116 internal: false,
117 http_status_code: Some(400),
118 source: None,
119 human_layer_presentation: HumanLayerPresentation::Normal,
120 error: Some(GenericError {
121 message: format!("Invalid Request: {}", msg),
122 }),
123 },
124
125 Error::ResponseBodyError(reqwest_err) => {
127 let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
128
129 AlienError {
130 code: "RESPONSE_BODY_ERROR".to_string(),
131 message: message.clone(),
132 context: reqwest_failure_context(&reqwest_err),
133 hint: None,
134 retryable: true, internal: false,
136 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
137 source: build_reqwest_source(&reqwest_err),
138 human_layer_presentation: HumanLayerPresentation::Normal,
139 error: Some(GenericError { message }),
140 }
141 }
142
143 Error::InvalidResponsePayload(bytes, json_err) => {
146 let raw_body = String::from_utf8_lossy(&bytes);
147 let truncated = if raw_body.len() > 1000 {
148 format!(
149 "{}...(truncated {} bytes)",
150 &raw_body[..1000],
151 raw_body.len() - 1000
152 )
153 } else {
154 raw_body.to_string()
155 };
156
157 AlienError {
158 code: "INVALID_RESPONSE_PAYLOAD".to_string(),
159 message: format!("Failed to parse response: {}", json_err),
160 context: Some(serde_json::json!({
161 "parseError": json_err.to_string(),
162 "responseBody": truncated,
163 })),
164 hint: None,
165 retryable: false,
166 internal: false,
167 http_status_code: None,
168 source: Some(Box::new(AlienError::new(GenericError {
169 message: json_err.to_string(),
170 }))),
171 human_layer_presentation: HumanLayerPresentation::Normal,
172 error: Some(GenericError {
173 message: format!("Failed to parse response: {}", json_err),
174 }),
175 }
176 }
177
178 Error::InvalidUpgrade(reqwest_err) => {
180 let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
181
182 AlienError {
183 code: "INVALID_UPGRADE".to_string(),
184 message: message.clone(),
185 context: reqwest_failure_context(&reqwest_err),
186 hint: None,
187 retryable: false,
188 internal: false,
189 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
190 source: build_reqwest_source(&reqwest_err),
191 human_layer_presentation: HumanLayerPresentation::Normal,
192 error: Some(GenericError { message }),
193 }
194 }
195
196 Error::UnexpectedResponse(response) => {
198 let status = response.status().as_u16();
199 AlienError {
200 code: "UNEXPECTED_RESPONSE".to_string(),
201 message: format!(
202 "Unexpected response: {} {}",
203 status,
204 response.status().canonical_reason().unwrap_or("Unknown")
205 ),
206 context: Some(serde_json::json!({
207 "status": status,
208 "url": response.url().to_string(),
209 })),
210 hint: None,
211 retryable: status >= 500, internal: false,
213 http_status_code: Some(status),
214 source: None,
215 human_layer_presentation: HumanLayerPresentation::Normal,
216 error: Some(GenericError {
217 message: format!("Unexpected response status: {}", status),
218 }),
219 }
220 }
221
222 Error::Custom(msg) => AlienError {
224 code: "SDK_HOOK_ERROR".to_string(),
225 message: msg.clone(),
226 context: None,
227 hint: None,
228 retryable: false,
229 internal: false,
230 http_status_code: None,
231 source: None,
232 human_layer_presentation: HumanLayerPresentation::Normal,
233 error: Some(GenericError { message: msg }),
234 },
235 }
236}
237
238fn context_with_request_id(
239 context: Option<serde_json::Value>,
240 request_id: Option<&str>,
241) -> Option<serde_json::Value> {
242 let Some(request_id) = request_id else {
243 return context;
244 };
245
246 match context {
247 Some(serde_json::Value::Object(mut object)) => {
248 object
249 .entry("requestId")
250 .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
251 Some(serde_json::Value::Object(object))
252 }
253 Some(value) => Some(serde_json::json!({
254 "requestId": request_id,
255 "context": value,
256 })),
257 None => Some(serde_json::json!({ "requestId": request_id })),
258 }
259}
260
261fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
262 match err.url() {
263 Some(url) => format!("{operation} {} failed: {err}", url),
264 None => format!("{operation} failed: {err}"),
265 }
266}
267
268fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
269 err.url().map(|url| {
270 serde_json::json!({
271 "url": url.to_string(),
272 })
273 })
274}
275
276fn build_reqwest_source(err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
278 use std::error::Error;
280
281 let mut sources = Vec::new();
282 let mut current: Option<&(dyn Error + 'static)> = err.source();
283
284 while let Some(src) = current {
285 sources.push(src.to_string());
286 current = src.source();
287 }
288
289 if sources.is_empty() {
290 return None;
291 }
292
293 let mut result: Option<Box<AlienError<GenericError>>> = None;
295 for msg in sources.into_iter().rev() {
296 let error = AlienError {
297 code: "GENERIC_ERROR".to_string(),
298 message: msg.clone(),
299 context: None,
300 hint: None,
301 retryable: false,
302 internal: false,
303 http_status_code: None,
304 source: result,
305 human_layer_presentation: HumanLayerPresentation::Normal,
306 error: Some(GenericError { message: msg }),
307 };
308 result = Some(Box::new(error));
309 }
310
311 result
312}
313
314fn parse_source_error(value: serde_json::Value) -> Option<Box<AlienError<GenericError>>> {
316 let obj = value.as_object()?;
317
318 let code = obj
319 .get("code")
320 .and_then(|v| v.as_str())
321 .unwrap_or("NESTED_ERROR")
322 .to_string();
323
324 let message = obj
325 .get("message")
326 .and_then(|v| v.as_str())
327 .unwrap_or("Nested error")
328 .to_string();
329
330 let context = obj.get("context").cloned();
331 let retryable = obj
332 .get("retryable")
333 .and_then(|v| v.as_bool())
334 .unwrap_or(false);
335
336 let nested_source = obj.get("source").cloned().and_then(parse_source_error);
338
339 Some(Box::new(AlienError {
340 code,
341 message: message.clone(),
342 context,
343 hint: None,
344 retryable,
345 internal: false,
346 http_status_code: None,
347 source: nested_source,
348 human_layer_presentation: HumanLayerPresentation::Normal,
349 error: Some(GenericError { message }),
350 }))
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn test_api_error_code_deref() {
359 let code = types::ApiErrorCode::try_from("TEST_ERROR").unwrap();
361 assert_eq!(code.as_str(), "TEST_ERROR");
362 }
363
364 #[test]
365 fn context_with_request_id_adds_request_id_to_empty_context() {
366 let context = super::context_with_request_id(None, Some("req_123")).unwrap();
367
368 assert_eq!(context["requestId"], "req_123");
369 }
370
371 #[test]
372 fn context_with_request_id_preserves_existing_context() {
373 let context = super::context_with_request_id(
374 Some(serde_json::json!({ "workspace": "demo" })),
375 Some("req_123"),
376 )
377 .unwrap();
378
379 assert_eq!(context["workspace"], "demo");
380 assert_eq!(context["requestId"], "req_123");
381 }
382
383 #[tokio::test]
384 async fn communication_error_includes_url_in_message_and_context() {
385 let reqwest_err = reqwest::Client::new()
386 .get("http://127.0.0.1:9/v1/whoami")
387 .send()
388 .await
389 .expect_err("localhost discard port should refuse the connection");
390
391 let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
392
393 assert_eq!(error.code, "COMMUNICATION_ERROR");
394 assert!(error
395 .message
396 .starts_with("HTTP request http://127.0.0.1:9/v1/whoami failed:"));
397 assert_eq!(
398 error.context.as_ref().unwrap()["url"],
399 "http://127.0.0.1:9/v1/whoami"
400 );
401 }
402}