Skip to main content

alien_manager_api/
lib.rs

1//! Alien Manager API
2//!
3//! Auto-generated from OpenAPI spec using Progenitor.
4//! Provides a type-safe Rust client for the alien-manager API.
5//!
6//! ## Usage
7//!
8//! ```ignore
9//! use alien_manager_api::Client;
10//!
11//! let client = Client::new("http://localhost:8080");
12//!
13//! // Create a deployment
14//! let response = client
15//!     .create_deployment()
16//!     .body(&CreateDeploymentRequest {
17//!         name: "my-deployment".into(),
18//!         platform: Platform::Aws,
19//!         ..Default::default()
20//!     })
21//!     .send()
22//!     .await?;
23//! ```
24
25include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
26
27use alien_error::{AlienError, GenericError, HumanLayerPresentation};
28
29/// Extension trait for converting manager SDK results to `AlienError`.
30///
31/// Async because it reads the body of an error response. The manager answers
32/// every failure with an Alien error JSON body, and the spec this client is
33/// generated from declares no error responses (see
34/// `client-sdks/manager/scripts/fix-openapi.mjs`), so every error status
35/// arrives as `Error::UnexpectedResponse` with its body unread. Reading it keeps
36/// the server's code, message, hint and source instead of "Unexpected response".
37pub trait SdkResultExt<T> {
38    /// Convert SDK result to `AlienError` result, preserving API error details.
39    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
58/// Convert a progenitor SDK error to `AlienError`, preserving all details.
59///
60/// For an error status it reads the response body, so a structured Alien
61/// error returned by the manager survives the round-trip. A body that is not
62/// an Alien error (a proxy's HTML page) gives an `UNEXPECTED_RESPONSE` error
63/// that keeps the status, so retry decisions still work.
64pub 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        // GENERIC_ERROR is the unclassified error: it is always sent with
95        // `retryable: false`, including a server's sanitized "Internal server
96        // error" that hides a retryable cause (a database error, say). Its
97        // flag is no retry decision, so the HTTP status decides.
98        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
114/// An error for an HTTP status whose body carried no Alien error.
115fn 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
167/// Returns whether an HTTP response represents a transient failure that a
168/// caller may safely retry.
169pub fn is_retryable_http_status(status: u16) -> bool {
170    matches!(status, 408 | 425 | 429) || (500..=599).contains(&status)
171}
172
173/// Convert an SDK error that carries no unread response body.
174fn convert_sdk_error_without_body(err: Error<()>) -> AlienError<GenericError> {
175    match err {
176        // The generation spec declares no error statuses, so this only covers a
177        // body-less error type; there is no body left to read.
178        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                    // Manager responses can contain short-lived credentials.
234                    // Preserve enough metadata to diagnose truncation or
235                    // schema drift without copying response bytes into errors.
236                    "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    /// A server hides an internal error behind GENERIC_ERROR "Internal server
365    /// error" with `retryable: false`, whatever the cause. A transient database
366    /// failure must stay retryable for callers like `alien deployments wait`.
367    #[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    /// A classified error keeps the server's decision, even on a 5xx status.
391    #[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    /// Serves exactly one HTTP response on a loopback port and returns its base URL.
428    /// `head` is the status line plus any extra header lines, each ending in CRLF.
429    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    /// Every manager route answers failures with an Alien error body. The
481    /// generated client must hand that error to callers for any status, whether
482    /// or not the route's OpenAPI annotation lists it: GET /v1/deployments/{id}
483    /// lists 404 but not 400.
484    #[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}