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 =
72 context_with_request_id(api_error.context, api_error.request_id.as_deref());
73
74 AlienError {
75 code: api_error.code.to_string(),
76 message: api_error.message.to_string(),
77 context,
78 hint: api_error.hint,
79 retryable: api_error.retryable,
80 internal: false, http_status_code: Some(status),
82 source: api_error.source.and_then(parse_source_error),
83 human_layer_presentation: HumanLayerPresentation::Normal,
84 error: Some(GenericError {
85 message: api_error.message.to_string(),
86 }),
87 }
88 }
89
90 Error::CommunicationError(reqwest_err) => {
92 let retryable =
93 reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
94 let message = reqwest_failure_message("HTTP request", &reqwest_err);
95
96 AlienError {
97 code: "COMMUNICATION_ERROR".to_string(),
98 message: message.clone(),
99 context: reqwest_failure_context(&reqwest_err),
100 hint: None,
101 retryable,
102 internal: false,
103 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
104 source: build_reqwest_source(&reqwest_err),
105 human_layer_presentation: HumanLayerPresentation::Normal,
106 error: Some(GenericError { message }),
107 }
108 }
109
110 Error::InvalidRequest(msg) => AlienError {
112 code: "INVALID_REQUEST".to_string(),
113 message: format!("Invalid Request: {}", msg),
114 context: None,
115 hint: None,
116 retryable: false,
117 internal: false,
118 http_status_code: Some(400),
119 source: None,
120 human_layer_presentation: HumanLayerPresentation::Normal,
121 error: Some(GenericError {
122 message: format!("Invalid Request: {}", msg),
123 }),
124 },
125
126 Error::ResponseBodyError(reqwest_err) => {
128 let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
129
130 AlienError {
131 code: "RESPONSE_BODY_ERROR".to_string(),
132 message: message.clone(),
133 context: reqwest_failure_context(&reqwest_err),
134 hint: None,
135 retryable: true, internal: false,
137 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
138 source: build_reqwest_source(&reqwest_err),
139 human_layer_presentation: HumanLayerPresentation::Normal,
140 error: Some(GenericError { message }),
141 }
142 }
143
144 Error::InvalidResponsePayload(bytes, json_err) => {
147 let raw_body = String::from_utf8_lossy(&bytes);
148 let truncated = if raw_body.len() > 1000 {
149 format!(
150 "{}...(truncated {} bytes)",
151 &raw_body[..1000],
152 raw_body.len() - 1000
153 )
154 } else {
155 raw_body.to_string()
156 };
157
158 AlienError {
159 code: "INVALID_RESPONSE_PAYLOAD".to_string(),
160 message: format!("Failed to parse response: {}", json_err),
161 context: Some(serde_json::json!({
162 "parseError": json_err.to_string(),
163 "responseBody": truncated,
164 })),
165 hint: None,
166 retryable: false,
167 internal: false,
168 http_status_code: None,
169 source: Some(Box::new(AlienError::new(GenericError {
170 message: json_err.to_string(),
171 }))),
172 human_layer_presentation: HumanLayerPresentation::Normal,
173 error: Some(GenericError {
174 message: format!("Failed to parse response: {}", json_err),
175 }),
176 }
177 }
178
179 Error::InvalidUpgrade(reqwest_err) => {
181 let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
182
183 AlienError {
184 code: "INVALID_UPGRADE".to_string(),
185 message: message.clone(),
186 context: reqwest_failure_context(&reqwest_err),
187 hint: None,
188 retryable: false,
189 internal: false,
190 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
191 source: build_reqwest_source(&reqwest_err),
192 human_layer_presentation: HumanLayerPresentation::Normal,
193 error: Some(GenericError { message }),
194 }
195 }
196
197 Error::UnexpectedResponse(response) => {
199 let status = response.status().as_u16();
200 AlienError {
201 code: "UNEXPECTED_RESPONSE".to_string(),
202 message: format!(
203 "Unexpected response: {} {}",
204 status,
205 response.status().canonical_reason().unwrap_or("Unknown")
206 ),
207 context: Some(serde_json::json!({
208 "status": status,
209 "url": response.url().to_string(),
210 })),
211 hint: None,
212 retryable: status >= 500, internal: false,
214 http_status_code: Some(status),
215 source: None,
216 human_layer_presentation: HumanLayerPresentation::Normal,
217 error: Some(GenericError {
218 message: format!("Unexpected response status: {}", status),
219 }),
220 }
221 }
222
223 Error::Custom(msg) => AlienError {
225 code: "SDK_HOOK_ERROR".to_string(),
226 message: msg.clone(),
227 context: None,
228 hint: None,
229 retryable: false,
230 internal: false,
231 http_status_code: None,
232 source: None,
233 human_layer_presentation: HumanLayerPresentation::Normal,
234 error: Some(GenericError { message: msg }),
235 },
236 }
237}
238
239fn context_with_request_id(
240 context: Option<serde_json::Value>,
241 request_id: Option<&str>,
242) -> Option<serde_json::Value> {
243 let Some(request_id) = request_id else {
244 return context;
245 };
246
247 match context {
248 Some(serde_json::Value::Object(mut object)) => {
249 object
250 .entry("requestId")
251 .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
252 Some(serde_json::Value::Object(object))
253 }
254 Some(value) => Some(serde_json::json!({
255 "requestId": request_id,
256 "context": value,
257 })),
258 None => Some(serde_json::json!({ "requestId": request_id })),
259 }
260}
261
262fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
263 match err.url() {
264 Some(url) => format!("{operation} {} failed: {err}", url),
265 None => format!("{operation} failed: {err}"),
266 }
267}
268
269fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
270 err.url().map(|url| {
271 serde_json::json!({
272 "url": url.to_string(),
273 })
274 })
275}
276
277fn build_reqwest_source(err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
279 use std::error::Error;
281
282 let mut sources = Vec::new();
283 let mut current: Option<&(dyn Error + 'static)> = err.source();
284
285 while let Some(src) = current {
286 sources.push(src.to_string());
287 current = src.source();
288 }
289
290 if sources.is_empty() {
291 return None;
292 }
293
294 let mut result: Option<Box<AlienError<GenericError>>> = None;
296 for msg in sources.into_iter().rev() {
297 let error = AlienError {
298 code: "GENERIC_ERROR".to_string(),
299 message: msg.clone(),
300 context: None,
301 hint: None,
302 retryable: false,
303 internal: false,
304 http_status_code: None,
305 source: result,
306 human_layer_presentation: HumanLayerPresentation::Normal,
307 error: Some(GenericError { message: msg }),
308 };
309 result = Some(Box::new(error));
310 }
311
312 result
313}
314
315fn parse_source_error(value: serde_json::Value) -> Option<Box<AlienError<GenericError>>> {
317 let obj = value.as_object()?;
318
319 let code = obj
320 .get("code")
321 .and_then(|v| v.as_str())
322 .unwrap_or("NESTED_ERROR")
323 .to_string();
324
325 let message = obj
326 .get("message")
327 .and_then(|v| v.as_str())
328 .unwrap_or("Nested error")
329 .to_string();
330
331 let context = obj.get("context").cloned();
332 let retryable = obj
333 .get("retryable")
334 .and_then(|v| v.as_bool())
335 .unwrap_or(false);
336
337 let nested_source = obj.get("source").cloned().and_then(parse_source_error);
339
340 Some(Box::new(AlienError {
341 code,
342 message: message.clone(),
343 context,
344 hint: None,
345 retryable,
346 internal: false,
347 http_status_code: None,
348 source: nested_source,
349 human_layer_presentation: HumanLayerPresentation::Normal,
350 error: Some(GenericError { message }),
351 }))
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn reconcile_state_preserves_runtime_update_metadata() {
360 let state = serde_json::json!({
361 "status": "updating",
362 "platform": "machines",
363 "protocolVersion": 1,
364 "runtimeMetadata": {
365 "pendingPreparedStack": {
366 "id": "updated-stack",
367 "resources": {}
368 },
369 "setupUpdateAuthorization": {
370 "nonce": "nonce-1",
371 "baselineFrozenDigest": "before",
372 "targetFrozenDigest": "after",
373 "releaseId": "rel_123",
374 "setupTarget": "aws",
375 "setupFingerprint": "fingerprint",
376 "setupFingerprintVersion": 1
377 }
378 }
379 });
380
381 let sdk_state: types::SyncReconcileRequestState =
382 serde_json::from_value(state).expect("deployment state should deserialize");
383 let serialized =
384 serde_json::to_value(sdk_state).expect("deployment state should serialize");
385
386 assert_eq!(
387 serialized["runtimeMetadata"]["pendingPreparedStack"]["id"],
388 "updated-stack"
389 );
390 assert_eq!(
391 serialized["runtimeMetadata"]["setupUpdateAuthorization"]["nonce"],
392 "nonce-1"
393 );
394 }
395
396 #[test]
397 fn test_api_error_code_deref() {
398 let code = types::ApiErrorCode::try_from("TEST_ERROR").unwrap();
400 assert_eq!(code.as_str(), "TEST_ERROR");
401 }
402
403 #[test]
404 fn context_with_request_id_adds_request_id_to_empty_context() {
405 let context = super::context_with_request_id(None, Some("req_123")).unwrap();
406
407 assert_eq!(context["requestId"], "req_123");
408 }
409
410 #[test]
411 fn context_with_request_id_preserves_existing_context() {
412 let context = super::context_with_request_id(
413 Some(serde_json::json!({ "workspace": "demo" })),
414 Some("req_123"),
415 )
416 .unwrap();
417
418 assert_eq!(context["workspace"], "demo");
419 assert_eq!(context["requestId"], "req_123");
420 }
421
422 #[tokio::test]
423 async fn documented_api_error_preserves_hint_and_request_id() {
424 let response = http::Response::builder()
425 .status(409)
426 .body(
427 serde_json::json!({
428 "code": "DEPLOYMENT_OPERATION_NOT_ALLOWED",
429 "message": "The deployment cannot be redeployed from this state",
430 "hint": "Retry the desired release or pin a different release",
431 "requestId": "req_recovery_123",
432 "retryable": false,
433 "internal": false
434 })
435 .to_string(),
436 )
437 .expect("test response should build");
438 let response = reqwest::Response::from(response);
439 let response = ResponseValue::from_response::<types::ApiError>(response)
440 .await
441 .expect("API error body should deserialize");
442
443 let error = convert_sdk_error(Error::ErrorResponse(response));
444
445 assert_eq!(
446 error.hint.as_deref(),
447 Some("Retry the desired release or pin a different release")
448 );
449 assert_eq!(
450 error.context.as_ref().unwrap()["requestId"],
451 "req_recovery_123"
452 );
453 }
454
455 #[tokio::test]
456 async fn communication_error_includes_url_in_message_and_context() {
457 let reqwest_err = reqwest::Client::new()
458 .get("http://127.0.0.1:9/v1/whoami")
459 .send()
460 .await
461 .expect_err("localhost discard port should refuse the connection");
462
463 let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
464
465 assert_eq!(error.code, "COMMUNICATION_ERROR");
466 assert!(error
467 .message
468 .starts_with("HTTP request http://127.0.0.1:9/v1/whoami failed:"));
469 assert_eq!(
470 error.context.as_ref().unwrap()["url"],
471 "http://127.0.0.1:9/v1/whoami"
472 );
473 }
474}