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
35impl<T> SdkResultExt<ResponseValue<T>> for Result<ResponseValue<T>, Error<()>> {
36 fn into_sdk_error(self) -> Result<ResponseValue<T>, AlienError<GenericError>> {
37 self.map_err(convert_sdk_error)
38 }
39}
40
41pub async fn convert_sdk_error_reading_body(err: Error<()>) -> AlienError<GenericError> {
49 match err {
50 Error::UnexpectedResponse(response) => {
51 let status = response.status().as_u16();
52 let canonical_reason = response
53 .status()
54 .canonical_reason()
55 .unwrap_or("Unknown")
56 .to_string();
57 let url = response.url().to_string();
58 let body = response.text().await.unwrap_or_default();
59
60 if let Ok(mut api_error) = serde_json::from_str::<AlienError<GenericError>>(&body) {
61 if api_error.http_status_code.is_none() {
62 api_error.http_status_code = Some(status);
63 }
64 return api_error;
65 }
66
67 AlienError {
68 code: "UNEXPECTED_RESPONSE".to_string(),
69 message: format!("Unexpected response: {} {}", status, canonical_reason),
70 context: Some(serde_json::json!({
71 "status": status,
72 "url": url,
73 })),
74 hint: None,
75 retryable: status >= 500,
76 internal: false,
77 http_status_code: Some(status),
78 source: None,
79 human_layer_presentation: HumanLayerPresentation::Normal,
80 error: Some(GenericError {
81 message: format!("Unexpected response status: {}", status),
82 }),
83 }
84 }
85 other => convert_sdk_error(other),
86 }
87}
88
89pub fn convert_sdk_error(err: Error<()>) -> AlienError<GenericError> {
91 match err {
92 Error::ErrorResponse(response) => {
93 let status = response.status().as_u16();
94 AlienError {
95 code: "UNEXPECTED_RESPONSE".to_string(),
96 message: format!(
97 "Unexpected response: {} {}",
98 status,
99 response.status().canonical_reason().unwrap_or("Unknown")
100 ),
101 context: Some(serde_json::json!({
102 "status": status,
103 })),
104 hint: None,
105 retryable: status >= 500,
106 internal: false,
107 http_status_code: Some(status),
108 source: None,
109 human_layer_presentation: HumanLayerPresentation::Normal,
110 error: Some(GenericError {
111 message: format!("Unexpected response status: {}", status),
112 }),
113 }
114 }
115 Error::CommunicationError(reqwest_err) => {
116 let retryable =
117 reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
118 let message = reqwest_failure_message("HTTP request", &reqwest_err);
119
120 AlienError {
121 code: "COMMUNICATION_ERROR".to_string(),
122 message: message.clone(),
123 context: reqwest_failure_context(&reqwest_err),
124 hint: None,
125 retryable,
126 internal: false,
127 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
128 source: build_reqwest_source(&reqwest_err),
129 human_layer_presentation: HumanLayerPresentation::Normal,
130 error: Some(GenericError { message }),
131 }
132 }
133 Error::InvalidRequest(msg) => AlienError {
134 code: "INVALID_REQUEST".to_string(),
135 message: format!("Invalid Request: {}", msg),
136 context: None,
137 hint: None,
138 retryable: false,
139 internal: false,
140 http_status_code: Some(400),
141 source: None,
142 human_layer_presentation: HumanLayerPresentation::Normal,
143 error: Some(GenericError {
144 message: format!("Invalid Request: {}", msg),
145 }),
146 },
147 Error::ResponseBodyError(reqwest_err) => {
148 let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
149
150 AlienError {
151 code: "RESPONSE_BODY_ERROR".to_string(),
152 message: message.clone(),
153 context: reqwest_failure_context(&reqwest_err),
154 hint: None,
155 retryable: true,
156 internal: false,
157 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
158 source: build_reqwest_source(&reqwest_err),
159 human_layer_presentation: HumanLayerPresentation::Normal,
160 error: Some(GenericError { message }),
161 }
162 }
163 Error::InvalidResponsePayload(bytes, json_err) => {
164 let raw_body = String::from_utf8_lossy(&bytes);
165 let truncated = if raw_body.len() > 1000 {
166 format!(
167 "{}...(truncated {} bytes)",
168 &raw_body[..1000],
169 raw_body.len() - 1000
170 )
171 } else {
172 raw_body.to_string()
173 };
174
175 AlienError {
176 code: "INVALID_RESPONSE_PAYLOAD".to_string(),
177 message: format!("Failed to parse response: {}", json_err),
178 context: Some(serde_json::json!({
179 "parseError": json_err.to_string(),
180 "responseBody": truncated,
181 })),
182 hint: None,
183 retryable: false,
184 internal: false,
185 http_status_code: None,
186 source: Some(Box::new(AlienError::new(GenericError {
187 message: json_err.to_string(),
188 }))),
189 human_layer_presentation: HumanLayerPresentation::Normal,
190 error: Some(GenericError {
191 message: format!("Failed to parse response: {}", json_err),
192 }),
193 }
194 }
195 Error::InvalidUpgrade(reqwest_err) => {
196 let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
197
198 AlienError {
199 code: "INVALID_UPGRADE".to_string(),
200 message: message.clone(),
201 context: reqwest_failure_context(&reqwest_err),
202 hint: None,
203 retryable: false,
204 internal: false,
205 http_status_code: reqwest_err.status().map(|s| s.as_u16()),
206 source: build_reqwest_source(&reqwest_err),
207 human_layer_presentation: HumanLayerPresentation::Normal,
208 error: Some(GenericError { message }),
209 }
210 }
211 Error::UnexpectedResponse(response) => {
212 let status = response.status().as_u16();
213 AlienError {
214 code: "UNEXPECTED_RESPONSE".to_string(),
215 message: format!(
216 "Unexpected response: {} {}",
217 status,
218 response.status().canonical_reason().unwrap_or("Unknown")
219 ),
220 context: Some(serde_json::json!({
221 "status": status,
222 "url": response.url().to_string(),
223 })),
224 hint: None,
225 retryable: status >= 500,
226 internal: false,
227 http_status_code: Some(status),
228 source: None,
229 human_layer_presentation: HumanLayerPresentation::Normal,
230 error: Some(GenericError {
231 message: format!("Unexpected response status: {}", status),
232 }),
233 }
234 }
235 Error::Custom(msg) => AlienError {
236 code: "SDK_HOOK_ERROR".to_string(),
237 message: msg.clone(),
238 context: None,
239 hint: None,
240 retryable: false,
241 internal: false,
242 http_status_code: None,
243 source: None,
244 human_layer_presentation: HumanLayerPresentation::Normal,
245 error: Some(GenericError { message: msg }),
246 },
247 }
248}
249
250fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
251 match err.url() {
252 Some(url) => format!("{operation} {} failed: {err}", url),
253 None => format!("{operation} failed: {err}"),
254 }
255}
256
257fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
258 err.url().map(|url| {
259 serde_json::json!({
260 "url": url.to_string(),
261 })
262 })
263}
264
265fn build_reqwest_source(reqwest_err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
266 use std::error::Error as _;
267
268 reqwest_err.source().map(|source| {
269 Box::new(AlienError {
270 code: "GENERIC_ERROR".to_string(),
271 message: source.to_string(),
272 context: None,
273 hint: None,
274 retryable: false,
275 internal: false,
276 http_status_code: None,
277 source: None,
278 human_layer_presentation: HumanLayerPresentation::Transparent,
279 error: Some(GenericError {
280 message: source.to_string(),
281 }),
282 })
283 })
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn unexpected_response(status: u16, body: &str) -> Error<()> {
291 let response = http::Response::builder()
292 .status(status)
293 .body(body.to_string())
294 .expect("test response should build");
295 Error::UnexpectedResponse(reqwest::Response::from(response))
296 }
297
298 #[tokio::test]
299 async fn reading_body_preserves_structured_alien_errors() {
300 let body = serde_json::json!({
301 "code": "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN",
302 "message": "Choosing a public subdomain requires a custom project domain",
303 "retryable": false,
304 "internal": false,
305 "httpStatusCode": 400,
306 })
307 .to_string();
308
309 let error = convert_sdk_error_reading_body(unexpected_response(400, &body)).await;
310
311 assert_eq!(error.code, "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN");
312 assert_eq!(
313 error.message,
314 "Choosing a public subdomain requires a custom project domain"
315 );
316 assert_eq!(error.http_status_code, Some(400));
317 assert!(!error.retryable);
318 assert!(!error.internal);
319 }
320
321 #[tokio::test]
322 async fn reading_body_falls_back_to_generic_error_for_non_alien_payloads() {
323 let error =
324 convert_sdk_error_reading_body(unexpected_response(502, "<html>bad gateway</html>"))
325 .await;
326
327 assert_eq!(error.code, "UNEXPECTED_RESPONSE");
328 assert_eq!(error.message, "Unexpected response: 502 Bad Gateway");
329 assert_eq!(error.http_status_code, Some(502));
330 assert!(error.retryable);
331 }
332
333 #[tokio::test]
334 async fn communication_error_includes_url_in_message_and_context() {
335 let reqwest_err = reqwest::Client::new()
336 .get("http://127.0.0.1:9/v1/initialize")
337 .send()
338 .await
339 .expect_err("localhost discard port should refuse the connection");
340
341 let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
342
343 assert_eq!(error.code, "COMMUNICATION_ERROR");
344 assert!(error
345 .message
346 .starts_with("HTTP request http://127.0.0.1:9/v1/initialize failed:"));
347 assert_eq!(
348 error.context.as_ref().unwrap()["url"],
349 "http://127.0.0.1:9/v1/initialize"
350 );
351 }
352}