1include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
26
27use alien_error::{AlienError, GenericError, HumanLayerPresentation};
28
29pub trait SdkResultExt<T> {
31 fn into_sdk_error(self) -> Result<T, AlienError<GenericError>>;
33}
34
35pub trait SdkResultExtReadingBody<T> {
39 fn into_sdk_error_reading_body(
40 self,
41 ) -> impl std::future::Future<Output = Result<T, AlienError<GenericError>>> + Send;
42}
43
44impl<T: Send> SdkResultExtReadingBody<ResponseValue<T>> for Result<ResponseValue<T>, Error<()>> {
45 fn into_sdk_error_reading_body(
46 self,
47 ) -> impl std::future::Future<Output = Result<ResponseValue<T>, AlienError<GenericError>>> + Send
48 {
49 async move {
50 match self {
51 Ok(response) => Ok(response),
52 Err(error) => Err(convert_sdk_error_reading_body(error).await),
53 }
54 }
55 }
56}
57
58impl<T> SdkResultExt<ResponseValue<T>> for Result<ResponseValue<T>, Error<()>> {
59 fn into_sdk_error(self) -> Result<ResponseValue<T>, AlienError<GenericError>> {
60 self.map_err(convert_sdk_error)
61 }
62}
63
64pub async fn convert_sdk_error_reading_body(err: Error<()>) -> AlienError<GenericError> {
72 match err {
73 Error::UnexpectedResponse(response) => {
74 let status = response.status().as_u16();
75 let canonical_reason = response
76 .status()
77 .canonical_reason()
78 .unwrap_or("Unknown")
79 .to_string();
80 let url = response.url().to_string();
81 let header_request_id = response
82 .headers()
83 .get("x-request-id")
84 .and_then(|value| value.to_str().ok())
85 .map(str::to_string);
86 let body = response.text().await.unwrap_or_default();
87
88 if let Ok(mut api_error) = serde_json::from_str::<AlienError<GenericError>>(&body) {
89 if api_error.http_status_code.is_none() {
90 api_error.http_status_code = Some(status);
91 }
92 let body_request_id = serde_json::from_str::<serde_json::Value>(&body)
93 .ok()
94 .and_then(|value| value.get("requestId")?.as_str().map(str::to_string));
95 api_error.context = context_with_request_id(
96 api_error.context,
97 header_request_id.as_deref().or(body_request_id.as_deref()),
98 );
99 return api_error;
100 }
101
102 AlienError {
103 code: "UNEXPECTED_RESPONSE".to_string(),
104 message: format!("Unexpected response: {} {}", status, canonical_reason),
105 context: Some(serde_json::json!({
106 "status": status,
107 "url": url,
108 })),
109 hint: None,
110 retryable: status >= 500,
111 internal: false,
112 http_status_code: Some(status),
113 source: None,
114 human_layer_presentation: HumanLayerPresentation::Normal,
115 error: Some(GenericError {
116 message: format!("Unexpected response status: {}", status),
117 }),
118 }
119 }
120 other => convert_sdk_error(other),
121 }
122}
123
124fn context_with_request_id(
125 context: Option<serde_json::Value>,
126 request_id: Option<&str>,
127) -> Option<serde_json::Value> {
128 let Some(request_id) = request_id else {
129 return context;
130 };
131
132 match context {
133 Some(serde_json::Value::Object(mut object)) => {
134 object
135 .entry("requestId")
136 .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
137 Some(serde_json::Value::Object(object))
138 }
139 Some(value) => Some(serde_json::json!({
140 "requestId": request_id,
141 "details": value,
142 })),
143 None => Some(serde_json::json!({ "requestId": request_id })),
144 }
145}
146
147pub fn convert_sdk_error(err: Error<()>) -> AlienError<GenericError> {
149 match err {
150 Error::ErrorResponse(response) => {
151 let status = response.status().as_u16();
152 AlienError {
153 code: "UNEXPECTED_RESPONSE".to_string(),
154 message: format!(
155 "Unexpected response: {} {}",
156 status,
157 response.status().canonical_reason().unwrap_or("Unknown")
158 ),
159 context: Some(serde_json::json!({
160 "status": status,
161 })),
162 hint: None,
163 retryable: status >= 500,
164 internal: false,
165 http_status_code: Some(status),
166 source: None,
167 human_layer_presentation: HumanLayerPresentation::Normal,
168 error: Some(GenericError {
169 message: format!("Unexpected response status: {}", status),
170 }),
171 }
172 }
173 Error::CommunicationError(reqwest_err) => {
174 let retryable =
175 reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
176 let message = reqwest_failure_message("HTTP request", &reqwest_err);
177
178 AlienError {
179 code: "COMMUNICATION_ERROR".to_string(),
180 message: message.clone(),
181 context: reqwest_failure_context(&reqwest_err),
182 hint: None,
183 retryable,
184 internal: false,
185 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
186 source: build_reqwest_source(&reqwest_err),
187 human_layer_presentation: HumanLayerPresentation::Normal,
188 error: Some(GenericError { message }),
189 }
190 }
191 Error::InvalidRequest(msg) => AlienError {
192 code: "INVALID_REQUEST".to_string(),
193 message: format!("Invalid Request: {}", msg),
194 context: None,
195 hint: None,
196 retryable: false,
197 internal: false,
198 http_status_code: Some(400),
199 source: None,
200 human_layer_presentation: HumanLayerPresentation::Normal,
201 error: Some(GenericError {
202 message: format!("Invalid Request: {}", msg),
203 }),
204 },
205 Error::ResponseBodyError(reqwest_err) => {
206 let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
207
208 AlienError {
209 code: "RESPONSE_BODY_ERROR".to_string(),
210 message: message.clone(),
211 context: reqwest_failure_context(&reqwest_err),
212 hint: None,
213 retryable: true,
214 internal: false,
215 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
216 source: build_reqwest_source(&reqwest_err),
217 human_layer_presentation: HumanLayerPresentation::Normal,
218 error: Some(GenericError { message }),
219 }
220 }
221 Error::InvalidResponsePayload(bytes, json_err) => {
222 let raw_body = String::from_utf8_lossy(&bytes);
223 let truncated = if raw_body.len() > 1000 {
224 format!(
225 "{}...(truncated {} bytes)",
226 &raw_body[..1000],
227 raw_body.len() - 1000
228 )
229 } else {
230 raw_body.to_string()
231 };
232
233 AlienError {
234 code: "INVALID_RESPONSE_PAYLOAD".to_string(),
235 message: format!("Failed to parse response: {}", json_err),
236 context: Some(serde_json::json!({
237 "parseError": json_err.to_string(),
238 "responseBody": truncated,
239 })),
240 hint: None,
241 retryable: false,
242 internal: false,
243 http_status_code: None,
244 source: Some(Box::new(AlienError::new(GenericError {
245 message: json_err.to_string(),
246 }))),
247 human_layer_presentation: HumanLayerPresentation::Normal,
248 error: Some(GenericError {
249 message: format!("Failed to parse response: {}", json_err),
250 }),
251 }
252 }
253 Error::InvalidUpgrade(reqwest_err) => {
254 let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
255
256 AlienError {
257 code: "INVALID_UPGRADE".to_string(),
258 message: message.clone(),
259 context: reqwest_failure_context(&reqwest_err),
260 hint: None,
261 retryable: false,
262 internal: false,
263 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
264 source: build_reqwest_source(&reqwest_err),
265 human_layer_presentation: HumanLayerPresentation::Normal,
266 error: Some(GenericError { message }),
267 }
268 }
269 Error::UnexpectedResponse(response) => {
270 let status = response.status().as_u16();
271 AlienError {
272 code: "UNEXPECTED_RESPONSE".to_string(),
273 message: format!(
274 "Unexpected response: {} {}",
275 status,
276 response.status().canonical_reason().unwrap_or("Unknown")
277 ),
278 context: Some(serde_json::json!({
279 "status": status,
280 "url": response.url().to_string(),
281 })),
282 hint: None,
283 retryable: status >= 500,
284 internal: false,
285 http_status_code: Some(status),
286 source: None,
287 human_layer_presentation: HumanLayerPresentation::Normal,
288 error: Some(GenericError {
289 message: format!("Unexpected response status: {}", status),
290 }),
291 }
292 }
293 Error::Custom(msg) => AlienError {
294 code: "SDK_HOOK_ERROR".to_string(),
295 message: msg.clone(),
296 context: None,
297 hint: None,
298 retryable: false,
299 internal: false,
300 http_status_code: None,
301 source: None,
302 human_layer_presentation: HumanLayerPresentation::Normal,
303 error: Some(GenericError { message: msg }),
304 },
305 }
306}
307
308fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
309 match err.url() {
310 Some(url) => format!("{operation} {} failed: {err}", url),
311 None => format!("{operation} failed: {err}"),
312 }
313}
314
315fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
316 err.url().map(|url| {
317 serde_json::json!({
318 "url": url.to_string(),
319 })
320 })
321}
322
323fn build_reqwest_source(reqwest_err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
324 use std::error::Error as _;
325
326 reqwest_err.source().map(|source| {
327 Box::new(AlienError {
328 code: "GENERIC_ERROR".to_string(),
329 message: source.to_string(),
330 context: None,
331 hint: None,
332 retryable: false,
333 internal: false,
334 http_status_code: None,
335 source: None,
336 human_layer_presentation: HumanLayerPresentation::Transparent,
337 error: Some(GenericError {
338 message: source.to_string(),
339 }),
340 })
341 })
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn unexpected_response(status: u16, body: &str) -> Error<()> {
349 let response = http::Response::builder()
350 .status(status)
351 .body(body.to_string())
352 .expect("test response should build");
353 Error::UnexpectedResponse(reqwest::Response::from(response))
354 }
355
356 #[tokio::test]
357 async fn reading_body_preserves_structured_alien_errors() {
358 let body = serde_json::json!({
359 "code": "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN",
360 "message": "Choosing a public subdomain requires a custom project domain",
361 "hint": "Configure a custom domain first",
362 "retryable": false,
363 "internal": false,
364 "httpStatusCode": 400,
365 "requestId": "req_body_123",
366 })
367 .to_string();
368
369 let error = convert_sdk_error_reading_body(unexpected_response(400, &body)).await;
370
371 assert_eq!(error.code, "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN");
372 assert_eq!(
373 error.message,
374 "Choosing a public subdomain requires a custom project domain"
375 );
376 assert_eq!(error.http_status_code, Some(400));
377 assert_eq!(
378 error.hint.as_deref(),
379 Some("Configure a custom domain first")
380 );
381 assert_eq!(error.context.as_ref().unwrap()["requestId"], "req_body_123");
382 assert!(!error.retryable);
383 assert!(!error.internal);
384 }
385
386 #[tokio::test]
387 async fn reading_body_falls_back_to_generic_error_for_non_alien_payloads() {
388 let error =
389 convert_sdk_error_reading_body(unexpected_response(502, "<html>bad gateway</html>"))
390 .await;
391
392 assert_eq!(error.code, "UNEXPECTED_RESPONSE");
393 assert_eq!(error.message, "Unexpected response: 502 Bad Gateway");
394 assert_eq!(error.http_status_code, Some(502));
395 assert!(error.retryable);
396 }
397
398 #[tokio::test]
399 async fn communication_error_includes_url_in_message_and_context() {
400 let reqwest_err = reqwest::Client::new()
401 .get("http://127.0.0.1:9/v1/initialize")
402 .send()
403 .await
404 .expect_err("localhost discard port should refuse the connection");
405
406 let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
407
408 assert_eq!(error.code, "COMMUNICATION_ERROR");
409 assert!(error
410 .message
411 .starts_with("HTTP request http://127.0.0.1:9/v1/initialize failed:"));
412 assert_eq!(
413 error.context.as_ref().unwrap()["url"],
414 "http://127.0.0.1:9/v1/initialize"
415 );
416 }
417}