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