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: Send> SdkResultExtReadingBody<ResponseValue<T>>
59 for Result<ResponseValue<T>, Error<types::AlienError>>
60{
61 fn into_sdk_error_reading_body(
62 self,
63 ) -> impl std::future::Future<Output = Result<ResponseValue<T>, AlienError<GenericError>>> + Send
64 {
65 async move {
66 match self {
67 Ok(response) => Ok(response),
68 Err(error) => Err(convert_typed_sdk_error_reading_body(error).await),
69 }
70 }
71 }
72}
73
74impl<T> SdkResultExt<ResponseValue<T>> for Result<ResponseValue<T>, Error<()>> {
75 fn into_sdk_error(self) -> Result<ResponseValue<T>, AlienError<GenericError>> {
76 self.map_err(convert_sdk_error)
77 }
78}
79
80pub async fn convert_sdk_error_reading_body(err: Error<()>) -> AlienError<GenericError> {
88 match err {
89 Error::UnexpectedResponse(response) => {
90 convert_unexpected_response_reading_body(response).await
91 }
92 other => convert_sdk_error(other),
93 }
94}
95
96async fn convert_typed_sdk_error_reading_body(
97 err: Error<types::AlienError>,
98) -> AlienError<GenericError> {
99 match err {
100 Error::ErrorResponse(response) => convert_typed_error_response(response),
101 Error::UnexpectedResponse(response) => {
102 convert_unexpected_response_reading_body(response).await
103 }
104 other => convert_sdk_error(other.into_untyped()),
105 }
106}
107
108fn convert_typed_error_response(
109 response: ResponseValue<types::AlienError>,
110) -> AlienError<GenericError> {
111 let status = response.status().as_u16();
112 let request_id = response
113 .headers()
114 .get("x-request-id")
115 .and_then(|value| value.to_str().ok())
116 .map(str::to_string);
117 let api_error = response.into_inner();
118 let message = String::from(api_error.message);
119 let source = api_error
120 .source
121 .and_then(|value| serde_json::from_value::<AlienError<GenericError>>(value).ok())
122 .map(Box::new);
123 let http_status_code = api_error
124 .http_status_code
125 .and_then(|value| u16::try_from(value).ok())
126 .filter(|value| (100..=599).contains(value))
127 .or(Some(status));
128
129 AlienError {
130 code: String::from(api_error.code),
131 message: message.clone(),
132 context: context_with_request_id(api_error.context, request_id.as_deref()),
133 hint: api_error.hint,
134 retryable: api_error.retryable,
135 internal: api_error.internal,
136 http_status_code,
137 source,
138 human_layer_presentation: HumanLayerPresentation::Normal,
139 error: Some(GenericError { message }),
140 }
141}
142
143async fn convert_unexpected_response_reading_body(
144 response: reqwest::Response,
145) -> AlienError<GenericError> {
146 let status = response.status().as_u16();
147 let canonical_reason = response
148 .status()
149 .canonical_reason()
150 .unwrap_or("Unknown")
151 .to_string();
152 let url = response.url().to_string();
153 let header_request_id = response
154 .headers()
155 .get("x-request-id")
156 .and_then(|value| value.to_str().ok())
157 .map(str::to_string);
158 let body = response.text().await.unwrap_or_default();
159
160 if let Ok(mut api_error) = serde_json::from_str::<AlienError<GenericError>>(&body) {
161 if api_error.http_status_code.is_none() {
162 api_error.http_status_code = Some(status);
163 }
164 let body_request_id = serde_json::from_str::<serde_json::Value>(&body)
165 .ok()
166 .and_then(|value| value.get("requestId")?.as_str().map(str::to_string));
167 api_error.context = context_with_request_id(
168 api_error.context,
169 header_request_id.as_deref().or(body_request_id.as_deref()),
170 );
171 return api_error;
172 }
173
174 AlienError {
175 code: "UNEXPECTED_RESPONSE".to_string(),
176 message: format!("Unexpected response: {} {}", status, canonical_reason),
177 context: Some(serde_json::json!({
178 "status": status,
179 "url": url,
180 })),
181 hint: None,
182 retryable: is_retryable_http_status(status),
183 internal: false,
184 http_status_code: Some(status),
185 source: None,
186 human_layer_presentation: HumanLayerPresentation::Normal,
187 error: Some(GenericError {
188 message: format!("Unexpected response status: {}", status),
189 }),
190 }
191}
192
193fn context_with_request_id(
194 context: Option<serde_json::Value>,
195 request_id: Option<&str>,
196) -> Option<serde_json::Value> {
197 let Some(request_id) = request_id else {
198 return context;
199 };
200
201 match context {
202 Some(serde_json::Value::Object(mut object)) => {
203 object
204 .entry("requestId")
205 .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
206 Some(serde_json::Value::Object(object))
207 }
208 Some(value) => Some(serde_json::json!({
209 "requestId": request_id,
210 "details": value,
211 })),
212 None => Some(serde_json::json!({ "requestId": request_id })),
213 }
214}
215
216pub fn is_retryable_http_status(status: u16) -> bool {
219 matches!(status, 408 | 425 | 429) || (500..=599).contains(&status)
220}
221
222pub fn convert_sdk_error(err: Error<()>) -> AlienError<GenericError> {
224 match err {
225 Error::ErrorResponse(response) => {
226 let status = response.status().as_u16();
227 AlienError {
228 code: "UNEXPECTED_RESPONSE".to_string(),
229 message: format!(
230 "Unexpected response: {} {}",
231 status,
232 response.status().canonical_reason().unwrap_or("Unknown")
233 ),
234 context: Some(serde_json::json!({
235 "status": status,
236 })),
237 hint: None,
238 retryable: is_retryable_http_status(status),
239 internal: false,
240 http_status_code: Some(status),
241 source: None,
242 human_layer_presentation: HumanLayerPresentation::Normal,
243 error: Some(GenericError {
244 message: format!("Unexpected response status: {}", status),
245 }),
246 }
247 }
248 Error::CommunicationError(reqwest_err) => {
249 let retryable =
250 reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
251 let message = reqwest_failure_message("HTTP request", &reqwest_err);
252
253 AlienError {
254 code: "COMMUNICATION_ERROR".to_string(),
255 message: message.clone(),
256 context: reqwest_failure_context(&reqwest_err),
257 hint: None,
258 retryable,
259 internal: false,
260 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
261 source: build_reqwest_source(&reqwest_err),
262 human_layer_presentation: HumanLayerPresentation::Normal,
263 error: Some(GenericError { message }),
264 }
265 }
266 Error::InvalidRequest(msg) => AlienError {
267 code: "INVALID_REQUEST".to_string(),
268 message: format!("Invalid Request: {}", msg),
269 context: None,
270 hint: None,
271 retryable: false,
272 internal: false,
273 http_status_code: Some(400),
274 source: None,
275 human_layer_presentation: HumanLayerPresentation::Normal,
276 error: Some(GenericError {
277 message: format!("Invalid Request: {}", msg),
278 }),
279 },
280 Error::ResponseBodyError(reqwest_err) => {
281 let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
282
283 AlienError {
284 code: "RESPONSE_BODY_ERROR".to_string(),
285 message: message.clone(),
286 context: reqwest_failure_context(&reqwest_err),
287 hint: None,
288 retryable: true,
289 internal: false,
290 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
291 source: build_reqwest_source(&reqwest_err),
292 human_layer_presentation: HumanLayerPresentation::Normal,
293 error: Some(GenericError { message }),
294 }
295 }
296 Error::InvalidResponsePayload(bytes, json_err) => {
297 AlienError {
298 code: "INVALID_RESPONSE_PAYLOAD".to_string(),
299 message: format!("Failed to parse response: {}", json_err),
300 context: Some(serde_json::json!({
301 "parseError": json_err.to_string(),
302 "responseBodyLength": bytes.len(),
306 })),
307 hint: None,
308 retryable: false,
309 internal: false,
310 http_status_code: None,
311 source: Some(Box::new(AlienError::new(GenericError {
312 message: json_err.to_string(),
313 }))),
314 human_layer_presentation: HumanLayerPresentation::Normal,
315 error: Some(GenericError {
316 message: format!("Failed to parse response: {}", json_err),
317 }),
318 }
319 }
320 Error::InvalidUpgrade(reqwest_err) => {
321 let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
322
323 AlienError {
324 code: "INVALID_UPGRADE".to_string(),
325 message: message.clone(),
326 context: reqwest_failure_context(&reqwest_err),
327 hint: None,
328 retryable: false,
329 internal: false,
330 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
331 source: build_reqwest_source(&reqwest_err),
332 human_layer_presentation: HumanLayerPresentation::Normal,
333 error: Some(GenericError { message }),
334 }
335 }
336 Error::UnexpectedResponse(response) => {
337 let status = response.status().as_u16();
338 AlienError {
339 code: "UNEXPECTED_RESPONSE".to_string(),
340 message: format!(
341 "Unexpected response: {} {}",
342 status,
343 response.status().canonical_reason().unwrap_or("Unknown")
344 ),
345 context: Some(serde_json::json!({
346 "status": status,
347 "url": response.url().to_string(),
348 })),
349 hint: None,
350 retryable: is_retryable_http_status(status),
351 internal: false,
352 http_status_code: Some(status),
353 source: None,
354 human_layer_presentation: HumanLayerPresentation::Normal,
355 error: Some(GenericError {
356 message: format!("Unexpected response status: {}", status),
357 }),
358 }
359 }
360 Error::Custom(msg) => AlienError {
361 code: "SDK_HOOK_ERROR".to_string(),
362 message: msg.clone(),
363 context: None,
364 hint: None,
365 retryable: false,
366 internal: false,
367 http_status_code: None,
368 source: None,
369 human_layer_presentation: HumanLayerPresentation::Normal,
370 error: Some(GenericError { message: msg }),
371 },
372 }
373}
374
375fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
376 match err.url() {
377 Some(url) => format!("{operation} {} failed: {err}", url),
378 None => format!("{operation} failed: {err}"),
379 }
380}
381
382fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
383 err.url().map(|url| {
384 serde_json::json!({
385 "url": url.to_string(),
386 })
387 })
388}
389
390fn build_reqwest_source(reqwest_err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
391 use std::error::Error as _;
392
393 reqwest_err.source().map(|source| {
394 Box::new(AlienError {
395 code: "GENERIC_ERROR".to_string(),
396 message: source.to_string(),
397 context: None,
398 hint: None,
399 retryable: false,
400 internal: false,
401 http_status_code: None,
402 source: None,
403 human_layer_presentation: HumanLayerPresentation::Transparent,
404 error: Some(GenericError {
405 message: source.to_string(),
406 }),
407 })
408 })
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn unexpected_response<E>(status: u16, body: &str) -> Error<E> {
416 let response = http::Response::builder()
417 .status(status)
418 .body(body.to_string())
419 .expect("test response should build");
420 Error::UnexpectedResponse(reqwest::Response::from(response))
421 }
422
423 #[tokio::test]
424 async fn reading_body_preserves_structured_alien_errors() {
425 let body = serde_json::json!({
426 "code": "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN",
427 "message": "Choosing a public subdomain requires a custom project domain",
428 "hint": "Configure a custom domain first",
429 "retryable": false,
430 "internal": false,
431 "httpStatusCode": 400,
432 "requestId": "req_body_123",
433 })
434 .to_string();
435
436 let error = convert_sdk_error_reading_body(unexpected_response(400, &body)).await;
437
438 assert_eq!(error.code, "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN");
439 assert_eq!(
440 error.message,
441 "Choosing a public subdomain requires a custom project domain"
442 );
443 assert_eq!(error.http_status_code, Some(400));
444 assert_eq!(
445 error.hint.as_deref(),
446 Some("Configure a custom domain first")
447 );
448 assert_eq!(error.context.as_ref().unwrap()["requestId"], "req_body_123");
449 assert!(!error.retryable);
450 assert!(!error.internal);
451 }
452
453 #[tokio::test]
454 async fn typed_error_response_preserves_alien_error_and_request_id() {
455 let api_error = serde_json::from_value::<types::AlienError>(serde_json::json!({
456 "code": "FORBIDDEN",
457 "message": "Binding access denied",
458 "context": { "deploymentId": "dep_123" },
459 "hint": "Use the assigned manager",
460 "retryable": false,
461 "internal": false,
462 "httpStatusCode": 403,
463 "source": {
464 "code": "GENERIC_ERROR",
465 "message": "policy rejected request",
466 "retryable": false,
467 "internal": false
468 }
469 }))
470 .expect("typed API error should deserialize");
471 let mut headers = reqwest::header::HeaderMap::new();
472 headers.insert("x-request-id", "req_header_123".parse().unwrap());
473 let response = ResponseValue::new(api_error, reqwest::StatusCode::FORBIDDEN, headers);
474
475 let error = convert_typed_sdk_error_reading_body(Error::ErrorResponse(response)).await;
476
477 assert_eq!(error.code, "FORBIDDEN");
478 assert_eq!(error.message, "Binding access denied");
479 assert_eq!(error.http_status_code, Some(403));
480 assert_eq!(error.hint.as_deref(), Some("Use the assigned manager"));
481 assert_eq!(error.context.as_ref().unwrap()["deploymentId"], "dep_123");
482 assert_eq!(
483 error.context.as_ref().unwrap()["requestId"],
484 "req_header_123"
485 );
486 assert_eq!(error.source.as_ref().unwrap().code, "GENERIC_ERROR");
487 assert!(!error.retryable);
488 assert!(!error.internal);
489 }
490
491 #[tokio::test]
492 async fn reading_body_falls_back_to_generic_error_for_non_alien_payloads() {
493 let error =
494 convert_sdk_error_reading_body(unexpected_response(502, "<html>bad gateway</html>"))
495 .await;
496
497 assert_eq!(error.code, "UNEXPECTED_RESPONSE");
498 assert_eq!(error.message, "Unexpected response: 502 Bad Gateway");
499 assert_eq!(error.http_status_code, Some(502));
500 assert!(error.retryable);
501 }
502
503 #[tokio::test]
504 async fn reading_body_classifies_unstructured_rate_limits_as_retryable() {
505 let error = convert_sdk_error_reading_body(unexpected_response(429, "rate limited")).await;
506
507 assert_eq!(error.code, "UNEXPECTED_RESPONSE");
508 assert_eq!(error.http_status_code, Some(429));
509 assert!(error.retryable);
510 }
511
512 #[tokio::test]
513 async fn typed_endpoint_classifies_undocumented_rate_limits_as_retryable() {
514 let error = convert_typed_sdk_error_reading_body(unexpected_response::<types::AlienError>(
515 429,
516 "rate limited",
517 ))
518 .await;
519
520 assert_eq!(error.code, "UNEXPECTED_RESPONSE");
521 assert_eq!(error.http_status_code, Some(429));
522 assert!(error.retryable);
523 }
524
525 #[tokio::test]
526 async fn generated_typed_endpoint_preserves_malformed_server_error_status() {
527 use std::io::{Read, Write};
528
529 let listener = std::net::TcpListener::bind("127.0.0.1:0")
530 .expect("test server should bind to a loopback port");
531 let address = listener
532 .local_addr()
533 .expect("test server should have a local address");
534 let server = std::thread::spawn(move || {
535 let (mut stream, _) = listener
536 .accept()
537 .expect("test server should accept the SDK request");
538 let mut request = [0_u8; 4096];
539 stream
540 .read(&mut request)
541 .expect("test server should read the SDK request");
542 stream
543 .write_all(
544 b"HTTP/1.1 500 Internal Server Error\r\ncontent-type: text/html\r\ncontent-length: 17\r\nconnection: close\r\n\r\nupstream exploded",
545 )
546 .expect("test server should return its malformed error body");
547 });
548
549 let sdk_error = Client::new(&format!("http://{address}"))
550 .resolve_binding()
551 .body(types::ResolveBindingRequest {
552 deployment_id: "dep_test".to_string(),
553 resource_id: "storage".to_string(),
554 })
555 .send()
556 .await
557 .expect_err("the generated SDK should return the server error");
558 server.join().expect("test server should stop cleanly");
559
560 assert!(matches!(
561 &sdk_error,
562 Error::UnexpectedResponse(response)
563 if response.status() == reqwest::StatusCode::INTERNAL_SERVER_ERROR
564 ));
565 let error = convert_typed_sdk_error_reading_body(sdk_error).await;
566
567 assert_eq!(error.code, "UNEXPECTED_RESPONSE");
568 assert_eq!(error.http_status_code, Some(500));
569 assert!(error.retryable);
570 }
571
572 #[test]
573 fn retryable_http_statuses_are_limited_to_transient_failures() {
574 for status in [408, 425, 429, 500, 502, 503, 504, 599] {
575 assert!(
576 is_retryable_http_status(status),
577 "status {status} should be retryable"
578 );
579 }
580 for status in [400, 401, 403, 404, 409, 422, 600] {
581 assert!(
582 !is_retryable_http_status(status),
583 "status {status} should not be retryable"
584 );
585 }
586 }
587
588 #[tokio::test]
589 async fn communication_error_includes_url_in_message_and_context() {
590 let reqwest_err = reqwest::Client::new()
591 .get("http://127.0.0.1:9/v1/initialize")
592 .send()
593 .await
594 .expect_err("localhost discard port should refuse the connection");
595
596 let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
597
598 assert_eq!(error.code, "COMMUNICATION_ERROR");
599 assert!(error
600 .message
601 .starts_with("HTTP request http://127.0.0.1:9/v1/initialize failed:"));
602 assert_eq!(
603 error.context.as_ref().unwrap()["url"],
604 "http://127.0.0.1:9/v1/initialize"
605 );
606 }
607
608 #[test]
609 fn invalid_success_payload_never_copies_response_credentials_into_errors() {
610 let body = br#"{"accessToken":"sensitive-token","unexpected":true}"#.to_vec();
611 let parse_error = serde_json::from_slice::<serde_json::Value>(b"{")
612 .expect_err("fixture JSON should be invalid");
613 let error = super::convert_sdk_error(Error::InvalidResponsePayload(
614 body.clone().into(),
615 parse_error,
616 ));
617 let rendered = format!("{error:?}");
618
619 assert_eq!(error.code, "INVALID_RESPONSE_PAYLOAD");
620 assert_eq!(
621 error.context.as_ref().unwrap()["responseBodyLength"],
622 body.len()
623 );
624 assert!(!rendered.contains("sensitive-token"));
625 assert!(error
626 .context
627 .as_ref()
628 .unwrap()
629 .get("responseBody")
630 .is_none());
631 }
632}