1use std::time::{Duration, Instant};
2
3use alien_core::{presigned::redact_url_for_error, MessagePayload, QueueMessage};
4use alien_error::{AlienError, Context, ContextError, IntoAlienError, IntoAlienErrorDirect};
5use chrono::{DateTime, Utc};
6use tracing::debug;
7
8use crate::{
9 error::{Error, ErrorData, Result},
10 resolve_envelope_urls,
11 types::{BodySpec, CommandResponse, Envelope, LeaseInfo, LeaseRequest, LeaseResponse},
12 PROTOCOL_VERSION,
13};
14
15pub const LEASE_SAFETY_MARGIN: Duration = Duration::from_secs(5);
22
23const COMMAND_RESPONSE_SUBMISSION_TIMEOUT: Duration = Duration::from_secs(30);
26
27pub fn command_budget(
36 deadline: Option<DateTime<Utc>>,
37 lease_expires_at: DateTime<Utc>,
38) -> DateTime<Utc> {
39 let margin = chrono::Duration::from_std(LEASE_SAFETY_MARGIN)
40 .unwrap_or_else(|_| chrono::Duration::seconds(5));
41 let lease_bound = (lease_expires_at - margin).max(Utc::now());
42 deadline.map_or(lease_bound, |d| d.min(lease_bound))
43}
44
45pub fn parse_envelope(message: &QueueMessage) -> Result<Option<Envelope>> {
47 let envelope_data = match &message.payload {
48 MessagePayload::Json(value) => {
49 match serde_json::from_value::<Envelope>(value.clone()) {
51 Ok(envelope) => envelope,
52 Err(_) => return Ok(None), }
54 }
55 MessagePayload::Text(text) => {
56 match serde_json::from_str::<Envelope>(text) {
58 Ok(envelope) => envelope,
59 Err(_) => return Ok(None), }
61 }
62 };
63
64 if envelope_data.protocol != PROTOCOL_VERSION {
66 return Ok(None);
67 }
68
69 envelope_data
70 .validate()
71 .context(ErrorData::InvalidEnvelope {
72 message: "Envelope validation failed".to_string(),
73 field: None,
74 })?;
75 Ok(Some(envelope_data))
76}
77
78pub async fn decode_params(envelope: &Envelope) -> Result<serde_json::Value> {
83 match &envelope.params {
84 BodySpec::Inline { inline_base64 } => {
85 use base64::{engine::general_purpose, Engine as _};
86
87 let bytes = general_purpose::STANDARD
88 .decode(inline_base64)
89 .into_alien_error()
90 .context(ErrorData::InvalidEnvelope {
91 message: "Failed to decode base64 params".to_string(),
92 field: Some("params.inlineBase64".to_string()),
93 })?;
94
95 serde_json::from_slice(&bytes)
96 .into_alien_error()
97 .context(ErrorData::InvalidEnvelope {
98 message: "Failed to parse params JSON".to_string(),
99 field: Some("params".to_string()),
100 })
101 }
102 BodySpec::Storage {
103 storage_get_request,
104 ..
105 } => {
106 let presigned_request = storage_get_request.as_ref().ok_or_else(|| {
107 AlienError::new(ErrorData::InvalidEnvelope {
108 message: "Storage params missing storage_get_request".to_string(),
109 field: Some("params.storageGetRequest".to_string()),
110 })
111 })?;
112
113 let response = presigned_request.execute(None).await.context(
114 ErrorData::StorageOperationFailed {
115 message: "Failed to fetch params from storage".to_string(),
116 operation: Some("get".to_string()),
117 path: Some(presigned_request.path.clone()),
118 },
119 )?;
120
121 let body = response.body.ok_or_else(|| {
122 AlienError::new(ErrorData::StorageOperationFailed {
123 message: "Storage response has no body".to_string(),
124 operation: Some("get".to_string()),
125 path: Some(presigned_request.path.clone()),
126 })
127 })?;
128
129 serde_json::from_slice(&body)
130 .into_alien_error()
131 .context(ErrorData::InvalidEnvelope {
132 message: "Failed to parse params JSON from storage".to_string(),
133 field: Some("params".to_string()),
134 })
135 }
136 }
137}
138
139pub async fn decode_params_bytes(envelope: &Envelope) -> Result<Vec<u8>> {
144 match &envelope.params {
145 BodySpec::Inline { inline_base64 } => {
146 use base64::{engine::general_purpose, Engine as _};
147
148 general_purpose::STANDARD
149 .decode(inline_base64)
150 .into_alien_error()
151 .context(ErrorData::InvalidEnvelope {
152 message: "Failed to decode base64 params".to_string(),
153 field: Some("params.inlineBase64".to_string()),
154 })
155 }
156 BodySpec::Storage {
157 storage_get_request,
158 ..
159 } => {
160 let presigned_request = storage_get_request.as_ref().ok_or_else(|| {
161 AlienError::new(ErrorData::InvalidEnvelope {
162 message: "Storage params missing storage_get_request".to_string(),
163 field: Some("params.storageGetRequest".to_string()),
164 })
165 })?;
166
167 let response = presigned_request.execute(None).await.context(
168 ErrorData::StorageOperationFailed {
169 message: "Failed to fetch params from storage".to_string(),
170 operation: Some("get".to_string()),
171 path: Some(presigned_request.path.clone()),
172 },
173 )?;
174
175 response.body.map(|b| b.to_vec()).ok_or_else(|| {
176 AlienError::new(ErrorData::StorageOperationFailed {
177 message: "Storage response has no body".to_string(),
178 operation: Some("get".to_string()),
179 path: Some(presigned_request.path.clone()),
180 })
181 })
182 }
183 }
184}
185
186#[cfg(any(feature = "runtime", feature = "receiver"))]
192pub async fn submit_response(envelope: &Envelope, response: CommandResponse) -> Result<()> {
193 submit_response_with_timeout(envelope, response, COMMAND_RESPONSE_SUBMISSION_TIMEOUT).await
194}
195
196#[cfg(feature = "receiver")]
203pub(crate) async fn submit_response_before(
204 envelope: &Envelope,
205 response: CommandResponse,
206 lease_expires_at: DateTime<Utc>,
207) -> Result<()> {
208 let remaining_lease = (lease_expires_at - Utc::now())
209 .to_std()
210 .unwrap_or(Duration::ZERO);
211 submit_response_with_timeout(
212 envelope,
213 response,
214 COMMAND_RESPONSE_SUBMISSION_TIMEOUT.min(remaining_lease),
215 )
216 .await
217}
218
219#[cfg(any(feature = "runtime", feature = "receiver"))]
220async fn submit_response_with_timeout(
221 envelope: &Envelope,
222 response: CommandResponse,
223 timeout: Duration,
224) -> Result<()> {
225 let operation = async {
226 let start_time = Instant::now();
227 let client = reqwest::Client::builder()
228 .timeout(COMMAND_RESPONSE_SUBMISSION_TIMEOUT)
229 .pool_max_idle_per_host(2)
230 .pool_idle_timeout(Some(Duration::from_secs(60)))
231 .build()
232 .into_alien_error()
233 .context(ErrorData::Other {
234 message: "Failed to create HTTP client".to_string(),
235 })?;
236 let (final_response, mut pending_upload) = prepare_response_submission(envelope, response)?;
237 let mut retry_delay = Duration::from_millis(100);
238 loop {
239 match submit_response_attempt(&client, envelope, &final_response, &mut pending_upload)
240 .await
241 {
242 Ok(()) => {
243 debug!(
244 command_id = %envelope.command_id,
245 processing_ms = start_time.elapsed().as_millis(),
246 response_type = if final_response.is_success() { "success" } else { "error" },
247 "Command response submitted successfully"
248 );
249 return Ok(());
250 }
251 Err(SubmissionAttemptError::Permanent(error)) => return Err(error),
252 Err(SubmissionAttemptError::Retryable(error)) => {
253 debug!(
254 command_id = %envelope.command_id,
255 error_code = %error.code,
256 retry_delay_ms = retry_delay.as_millis(),
257 "Transient command response submission failure; retrying"
258 );
259 tokio::time::sleep(retry_delay).await;
260 retry_delay = (retry_delay * 2).min(Duration::from_secs(1));
261 }
262 }
263 }
264 };
265
266 match tokio::time::timeout(timeout, operation).await {
267 Ok(result) => result,
268 Err(error) => Err(error
269 .into_alien_error()
270 .context(ErrorData::HttpOperationFailed {
271 message: format!(
272 "Command response upload/submission exceeded its {} ms headroom budget",
273 timeout.as_millis()
274 ),
275 method: None,
276 url: Some(redact_url_for_error(
277 &envelope.response_handling.submit_response_url,
278 )),
279 })),
280 }
281}
282
283#[cfg(any(feature = "runtime", feature = "receiver"))]
284enum SubmissionAttemptError {
285 Retryable(Error),
286 Permanent(Error),
287}
288
289#[cfg(any(feature = "runtime", feature = "receiver"))]
290fn prepare_response_submission(
291 envelope: &Envelope,
292 response: CommandResponse,
293) -> Result<(CommandResponse, Option<bytes::Bytes>)> {
294 match &response {
295 CommandResponse::Success { response: body } => {
296 let body_size = body.size().unwrap_or(0);
297
298 if body_size > envelope.response_handling.max_inline_bytes {
299 let body_bytes = body.decode_inline().ok_or_else(|| {
300 AlienError::new(ErrorData::Other {
301 message: "Cannot upload storage body - expected inline body".to_string(),
302 })
303 })?;
304 Ok((
305 CommandResponse::Success {
306 response: BodySpec::Storage {
307 size: Some(body_bytes.len() as u64),
308 storage_get_request: None,
309 storage_put_used: Some(true),
310 },
311 },
312 Some(bytes::Bytes::from(body_bytes)),
313 ))
314 } else {
315 Ok((response, None))
316 }
317 }
318 CommandResponse::Error { .. } => Ok((response, None)),
319 }
320}
321
322#[cfg(any(feature = "runtime", feature = "receiver"))]
323async fn submit_response_attempt(
324 client: &reqwest::Client,
325 envelope: &Envelope,
326 final_response: &CommandResponse,
327 pending_upload: &mut Option<bytes::Bytes>,
328) -> std::result::Result<(), SubmissionAttemptError> {
329 if let Some(body_bytes) = pending_upload.as_ref() {
330 debug!(
331 command_id = %envelope.command_id,
332 body_size = body_bytes.len(),
333 max_inline = envelope.response_handling.max_inline_bytes,
334 "Uploading large response body to storage"
335 );
336
337 let upload_result = envelope
338 .response_handling
339 .storage_upload_request
340 .execute_with_client(client, Some(body_bytes.clone()))
341 .await;
342 let upload_response = match upload_result {
343 Ok(response) => response,
344 Err(error) => {
345 let retryable = error.retryable;
346 let error = error.context(ErrorData::StorageOperationFailed {
347 message: "Failed to upload response to storage".to_string(),
348 operation: Some("put".to_string()),
349 path: Some(
350 envelope
351 .response_handling
352 .storage_upload_request
353 .path
354 .clone(),
355 ),
356 });
357 return Err(if retryable {
358 SubmissionAttemptError::Retryable(error)
359 } else {
360 SubmissionAttemptError::Permanent(error)
361 });
362 }
363 };
364
365 if upload_response.status_code < 200 || upload_response.status_code >= 300 {
366 let status = upload_response.status_code;
367 let error = AlienError::new(ErrorData::StorageOperationFailed {
368 message: format!("Storage upload failed with status {}", status),
369 operation: Some("put".to_string()),
370 path: Some(
371 envelope
372 .response_handling
373 .storage_upload_request
374 .path
375 .clone(),
376 ),
377 });
378 return Err(if status == 408 || status == 429 || status >= 500 {
379 SubmissionAttemptError::Retryable(error)
380 } else {
381 SubmissionAttemptError::Permanent(error)
382 });
383 }
384
385 debug!(
386 command_id = %envelope.command_id,
387 upload_status = upload_response.status_code,
388 "Response body uploaded to storage successfully"
389 );
390 *pending_upload = None;
391 }
392
393 let submit_url = &envelope.response_handling.submit_response_url;
395 let safe_submit_url = redact_url_for_error(submit_url);
396
397 debug!(
398 command_id = %envelope.command_id,
399 url = %safe_submit_url,
400 "Submitting command response"
401 );
402
403 let http_response = client
404 .put(submit_url)
405 .json(&crate::types::SubmitResponseRequest {
406 response: final_response.clone(),
407 })
408 .send()
409 .await;
410 let http_response =
411 match http_response {
412 Ok(response) => response,
413 Err(error) => {
414 let retryable = !error.is_builder();
415 let error = error.without_url().into_alien_error().context(
416 ErrorData::HttpOperationFailed {
417 message: "Failed to submit response".to_string(),
418 method: Some("PUT".to_string()),
419 url: Some(safe_submit_url.clone()),
420 },
421 );
422 return Err(if retryable {
423 SubmissionAttemptError::Retryable(error)
424 } else {
425 SubmissionAttemptError::Permanent(error)
426 });
427 }
428 };
429
430 if !http_response.status().is_success()
431 && http_response.status() != reqwest::StatusCode::CONFLICT
432 && http_response.status() != reqwest::StatusCode::GONE
433 {
434 let status = http_response.status();
435 let error = AlienError::new(ErrorData::HttpOperationFailed {
436 message: format!("Response submission failed with status {status}"),
439 method: Some("PUT".to_string()),
440 url: Some(safe_submit_url),
441 });
442 return Err(
443 if status == reqwest::StatusCode::REQUEST_TIMEOUT
444 || status == reqwest::StatusCode::TOO_MANY_REQUESTS
445 || status.is_server_error()
446 {
447 SubmissionAttemptError::Retryable(error)
448 } else {
449 SubmissionAttemptError::Permanent(error)
450 },
451 );
452 }
453
454 Ok(())
455}
456
457#[cfg(any(feature = "runtime", feature = "receiver"))]
468#[derive(Debug, Clone)]
469pub struct LeaseClient {
470 client: reqwest::Client,
471 endpoint: reqwest::Url,
472 token: String,
473}
474
475#[cfg(any(feature = "runtime", feature = "receiver"))]
476impl LeaseClient {
477 pub fn from_base(base: &reqwest::Url, token: String) -> Option<Self> {
484 let mut endpoint = base.clone();
485 endpoint
486 .path_segments_mut()
487 .ok()?
488 .pop_if_empty()
489 .push("commands")
490 .push("leases");
491 let client = reqwest::Client::builder()
496 .timeout(Duration::from_secs(30))
497 .build()
498 .ok()?;
499 Some(Self {
500 client,
501 endpoint,
502 token,
503 })
504 }
505
506 pub fn endpoint(&self) -> &reqwest::Url {
508 &self.endpoint
509 }
510
511 pub async fn acquire(&self, request: &LeaseRequest) -> Result<Vec<LeaseInfo>> {
515 self.acquire_with_token(request, &self.token).await
516 }
517
518 pub async fn acquire_with_token(
521 &self,
522 request: &LeaseRequest,
523 token: &str,
524 ) -> Result<Vec<LeaseInfo>> {
525 let response = self
526 .client
527 .post(self.endpoint.clone())
528 .header("Authorization", format!("Bearer {token}"))
529 .json(request)
530 .send()
531 .await
532 .into_alien_error()
533 .context(ErrorData::HttpOperationFailed {
534 message: "Failed to acquire leases".to_string(),
535 method: Some("POST".to_string()),
536 url: Some(self.endpoint.to_string()),
537 })?;
538
539 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
540 return Err(AlienError::new(ErrorData::CommandReceiverUnauthorized {
541 operation: "lease acquisition".to_string(),
542 url: self.endpoint.to_string(),
543 }));
544 }
545
546 if !response.status().is_success() {
547 let status = response.status();
548 if status == reqwest::StatusCode::REQUEST_TIMEOUT
549 || status == reqwest::StatusCode::TOO_MANY_REQUESTS
550 || status.is_server_error()
551 {
552 return Err(AlienError::new(ErrorData::HttpOperationFailed {
553 message: format!("Lease request failed with status {status}"),
554 method: Some("POST".to_string()),
555 url: Some(self.endpoint.to_string()),
556 }));
557 }
558 return Err(AlienError::new(ErrorData::CommandReceiverRequestRejected {
559 operation: "lease acquisition".to_string(),
560 status: status.as_u16(),
561 url: self.endpoint.to_string(),
562 }));
563 }
564
565 let mut lease_response: LeaseResponse =
566 response
567 .json()
568 .await
569 .into_alien_error()
570 .context(ErrorData::SerializationFailed {
571 message: "Failed to parse lease response".to_string(),
572 data_type: Some("LeaseResponse".to_string()),
573 })?;
574
575 for lease in &mut lease_response.leases {
580 resolve_envelope_urls(&mut lease.envelope, &self.endpoint);
581 }
582
583 Ok(lease_response.leases)
584 }
585
586 pub async fn release_with_token(&self, lease_id: &str, token: &str) -> Result<()> {
590 let mut endpoint = self.endpoint.clone();
591 let Ok(mut segments) = endpoint.path_segments_mut() else {
592 return Err(AlienError::new(ErrorData::HttpOperationFailed {
593 message: "Commands lease endpoint cannot be extended for release".to_string(),
594 method: Some("POST".to_string()),
595 url: Some(endpoint.to_string()),
596 }));
597 };
598 segments.push(lease_id).push("release");
599 drop(segments);
600 let response = self
601 .client
602 .post(endpoint.clone())
603 .header("Authorization", format!("Bearer {token}"))
604 .json(&crate::types::ReleaseRequest {
605 lease_id: lease_id.to_string(),
606 })
607 .send()
608 .await
609 .into_alien_error()
610 .context(ErrorData::HttpOperationFailed {
611 message: format!("Failed to release lease '{lease_id}'"),
612 method: Some("POST".to_string()),
613 url: Some(endpoint.to_string()),
614 })?;
615
616 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
617 return Err(AlienError::new(ErrorData::CommandReceiverUnauthorized {
618 operation: "lease release".to_string(),
619 url: endpoint.to_string(),
620 }));
621 }
622 if response.status().is_success()
623 || response.status() == reqwest::StatusCode::CONFLICT
624 || response.status() == reqwest::StatusCode::GONE
625 {
626 return Ok(());
627 }
628
629 let status = response.status();
630 Err(AlienError::new(ErrorData::HttpOperationFailed {
631 message: format!("Lease release failed with status {status}"),
632 method: Some("POST".to_string()),
633 url: Some(endpoint.to_string()),
634 }))
635 }
636}
637
638pub fn create_test_response(data: &[u8]) -> CommandResponse {
640 CommandResponse::success(data)
641}
642
643pub fn create_test_error(code: &str, message: &str) -> CommandResponse {
645 CommandResponse::error(code, message)
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use alien_core::presigned::{PresignedOperation, PresignedRequest};
652 use axum::{extract::State, http::StatusCode, routing::post, routing::put, Router};
653 use chrono::Utc;
654 use std::sync::atomic::{AtomicUsize, Ordering};
655 use std::sync::Arc;
656
657 async fn fail_once_then_accept(State(attempts): State<Arc<AtomicUsize>>) -> StatusCode {
658 if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
659 StatusCode::SERVICE_UNAVAILABLE
660 } else {
661 StatusCode::OK
662 }
663 }
664
665 async fn reject_permanently(State(attempts): State<Arc<AtomicUsize>>) -> StatusCode {
666 attempts.fetch_add(1, Ordering::SeqCst);
667 StatusCode::BAD_REQUEST
668 }
669
670 async fn reject_lease_permanently(
671 State(attempts): State<Arc<AtomicUsize>>,
672 ) -> (StatusCode, &'static str) {
673 attempts.fetch_add(1, Ordering::SeqCst);
674 (StatusCode::FORBIDDEN, "sensitive-provider-error-body")
675 }
676
677 async fn reject_lease_transiently(State(attempts): State<Arc<AtomicUsize>>) -> StatusCode {
678 attempts.fetch_add(1, Ordering::SeqCst);
679 StatusCode::SERVICE_UNAVAILABLE
680 }
681
682 #[derive(Clone)]
683 struct UploadThenRetryState {
684 uploads: Arc<AtomicUsize>,
685 submissions: Arc<AtomicUsize>,
686 }
687
688 async fn accept_upload(State(state): State<UploadThenRetryState>) -> StatusCode {
689 state.uploads.fetch_add(1, Ordering::SeqCst);
690 StatusCode::OK
691 }
692
693 async fn fail_first_submission(State(state): State<UploadThenRetryState>) -> StatusCode {
694 if state.submissions.fetch_add(1, Ordering::SeqCst) == 0 {
695 StatusCode::SERVICE_UNAVAILABLE
696 } else {
697 StatusCode::OK
698 }
699 }
700
701 fn create_test_envelope() -> Envelope {
702 Envelope {
703 protocol: PROTOCOL_VERSION.to_string(),
704 target: crate::types::CommandTarget::new(
705 "test-worker",
706 crate::types::CommandTargetType::Worker,
707 ),
708 command_id: "cmd_123".to_string(),
709 attempt: 1,
710 trace_context: None,
711 deadline: None,
712 command: "test-command".to_string(),
713 params: BodySpec::inline(b"{}"),
714 response_handling: crate::types::ResponseHandling {
715 max_inline_bytes: 150000,
716 submit_response_url: "https://commands.example.com/commands/cmd_123/response"
717 .to_string(),
718 storage_upload_request: PresignedRequest::new_http(
719 "https://storage.example.com/upload".to_string(),
720 "PUT".to_string(),
721 std::collections::HashMap::new(),
722 PresignedOperation::Put,
723 "test-path".to_string(),
724 Utc::now() + chrono::Duration::hours(1),
725 ),
726 },
727 deployment_id: "dep_123".to_string(),
728 }
729 }
730
731 #[test]
735 fn resolve_envelope_urls_resolves_relative_and_keeps_absolute() {
736 let base =
737 reqwest::Url::parse("https://edge.example.com/tenant/v1/commands/leases").unwrap();
738
739 let mut envelope = create_test_envelope();
740 envelope.response_handling.submit_response_url =
741 "cmd_123/response?response_token=t&expires=1".to_string();
742 if let alien_core::presigned::PresignedRequestBackend::Http { url, .. } =
743 &mut envelope.response_handling.storage_upload_request.backend
744 {
745 *url = "../storage/response-blob?sig=x".to_string();
746 }
747 envelope.params = BodySpec::Storage {
748 size: Some(2048),
749 storage_get_request: Some(PresignedRequest::new_http(
750 "cmd_123/params?sig=params".to_string(),
751 "GET".to_string(),
752 std::collections::HashMap::new(),
753 PresignedOperation::Get,
754 "commands/cmd_123/params".to_string(),
755 Utc::now() + chrono::Duration::hours(1),
756 )),
757 storage_put_used: Some(true),
758 };
759
760 resolve_envelope_urls(&mut envelope, &base);
761 assert_eq!(
762 envelope.response_handling.submit_response_url,
763 "https://edge.example.com/tenant/v1/commands/cmd_123/response?response_token=t&expires=1",
764 "relative submit URL must retain the endpoint prefix"
765 );
766 match &envelope.response_handling.storage_upload_request.backend {
767 alien_core::presigned::PresignedRequestBackend::Http { url, .. } => {
768 assert_eq!(
769 url,
770 "https://edge.example.com/tenant/v1/storage/response-blob?sig=x"
771 );
772 }
773 other => panic!("unexpected backend: {other:?}"),
774 }
775 let BodySpec::Storage {
776 storage_get_request: Some(params),
777 ..
778 } = &envelope.params
779 else {
780 panic!("storage params request");
781 };
782 assert_eq!(
783 params.url(),
784 "https://edge.example.com/tenant/v1/commands/cmd_123/params?sig=params"
785 );
786
787 let mut envelope = create_test_envelope();
789 let cloud_url = "https://storage.example.com/upload?X-Signature=DoNotCanonicalize%2FValue";
790 if let alien_core::presigned::PresignedRequestBackend::Http { url, .. } =
791 &mut envelope.response_handling.storage_upload_request.backend
792 {
793 *url = cloud_url.to_string();
794 }
795 resolve_envelope_urls(&mut envelope, &base);
796 assert_eq!(
797 envelope.response_handling.submit_response_url,
798 "https://commands.example.com/commands/cmd_123/response"
799 );
800 assert_eq!(
801 envelope.response_handling.storage_upload_request.url(),
802 cloud_url
803 );
804 }
805
806 #[tokio::test]
807 async fn submit_response_error_does_not_expose_response_token() {
808 let secret = "do-not-log-response-token";
809 let mut envelope = create_test_envelope();
810 envelope.response_handling.submit_response_url =
811 format!("http://127.0.0.1:0/response?response_token={secret}&expires=1");
812
813 let error = submit_response_with_timeout(
814 &envelope,
815 create_test_response(b"ok"),
816 Duration::from_millis(50),
817 )
818 .await
819 .expect_err("port zero must reject the response submission");
820 let serialized = serde_json::to_string(&error).unwrap();
821 let debug = format!("{error:?}");
822
823 assert!(
824 !serialized.contains(secret),
825 "serialized error leaked token"
826 );
827 assert!(!debug.contains(secret), "debug error leaked token");
828 assert!(serialized.contains("http://127.0.0.1:0/response"));
829 }
830
831 #[tokio::test]
832 async fn response_upload_and_submit_share_one_bounded_redacted_timeout() {
833 let upload_secret = "do-not-log-upload-signature";
834 let response_secret = "do-not-log-response-token";
835 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
836 let address = listener.local_addr().unwrap();
837 let server = tokio::spawn(async move {
838 let (_socket, _) = listener.accept().await.unwrap();
839 std::future::pending::<()>().await;
840 });
841
842 let mut envelope = create_test_envelope();
843 envelope.response_handling.max_inline_bytes = 1;
844 envelope.response_handling.submit_response_url =
845 format!("http://{address}/response?response_token={response_secret}");
846 envelope.response_handling.storage_upload_request = PresignedRequest::new_http(
847 format!("http://{address}/upload?signature={upload_secret}"),
848 "PUT".to_string(),
849 std::collections::HashMap::new(),
850 PresignedOperation::Put,
851 "test-path".to_string(),
852 Utc::now() + chrono::Duration::hours(1),
853 );
854
855 let error = submit_response_with_timeout(
856 &envelope,
857 create_test_response(b"large-response"),
858 Duration::from_millis(50),
859 )
860 .await
861 .expect_err("blackholed response upload must time out");
862 let serialized = serde_json::to_string(&error).unwrap();
863 let debug = format!("{error:?}");
864
865 for secret in [upload_secret, response_secret] {
866 assert!(
867 !serialized.contains(secret),
868 "serialized error leaked token"
869 );
870 assert!(!debug.contains(secret), "debug error leaked token");
871 }
872 assert!(serialized.contains(&format!("http://{address}/response")));
873 server.abort();
874 }
875
876 #[tokio::test]
877 async fn transient_final_put_is_retried_within_submission_budget() {
878 let attempts = Arc::new(AtomicUsize::new(0));
879 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
880 let address = listener.local_addr().unwrap();
881 let server_attempts = attempts.clone();
882 let server = tokio::spawn(async move {
883 axum::serve(
884 listener,
885 Router::new()
886 .route("/response", put(fail_once_then_accept))
887 .with_state(server_attempts),
888 )
889 .await
890 .unwrap();
891 });
892
893 let mut envelope = create_test_envelope();
894 envelope.response_handling.submit_response_url = format!("http://{address}/response");
895 submit_response_with_timeout(
896 &envelope,
897 create_test_response(b"ok"),
898 Duration::from_secs(2),
899 )
900 .await
901 .expect("second PUT should terminalize the command");
902
903 assert_eq!(attempts.load(Ordering::SeqCst), 2);
904 server.abort();
905 }
906
907 #[tokio::test]
908 async fn retrying_final_put_does_not_repeat_successful_blob_upload() {
909 let state = UploadThenRetryState {
910 uploads: Arc::new(AtomicUsize::new(0)),
911 submissions: Arc::new(AtomicUsize::new(0)),
912 };
913 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
914 let address = listener.local_addr().unwrap();
915 let server_state = state.clone();
916 let server = tokio::spawn(async move {
917 axum::serve(
918 listener,
919 Router::new()
920 .route("/upload", put(accept_upload))
921 .route("/response", put(fail_first_submission))
922 .with_state(server_state),
923 )
924 .await
925 .unwrap();
926 });
927
928 let mut envelope = create_test_envelope();
929 envelope.response_handling.max_inline_bytes = 1;
930 envelope.response_handling.submit_response_url = format!("http://{address}/response");
931 envelope.response_handling.storage_upload_request = PresignedRequest::new_http(
932 format!("http://{address}/upload"),
933 "PUT".to_string(),
934 std::collections::HashMap::new(),
935 PresignedOperation::Put,
936 "test-path".to_string(),
937 Utc::now() + chrono::Duration::hours(1),
938 );
939
940 submit_response_with_timeout(
941 &envelope,
942 create_test_response(b"large-response"),
943 Duration::from_secs(2),
944 )
945 .await
946 .expect("retrying the terminal PUT should not re-upload its blob");
947
948 assert_eq!(state.uploads.load(Ordering::SeqCst), 1);
949 assert_eq!(state.submissions.load(Ordering::SeqCst), 2);
950 server.abort();
951 }
952
953 #[tokio::test]
954 async fn permanent_final_put_rejection_is_not_retried() {
955 let attempts = Arc::new(AtomicUsize::new(0));
956 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
957 let address = listener.local_addr().unwrap();
958 let server_attempts = attempts.clone();
959 let server = tokio::spawn(async move {
960 axum::serve(
961 listener,
962 Router::new()
963 .route("/response", put(reject_permanently))
964 .with_state(server_attempts),
965 )
966 .await
967 .unwrap();
968 });
969
970 let mut envelope = create_test_envelope();
971 envelope.response_handling.submit_response_url = format!("http://{address}/response");
972 let error = submit_response_with_timeout(
973 &envelope,
974 create_test_response(b"ok"),
975 Duration::from_secs(2),
976 )
977 .await
978 .expect_err("400 is a permanent submission rejection");
979
980 assert_eq!(attempts.load(Ordering::SeqCst), 1);
981 assert!(error.message.contains("400"));
982 server.abort();
983 }
984
985 fn test_lease_request() -> LeaseRequest {
986 LeaseRequest {
987 deployment_id: "dep_123".to_string(),
988 target: crate::types::CommandTarget::new(
989 "agent",
990 crate::types::CommandTargetType::Daemon,
991 ),
992 max_leases: 1,
993 lease_seconds: 60,
994 }
995 }
996
997 #[tokio::test]
998 async fn permanent_lease_rejection_is_non_retryable_and_redacted() {
999 let attempts = Arc::new(AtomicUsize::new(0));
1000 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1001 let address = listener.local_addr().unwrap();
1002 let server_attempts = attempts.clone();
1003 let server = tokio::spawn(async move {
1004 axum::serve(
1005 listener,
1006 Router::new()
1007 .route("/commands/leases", post(reject_lease_permanently))
1008 .with_state(server_attempts),
1009 )
1010 .await
1011 .unwrap();
1012 });
1013 let base = reqwest::Url::parse(&format!("http://{address}")).unwrap();
1014 let client = LeaseClient::from_base(&base, "token".to_string()).unwrap();
1015
1016 let error = client
1017 .acquire(&test_lease_request())
1018 .await
1019 .expect_err("403 must terminate the receiver");
1020
1021 assert_eq!(attempts.load(Ordering::SeqCst), 1);
1022 assert_eq!(error.code, "COMMAND_RECEIVER_REQUEST_REJECTED");
1023 assert!(!error.retryable);
1024 assert!(!serde_json::to_string(&error)
1025 .unwrap()
1026 .contains("sensitive-provider-error-body"));
1027 server.abort();
1028 }
1029
1030 #[tokio::test]
1031 async fn transient_lease_rejection_remains_retryable() {
1032 let attempts = Arc::new(AtomicUsize::new(0));
1033 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1034 let address = listener.local_addr().unwrap();
1035 let server_attempts = attempts.clone();
1036 let server = tokio::spawn(async move {
1037 axum::serve(
1038 listener,
1039 Router::new()
1040 .route("/commands/leases", post(reject_lease_transiently))
1041 .with_state(server_attempts),
1042 )
1043 .await
1044 .unwrap();
1045 });
1046 let base = reqwest::Url::parse(&format!("http://{address}")).unwrap();
1047 let client = LeaseClient::from_base(&base, "token".to_string()).unwrap();
1048
1049 let error = client
1050 .acquire(&test_lease_request())
1051 .await
1052 .expect_err("503 must be retryable");
1053
1054 assert_eq!(attempts.load(Ordering::SeqCst), 1);
1055 assert_eq!(error.code, "HTTP_OPERATION_FAILED");
1056 assert!(error.retryable);
1057 server.abort();
1058 }
1059
1060 #[test]
1061 fn test_parse_envelope_json() {
1062 let envelope = create_test_envelope();
1063 let envelope_json = serde_json::to_value(&envelope).unwrap();
1064
1065 let queue_message = QueueMessage {
1066 id: "msg_123".to_string(),
1067 payload: MessagePayload::Json(envelope_json),
1068 receipt_handle: "handle_123".to_string(),
1069 timestamp: Utc::now(),
1070 source: "test-queue".to_string(),
1071 attributes: std::collections::HashMap::new(),
1072 attempt_count: Some(1),
1073 };
1074
1075 let parsed = parse_envelope(&queue_message).unwrap();
1076 assert!(parsed.is_some());
1077
1078 let parsed_envelope = parsed.unwrap();
1079 assert_eq!(parsed_envelope.command_id, "cmd_123");
1080 assert_eq!(parsed_envelope.command, "test-command");
1081 assert_eq!(parsed_envelope.protocol, PROTOCOL_VERSION);
1082 }
1083
1084 #[test]
1085 fn test_parse_envelope_text() {
1086 let envelope = create_test_envelope();
1087 let envelope_text = serde_json::to_string(&envelope).unwrap();
1088
1089 let queue_message = QueueMessage {
1090 id: "msg_456".to_string(),
1091 payload: MessagePayload::Text(envelope_text),
1092 receipt_handle: "handle_456".to_string(),
1093 timestamp: Utc::now(),
1094 source: "test-queue".to_string(),
1095 attributes: std::collections::HashMap::new(),
1096 attempt_count: Some(1),
1097 };
1098
1099 let parsed = parse_envelope(&queue_message).unwrap();
1100 assert!(parsed.is_some());
1101
1102 let parsed_envelope = parsed.unwrap();
1103 assert_eq!(parsed_envelope.command_id, "cmd_123");
1104 }
1105
1106 #[test]
1107 fn test_parse_non_command_message() {
1108 let queue_message = QueueMessage {
1109 id: "msg_789".to_string(),
1110 payload: MessagePayload::Json(serde_json::json!({"regular": "message"})),
1111 receipt_handle: "handle_789".to_string(),
1112 timestamp: Utc::now(),
1113 source: "test-queue".to_string(),
1114 attributes: std::collections::HashMap::new(),
1115 attempt_count: Some(1),
1116 };
1117
1118 let parsed = parse_envelope(&queue_message).unwrap();
1119 assert!(parsed.is_none());
1120 }
1121
1122 #[test]
1123 fn test_parse_invalid_protocol() {
1124 let mut envelope = create_test_envelope();
1125 envelope.protocol = "invalid.v1".to_string();
1126
1127 let envelope_json = serde_json::to_value(&envelope).unwrap();
1128 let queue_message = QueueMessage {
1129 id: "msg_invalid".to_string(),
1130 payload: MessagePayload::Json(envelope_json),
1131 receipt_handle: "handle_invalid".to_string(),
1132 timestamp: Utc::now(),
1133 source: "test-queue".to_string(),
1134 attributes: std::collections::HashMap::new(),
1135 attempt_count: Some(1),
1136 };
1137
1138 let parsed = parse_envelope(&queue_message).unwrap();
1139 assert!(parsed.is_none());
1140 }
1141
1142 #[test]
1143 fn test_create_test_response() {
1144 let response = create_test_response(b"Hello World");
1145 assert!(response.is_success());
1146
1147 if let CommandResponse::Success { response: body } = response {
1148 assert_eq!(body.decode_inline().unwrap(), b"Hello World");
1149 } else {
1150 panic!("Expected success response");
1151 }
1152 }
1153
1154 #[test]
1155 fn test_create_test_error() {
1156 let response = create_test_error("TEST_ERROR", "Something went wrong");
1157 assert!(response.is_error());
1158
1159 if let CommandResponse::Error { code, message, .. } = response {
1160 assert_eq!(code, "TEST_ERROR");
1161 assert_eq!(message, "Something went wrong");
1162 } else {
1163 panic!("Expected error response");
1164 }
1165 }
1166
1167 #[tokio::test]
1168 async fn test_decode_params_inline() {
1169 let params_json = serde_json::json!({"key": "value", "num": 42});
1170 let params_bytes = serde_json::to_vec(¶ms_json).unwrap();
1171
1172 let envelope = Envelope {
1173 protocol: PROTOCOL_VERSION.to_string(),
1174 target: crate::types::CommandTarget::new(
1175 "test-worker",
1176 crate::types::CommandTargetType::Worker,
1177 ),
1178 command_id: "cmd_decode".to_string(),
1179 attempt: 1,
1180 trace_context: None,
1181 deadline: None,
1182 command: "test".to_string(),
1183 params: BodySpec::inline(¶ms_bytes),
1184 response_handling: crate::types::ResponseHandling {
1185 max_inline_bytes: 150000,
1186 submit_response_url: "https://commands.example.com/response".to_string(),
1187 storage_upload_request: PresignedRequest::new_http(
1188 "https://storage.example.com/upload".to_string(),
1189 "PUT".to_string(),
1190 std::collections::HashMap::new(),
1191 PresignedOperation::Put,
1192 "test-path".to_string(),
1193 Utc::now() + chrono::Duration::hours(1),
1194 ),
1195 },
1196 deployment_id: "dep_123".to_string(),
1197 };
1198
1199 let decoded = decode_params(&envelope).await.unwrap();
1200 assert_eq!(decoded["key"], "value");
1201 assert_eq!(decoded["num"], 42);
1202 }
1203}