1use std::time::{Duration, Instant};
2
3use chrono::{DateTime, Utc};
4use futures::StreamExt;
5use serde::{Deserialize, Serialize};
6
7use crate::core::a2a::dlq::{DeadLetter, DeadLetterDelivery, DlqScope, MAX_ERROR_BYTES};
8use crate::core::a2a::relay::{
9 RELAY_MAX_PAYLOAD_BYTES, RELAY_MAX_RETRIES, RELAY_MAX_RETRY_DELAY_MS, RELAY_PEER_HEADER,
10 RelayPeerConfigV1, RelayPeerTableV1,
11};
12use crate::core::a2a_transport::TransportEnvelopeV1;
13
14const DEFAULT_MAX_PAYLOAD_BYTES: usize = 2_000_000;
15const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
16const MAX_TASK_RESPONSE_BYTES: usize = 64 * 1024;
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct RemoteTransportConfig {
20 pub endpoint_url: String,
21 pub timeout: Duration,
22 pub max_payload_bytes: usize,
23 pub auth_token: Option<String>,
24 pub signing_key: Option<String>,
25 pub recipient_id: Option<String>,
26 pub tenant_id: Option<String>,
27 pub project_id: Option<String>,
28 pub retry_count: u8,
29 pub retry_delay: Duration,
30 #[serde(default)]
31 pub peer_id: Option<String>,
32 #[serde(default)]
33 pub local_peer_id: Option<String>,
34 #[serde(default)]
35 pub peers: Vec<RelayPeerConfigV1>,
36 #[serde(skip)]
37 pub allow_loopback_http: bool,
38}
39
40impl Default for RemoteTransportConfig {
41 fn default() -> Self {
42 Self {
43 endpoint_url: String::new(),
44 timeout: Duration::from_secs(30),
45 max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES,
46 auth_token: None,
47 signing_key: None,
48 recipient_id: None,
49 tenant_id: None,
50 project_id: None,
51 retry_count: 2,
52 retry_delay: Duration::from_secs(1),
53 peer_id: None,
54 local_peer_id: None,
55 peers: Vec::new(),
56 allow_loopback_http: false,
57 }
58 }
59}
60
61impl RemoteTransportConfig {
62 pub fn validate(&self) -> Result<(), String> {
63 if self.timeout.is_zero() || self.max_payload_bytes == 0 {
64 return Err("transport bounds must be greater than zero".into());
65 }
66 if self.retry_count > RELAY_MAX_RETRIES
67 || self.retry_delay.as_millis() > RELAY_MAX_RETRY_DELAY_MS as u128
68 {
69 return Err("retry policy exceeds bounded relay caps".into());
70 }
71 if self.local_peer_id.is_some() || !self.peers.is_empty() {
74 return self.validate_peer_mode();
75 }
76 if self.peer_id.is_some() {
77 return Err("singleton transport must not contain a peer_id".into());
78 }
79 let url = reqwest::Url::parse(&self.endpoint_url)
80 .map_err(|error| format!("invalid endpoint_url: {error}"))?;
81 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
82 return Err("endpoint_url must be an absolute HTTP(S) URL".to_string());
83 }
84 if !url.username().is_empty() || url.password().is_some() {
85 return Err("endpoint_url must not contain credentials".to_string());
86 }
87 if url.scheme() == "http" && !is_loopback_host(&url) {
88 return Err("endpoint_url must use HTTPS except for loopback hosts".to_string());
89 }
90 if self.auth_token.as_ref().is_none_or(String::is_empty) {
91 return Err("auth_token is required for remote A2A delivery".to_string());
92 }
93 if self.signing_key.as_ref().is_none_or(String::is_empty) {
94 return Err("signing_key is required for remote A2A delivery".to_string());
95 }
96 if self.auth_token == self.signing_key {
97 return Err("signing_key must be distinct from auth_token".to_string());
98 }
99 if self.recipient_id.as_ref().is_none_or(String::is_empty) {
100 return Err("recipient_id is required for remote A2A delivery".to_string());
101 }
102 if self.tenant_id.as_ref().is_none_or(String::is_empty) {
103 return Err("tenant_id is required for remote A2A delivery".to_string());
104 }
105 if self.project_id.as_ref().is_none_or(String::is_empty) {
106 return Err("project_id is required for remote A2A delivery".to_string());
107 }
108 Ok(())
109 }
110
111 fn validate_peer_mode(&self) -> Result<(), String> {
112 if self.local_peer_id.as_deref().is_none_or(|id| {
113 id.is_empty() || id.len() > crate::core::a2a::relay::RELAY_MAX_ID_BYTES
114 }) || self.peers.is_empty()
115 || self.max_payload_bytes > RELAY_MAX_PAYLOAD_BYTES
116 {
117 return Err(
118 "peer transport requires a bounded local identity, peer table and payload limit"
119 .into(),
120 );
121 }
122 if !self.endpoint_url.is_empty()
123 || [
124 &self.auth_token,
125 &self.signing_key,
126 &self.recipient_id,
127 &self.tenant_id,
128 &self.project_id,
129 &self.peer_id,
130 ]
131 .iter()
132 .any(|value| value.is_some())
133 || self.retry_count != 0
134 || !self.retry_delay.is_zero()
135 {
136 return Err(
137 "peer transport must not contain singleton authority or retry policy".into(),
138 );
139 }
140 let table = RelayPeerTableV1 {
141 schema_version: crate::core::a2a::relay::RELAY_PEER_TABLE_VERSION,
142 peers: self.peers.clone(),
143 };
144 table
145 .validate(self.allow_loopback_http)
146 .map_err(|error| error.to_string())?;
147 table
148 .reject_shared_secret()
149 .map_err(|error| error.to_string())
150 }
151
152 fn delivery_url(&self) -> Result<reqwest::Url, String> {
153 self.validate()?;
154 let mut url = reqwest::Url::parse(&self.endpoint_url)
155 .map_err(|error| format!("invalid endpoint_url: {error}"))?;
156 let path = format!("{}/a2a/deliver", url.path().trim_end_matches('/'));
157 url.set_path(&path);
158 url.set_query(None);
159 url.set_fragment(None);
160 Ok(url)
161 }
162}
163
164fn is_loopback_host(url: &reqwest::Url) -> bool {
165 url.host_str().is_some_and(|host| {
166 let ip_host = host
167 .strip_prefix('[')
168 .and_then(|value| value.strip_suffix(']'))
169 .unwrap_or(host);
170 host.eq_ignore_ascii_case("localhost")
171 || ip_host
172 .parse::<std::net::IpAddr>()
173 .is_ok_and(|address| address.is_loopback())
174 })
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct RemoteTransport {
179 config: RemoteTransportConfig,
180 #[serde(skip, default = "fixed_peer_client")]
181 client: reqwest::Client,
182}
183
184fn fixed_peer_client() -> reqwest::Client {
185 reqwest::Client::builder()
187 .redirect(reqwest::redirect::Policy::none())
188 .build()
189 .expect("initialize fixed-peer HTTP client")
190}
191
192impl RemoteTransport {
193 #[cfg(any(all(test, feature = "http-server"), feature = "test-support"))]
194 pub fn with_test_tls_root(mut self, certificate: reqwest::Certificate) -> Self {
195 self.client = reqwest::Client::builder()
196 .redirect(reqwest::redirect::Policy::none())
197 .add_root_certificate(certificate)
198 .build()
199 .expect("test TLS client");
200 self
201 }
202
203 pub fn new(config: RemoteTransportConfig) -> Result<Self, String> {
204 config.validate()?;
205 Ok(Self {
206 config,
207 client: fixed_peer_client(),
208 })
209 }
210
211 pub fn for_peer_table(
212 table: RelayPeerTableV1,
213 local_peer_id: &str,
214 timeout: Duration,
215 allow_loopback_http: bool,
216 ) -> Result<Self, String> {
217 table
218 .validate(allow_loopback_http)
219 .map_err(|error| error.to_string())?;
220 Self::new(RemoteTransportConfig {
221 timeout,
222 max_payload_bytes: RELAY_MAX_PAYLOAD_BYTES,
223 retry_count: 0,
224 retry_delay: Duration::ZERO,
225 local_peer_id: Some(local_peer_id.to_string()),
226 peers: table.peers,
227 allow_loopback_http,
228 ..RemoteTransportConfig::default()
229 })
230 }
231
232 pub async fn deliver(
233 &self,
234 envelope: &TransportEnvelopeV1,
235 ) -> Result<DeliveryReceipt, TransportError> {
236 self.config
237 .validate()
238 .map_err(TransportError::SerializationError)?;
239 if self.config.local_peer_id.is_some() {
240 return Err(TransportError::SerializationError(
241 "peer transport requires explicit peer delivery".into(),
242 ));
243 }
244 self.ensure_dlq_available().await?;
245 let mut signed = envelope.clone();
246 let expected_recipient = self
247 .config
248 .recipient_id
249 .as_deref()
250 .expect("validated transport has recipient identity");
251 if signed.recipient.as_deref() != Some(expected_recipient) {
252 return Err(TransportError::SerializationError(
253 "envelope recipient does not match configured remote recipient".to_string(),
254 ));
255 }
256 signed.metadata.insert(
257 "tenant_id".to_string(),
258 self.config
259 .tenant_id
260 .clone()
261 .expect("validated transport has tenant scope"),
262 );
263 signed.metadata.insert(
264 "project_id".to_string(),
265 self.config
266 .project_id
267 .clone()
268 .expect("validated transport has project scope"),
269 );
270 let secret = self
271 .config
272 .signing_key
273 .as_deref()
274 .ok_or_else(|| TransportError::SerializationError("missing signing key".into()))?;
275 signed
276 .sign(secret.as_bytes())
277 .map_err(TransportError::SerializationError)?;
278 let body = serialize_and_validate(&signed, self.config.max_payload_bytes)?;
279 let envelope_id = envelope_id(&body);
280 let delivery_url = self
281 .config
282 .delivery_url()
283 .map_err(TransportError::SerializationError)?;
284 let started_at = Instant::now();
285
286 for attempt in 0..=self.config.retry_count {
287 let mut request = self
288 .client
289 .post(delivery_url.clone())
290 .header(reqwest::header::CONTENT_TYPE, "application/json")
291 .timeout(self.config.timeout)
292 .body(body.clone());
293 if let Some(token) = self.config.auth_token.as_deref() {
294 request = request.bearer_auth(token);
295 }
296
297 match request.send().await {
298 Ok(response) if response.status().is_success() => {
299 let remote_status = response.status().as_u16();
300 let unverified_task_response = match read_task_response(response, &signed).await
301 {
302 Ok(body) => body,
303 Err(error) => return Err(error),
307 };
308 return Ok(DeliveryReceipt {
309 envelope_id,
310 delivered_at: Utc::now(),
311 remote_status,
312 round_trip_ms: elapsed_millis(started_at),
313 unverified_task_response,
314 });
315 }
316 Ok(response) if response.status().is_server_error() => {
317 if attempt == self.config.retry_count {
318 return Err(self
319 .record_failure(
320 &signed,
321 &body,
322 TransportError::Exhausted(self.config.retry_count),
323 attempt.saturating_add(1),
324 )
325 .await);
326 }
327 }
328 Ok(response) => {
329 let status = response.status();
330 let error_body = read_error_body(response).await;
331 let error = TransportError::RemoteError(status.as_u16(), error_body);
332 return Err(self
333 .record_failure(&signed, &body, error, attempt.saturating_add(1))
334 .await);
335 }
336 Err(error) if error.is_timeout() && self.config.retry_count == 0 => {
337 return Err(self
338 .record_failure(
339 &signed,
340 &body,
341 TransportError::Timeout,
342 attempt.saturating_add(1),
343 )
344 .await);
345 }
346 Err(_) if attempt == self.config.retry_count => {
347 return Err(self
348 .record_failure(
349 &signed,
350 &body,
351 TransportError::Exhausted(self.config.retry_count),
352 attempt.saturating_add(1),
353 )
354 .await);
355 }
356 Err(_) => {}
357 }
358
359 tokio::time::sleep(self.config.retry_delay).await;
360 }
361
362 Err(TransportError::Exhausted(self.config.retry_count))
363 }
364
365 pub async fn deliver_to(
369 &self,
370 peer_id: &str,
371 envelope: &TransportEnvelopeV1,
372 ) -> Result<DeliveryReceipt, TransportError> {
373 self.config
374 .validate()
375 .map_err(TransportError::SerializationError)?;
376 if self.config.local_peer_id.is_none() {
377 return Err(TransportError::SerializationError(
378 "explicit peer delivery requires peer transport".into(),
379 ));
380 }
381 let peer = self
382 .config
383 .peers
384 .iter()
385 .find(|peer| peer.peer_id == peer_id)
386 .cloned()
387 .ok_or_else(|| {
388 TransportError::SerializationError(format!("unknown relay peer: {peer_id}"))
389 })?;
390 let mut signed = envelope.clone();
391 let record = signed
392 .relay_record()
393 .map_err(TransportError::SerializationError)?
394 .ok_or_else(|| {
395 TransportError::SerializationError(
396 "relay record is required for peer delivery".into(),
397 )
398 })?;
399 let now = Utc::now();
400 record
401 .validate_at(
402 now,
403 &record.current_peer.clone(),
404 Some(peer.peer_id.as_str()),
405 peer.max_hops,
406 )
407 .map_err(|error| TransportError::SerializationError(error.to_string()))?;
408 record
409 .verify_hop(&peer.channel_key)
410 .map_err(|error| TransportError::SerializationError(error.to_string()))?;
411 if !peer.allows(&record, signed.payload_json.len())
412 || signed.content_type != record.content_type
413 || signed.sender.agent_id != record.current_peer
414 || self.config.local_peer_id.as_deref() != Some(record.current_peer.as_str())
415 || signed.metadata.get("tenant_id").map(String::as_str)
416 != Some(record.tenant_id.as_str())
417 || signed.metadata.get("project_id").map(String::as_str)
418 != Some(record.project_id.as_str())
419 || record
420 .verify_payload(signed.payload_json.as_bytes())
421 .is_err()
422 {
423 return Err(TransportError::SerializationError(
424 "peer policy denied relay scope".into(),
425 ));
426 }
427 if signed.recipient.as_deref() != Some(record.final_recipient.as_str()) {
428 return Err(TransportError::SerializationError(
429 "envelope recipient does not match signed final recipient".into(),
430 ));
431 }
432 signed
433 .attach_relay_record(&record)
434 .map_err(TransportError::SerializationError)?;
435 signed.signature = None;
436 signed
437 .sign(peer.channel_key.as_bytes())
438 .map_err(TransportError::SerializationError)?;
439 let body = serialize_and_validate(&signed, peer.max_payload_bytes)?;
440 let delivery_id = record.delivery_id.clone();
441 let delivery_url = peer_delivery_url(&peer).map_err(TransportError::SerializationError)?;
442 self.ensure_dlq_scope(&record.tenant_id, &record.project_id)
443 .await?;
444 let started_at = Instant::now();
445 let source_peer = self
446 .config
447 .local_peer_id
448 .as_deref()
449 .unwrap_or(record.current_peer.as_str());
450
451 for attempt in 0..=peer.retry_count {
452 let request = self
453 .client
454 .post(delivery_url.clone())
455 .header(reqwest::header::CONTENT_TYPE, "application/json")
456 .header("idempotency-key", delivery_id.as_str())
457 .header(RELAY_PEER_HEADER, source_peer)
458 .timeout(self.config.timeout)
459 .bearer_auth(peer.bearer_token.as_str())
460 .body(body.clone());
461 match request.send().await {
462 Ok(response) if response.status().is_success() => {
463 let remote_status = response.status().as_u16();
464 let unverified_task_response = match read_task_response(response, &signed).await
465 {
466 Ok(body) => body,
467 Err(error) => return Err(error),
471 };
472 return Ok(DeliveryReceipt {
473 envelope_id: delivery_id,
474 delivered_at: Utc::now(),
475 remote_status,
476 round_trip_ms: elapsed_millis(started_at),
477 unverified_task_response,
478 });
479 }
480 Ok(response) if response.status().is_server_error() => {
481 if attempt == peer.retry_count {
482 let failure = TransportError::Exhausted(peer.retry_count);
483 return Err(self
484 .record_failure_for_peer(
485 &signed,
486 &body,
487 &failure,
488 attempt.saturating_add(1),
489 &peer,
490 )
491 .await);
492 }
493 }
494 Ok(response) => {
495 let status = response.status();
496 let error = TransportError::RemoteError(
497 status.as_u16(),
498 read_error_body(response).await,
499 );
500 return Err(self
501 .record_failure_for_peer(
502 &signed,
503 &body,
504 &error,
505 attempt.saturating_add(1),
506 &peer,
507 )
508 .await);
509 }
510 Err(error) if error.is_timeout() && peer.retry_count == 0 => {
511 return Err(self
512 .record_failure_for_peer(
513 &signed,
514 &body,
515 &TransportError::Timeout,
516 attempt.saturating_add(1),
517 &peer,
518 )
519 .await);
520 }
521 Err(_) if attempt == peer.retry_count => {
522 let failure = TransportError::Exhausted(peer.retry_count);
523 return Err(self
524 .record_failure_for_peer(
525 &signed,
526 &body,
527 &failure,
528 attempt.saturating_add(1),
529 &peer,
530 )
531 .await);
532 }
533 Err(_) => {}
534 }
535 tokio::time::sleep(Duration::from_millis(peer.retry_delay_ms)).await;
536 }
537 Err(TransportError::Exhausted(peer.retry_count))
538 }
539
540 async fn record_failure(
541 &self,
542 envelope: &TransportEnvelopeV1,
543 body: &[u8],
544 failure: TransportError,
545 attempts: u8,
546 ) -> TransportError {
547 let envelope = envelope.clone();
548 let body = body.to_vec();
549 let config = self.config.clone();
550 let failure_for_store = failure.clone();
551 let stored = tokio::task::spawn_blocking(move || {
552 enqueue_permanent_failure(&envelope, &body, &failure_for_store, attempts, &config)
553 })
554 .await;
555 match stored {
556 Ok(Ok(())) => failure,
557 Ok(Err(error)) => TransportError::DeadLetterFailure(format!("{failure}; {error}")),
558 Err(error) => TransportError::DeadLetterFailure(format!(
559 "{failure}; dead-letter worker failed: {error}"
560 )),
561 }
562 }
563
564 async fn ensure_dlq_available(&self) -> Result<(), TransportError> {
565 let scope = DlqScope::new(
566 self.config
567 .tenant_id
568 .clone()
569 .expect("validated tenant scope"),
570 self.config
571 .project_id
572 .clone()
573 .expect("validated project scope"),
574 )
575 .map_err(|error| TransportError::DeadLetterFailure(error.to_string()))?;
576 self.ensure_dlq_scope_inner(scope).await
577 }
578
579 async fn ensure_dlq_scope(
580 &self,
581 tenant_id: &str,
582 project_id: &str,
583 ) -> Result<(), TransportError> {
584 let scope = DlqScope::new(tenant_id, project_id)
585 .map_err(|error| TransportError::DeadLetterFailure(error.to_string()))?;
586 self.ensure_dlq_scope_inner(scope).await
587 }
588
589 async fn ensure_dlq_scope_inner(&self, scope: DlqScope) -> Result<(), TransportError> {
590 let queue = crate::core::ocla::health::dead_letter_queue().clone();
591 tokio::task::spawn_blocking(move || queue.ensure_writable(&scope))
592 .await
593 .map_err(|error| {
594 TransportError::DeadLetterFailure(format!("dead-letter worker failed: {error}"))
595 })?
596 .map_err(|error| TransportError::DeadLetterFailure(error.to_string()))
597 }
598
599 async fn record_failure_for_peer(
600 &self,
601 envelope: &TransportEnvelopeV1,
602 body: &[u8],
603 failure: &TransportError,
604 attempts: u8,
605 peer: &RelayPeerConfigV1,
606 ) -> TransportError {
607 let envelope = envelope.clone();
608 let body = body.to_vec();
609 let config = self.config.clone();
610 let failure_for_store = failure.clone();
611 let peer_id = peer.peer_id.clone();
612 let endpoint_url = peer.endpoint_url.clone();
613 let stored = tokio::task::spawn_blocking(move || {
614 enqueue_permanent_failure_for_peer(
615 &envelope,
616 &body,
617 &failure_for_store,
618 attempts,
619 &config,
620 &peer_id,
621 &endpoint_url,
622 )
623 })
624 .await;
625 match stored {
626 Ok(Ok(())) => failure.clone(),
627 Ok(Err(error)) => TransportError::DeadLetterFailure(format!("{failure}; {error}")),
628 Err(error) => TransportError::DeadLetterFailure(format!(
629 "{failure}; dead-letter worker failed: {error}"
630 )),
631 }
632 }
633
634 pub async fn retry_dead_letter(
635 &self,
636 letter: &DeadLetter,
637 ) -> Result<DeliveryReceipt, TransportError> {
638 if letter.peer_id != "legacy" {
639 let envelope: TransportEnvelopeV1 = serde_json::from_str(&letter.original_message)
640 .map_err(|error| TransportError::SerializationError(error.to_string()))?;
641 let record = envelope
642 .relay_record()
643 .map_err(TransportError::SerializationError)?
644 .ok_or_else(|| {
645 TransportError::SerializationError(
646 "peer dead letter is missing relay record".into(),
647 )
648 })?;
649 if record.delivery_id != letter.delivery_id {
650 return Err(TransportError::SerializationError(
651 "dead letter delivery ID does not match relay record".into(),
652 ));
653 }
654 if record.tenant_id != letter.tenant_id || record.project_id != letter.project_id {
655 return Err(TransportError::SerializationError(
656 "dead letter scope does not match relay record".into(),
657 ));
658 }
659 let peer = self
660 .config
661 .peers
662 .iter()
663 .find(|peer| peer.peer_id == letter.peer_id)
664 .ok_or_else(|| {
665 TransportError::SerializationError("dead letter peer is not configured".into())
666 })?;
667 if &peer.endpoint_url
668 != match &letter.delivery {
669 DeadLetterDelivery::RemoteHttp { endpoint_url } => endpoint_url,
670 DeadLetterDelivery::LocalAgentBus => {
671 return Err(TransportError::SerializationError(
672 "dead letter is not remote".into(),
673 ));
674 }
675 }
676 {
677 return Err(TransportError::SerializationError(
678 "dead letter endpoint does not match peer".into(),
679 ));
680 }
681 let mut envelope = envelope;
682 envelope.sent_at = Utc::now();
683 envelope.signature = None;
684 return self.deliver_to(&letter.peer_id, &envelope).await;
685 }
686 let expected_scope = (
687 self.config.tenant_id.as_deref(),
688 self.config.project_id.as_deref(),
689 );
690 if expected_scope
691 != (
692 Some(letter.tenant_id.as_str()),
693 Some(letter.project_id.as_str()),
694 )
695 {
696 return Err(TransportError::SerializationError(
697 "dead letter scope does not match configured remote transport".into(),
698 ));
699 }
700 let DeadLetterDelivery::RemoteHttp { endpoint_url } = &letter.delivery else {
701 return Err(TransportError::SerializationError(
702 "dead letter is not a remote delivery".into(),
703 ));
704 };
705 if endpoint_url != &self.config.endpoint_url {
706 return Err(TransportError::SerializationError(
707 "dead letter endpoint does not match configured remote transport".into(),
708 ));
709 }
710 let mut envelope: TransportEnvelopeV1 = serde_json::from_str(&letter.original_message)
711 .map_err(|error| TransportError::SerializationError(error.to_string()))?;
712 if !envelope
713 .metadata
714 .contains_key(crate::core::a2a_transport::LEGACY_DELIVERY_ID_METADATA)
715 && let Some(signature) = envelope.signature.as_deref()
716 {
717 envelope.metadata.insert(
718 crate::core::a2a_transport::LEGACY_DELIVERY_ID_METADATA.to_string(),
719 signature.to_ascii_lowercase(),
720 );
721 envelope.metadata.insert(
722 crate::core::a2a_transport::LEGACY_DELIVERY_SENT_AT_METADATA.to_string(),
723 envelope.sent_at.to_rfc3339(),
724 );
725 }
726 envelope.sent_at = Utc::now();
727 envelope.signature = None;
728 self.deliver(&envelope).await
729 }
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
733pub struct DeliveryReceipt {
734 pub envelope_id: String,
735 pub delivered_at: DateTime<Utc>,
736 pub remote_status: u16,
737 pub round_trip_ms: u64,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub unverified_task_response: Option<String>,
743}
744
745impl DeliveryReceipt {
746 pub fn verified_task_status(
750 &self,
751 request: &super::task::TaskControlDescriptorV1,
752 policy: &super::task::TaskAuthorityConfigV1,
753 now: DateTime<Utc>,
754 ) -> Result<super::task::TaskStatusV1, super::task_response::InvalidTaskResponse> {
755 use super::task_response::{InvalidTaskResponse, SignedTaskStatusV1};
756
757 if !(200..300).contains(&self.remote_status) {
758 return Err(InvalidTaskResponse);
759 }
760 let raw = self
761 .unverified_task_response
762 .as_deref()
763 .ok_or(InvalidTaskResponse)?;
764 let response = SignedTaskStatusV1::from_json(raw)?;
765 response.verify(request, policy, now)?;
766 Ok(response.status)
767 }
768}
769
770async fn read_task_response(
771 response: reqwest::Response,
772 envelope: &TransportEnvelopeV1,
773) -> Result<Option<String>, TransportError> {
774 if envelope.content_type != crate::core::a2a_transport::TransportContentType::A2ATask {
775 return Ok(None);
776 }
777 let invalid =
778 || TransportError::SerializationError("invalid or oversized task response".into());
779 if response
780 .content_length()
781 .is_some_and(|length| length > MAX_TASK_RESPONSE_BYTES as u64)
782 {
783 return Err(invalid());
784 }
785 let mut stream = response.bytes_stream();
786 let mut bytes = Vec::new();
787 while let Some(chunk) = stream.next().await {
788 let chunk = chunk.map_err(|_| invalid())?;
789 if chunk.len() > MAX_TASK_RESPONSE_BYTES.saturating_sub(bytes.len()) {
790 return Err(invalid());
791 }
792 bytes.extend_from_slice(&chunk);
793 }
794 if bytes.is_empty() {
795 return Err(invalid());
796 }
797 String::from_utf8(bytes).map(Some).map_err(|_| invalid())
798}
799
800#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, thiserror::Error)]
801pub enum TransportError {
802 #[error("payload too large: {0} bytes")]
803 PayloadTooLarge(usize),
804 #[error("transport timed out")]
805 Timeout,
806 #[error("remote returned HTTP {0}: {1}")]
807 RemoteError(u16, String),
808 #[error("serialization failed: {0}")]
809 SerializationError(String),
810 #[error("delivery exhausted after {0} retries")]
811 Exhausted(u8),
812 #[error("delivery failed and could not be persisted to DLQ: {0}")]
813 DeadLetterFailure(String),
814}
815
816fn serialize_and_validate(
817 envelope: &TransportEnvelopeV1,
818 max_payload_bytes: usize,
819) -> Result<Vec<u8>, TransportError> {
820 let body = serde_json::to_vec(envelope)
821 .map_err(|error| TransportError::SerializationError(error.to_string()))?;
822 if body.len() > max_payload_bytes {
823 return Err(TransportError::PayloadTooLarge(body.len()));
824 }
825 Ok(body)
826}
827
828fn peer_delivery_url(peer: &RelayPeerConfigV1) -> Result<reqwest::Url, String> {
829 let mut url = reqwest::Url::parse(&peer.endpoint_url)
830 .map_err(|error| format!("invalid peer endpoint_url: {error}"))?;
831 let path = format!("{}/a2a/deliver", url.path().trim_end_matches('/'));
832 url.set_path(&path);
833 url.set_query(None);
834 url.set_fragment(None);
835 Ok(url)
836}
837
838fn envelope_id(body: &[u8]) -> String {
839 format!("envelope:{}", blake3::hash(body).to_hex())
840}
841
842fn elapsed_millis(started_at: Instant) -> u64 {
843 u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
844}
845
846async fn read_error_body(response: reqwest::Response) -> String {
847 let mut stream = response.bytes_stream();
848 let mut body = Vec::new();
849 while let Some(chunk) = stream.next().await {
850 let Ok(chunk) = chunk else {
851 break;
852 };
853 let remaining = MAX_ERROR_BODY_BYTES.saturating_sub(body.len());
854 body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
855 if body.len() == MAX_ERROR_BODY_BYTES {
856 break;
857 }
858 }
859 String::from_utf8_lossy(&body).into_owned()
860}
861
862fn enqueue_permanent_failure(
863 envelope: &TransportEnvelopeV1,
864 body: &[u8],
865 failure: &TransportError,
866 attempts: u8,
867 config: &RemoteTransportConfig,
868) -> crate::core::ocla::types::OclaResult<()> {
869 let peer_id = config.peer_id.as_deref().unwrap_or("legacy");
870 enqueue_permanent_failure_for_peer(
871 envelope,
872 body,
873 failure,
874 attempts,
875 config,
876 peer_id,
877 &config.endpoint_url,
878 )
879}
880
881fn enqueue_permanent_failure_for_peer(
882 envelope: &TransportEnvelopeV1,
883 body: &[u8],
884 failure: &TransportError,
885 attempts: u8,
886 config: &RemoteTransportConfig,
887 peer_id: &str,
888 endpoint_url: &str,
889) -> crate::core::ocla::types::OclaResult<()> {
890 let failed_at = Utc::now().to_rfc3339();
891 let target_agent = envelope.recipient.as_deref().unwrap_or(endpoint_url);
892 let mut error = failure.to_string();
893 if error.len() > MAX_ERROR_BYTES {
894 let mut boundary = MAX_ERROR_BYTES;
895 while !error.is_char_boundary(boundary) {
896 boundary -= 1;
897 }
898 error.truncate(boundary);
899 }
900 let relay_record = envelope.relay_record().ok().flatten();
901 let delivery_id = relay_record
902 .as_ref()
903 .map(|record| record.delivery_id.clone())
904 .or_else(|| envelope.stable_delivery_id().ok())
905 .unwrap_or_else(|| envelope_id(body));
906 let tenant_id = relay_record
907 .as_ref()
908 .map(|record| record.tenant_id.clone())
909 .or_else(|| config.tenant_id.clone())
910 .expect("validated tenant scope");
911 let project_id = relay_record
912 .as_ref()
913 .map(|record| record.project_id.clone())
914 .or_else(|| config.project_id.clone())
915 .expect("validated project scope");
916 crate::core::ocla::health::dead_letter_queue().enqueue(DeadLetter {
917 id: envelope_id(body),
918 peer_id: peer_id.to_string(),
919 delivery_id,
920 tenant_id,
921 project_id,
922 delivery: DeadLetterDelivery::RemoteHttp {
923 endpoint_url: endpoint_url.to_string(),
924 },
925 original_message: String::from_utf8_lossy(body).into_owned(),
926 target_agent: target_agent.to_string(),
927 error,
928 attempts,
929 first_failed_at: failed_at.clone(),
930 last_failed_at: failed_at,
931 })
932}
933
934#[cfg(test)]
935mod tests {
936 use std::collections::HashMap;
937
938 #[cfg(feature = "http-server")]
939 use axum::{Router, http::StatusCode, routing::post};
940
941 use super::*;
942
943 #[tokio::test]
944 async fn relay_peer_mode_has_no_singleton_authority_or_fallback() {
945 let peer = RelayPeerConfigV1 {
946 schema_version: 1,
947 peer_id: "destination".into(),
948 endpoint_url: "https://destination.example".into(),
949 bearer_token: "destination-bearer".into(),
950 channel_key: "destination-channel".into(),
951 origin_public_key: crate::core::a2a::relay::test_origin_public_key("destination"),
952 recipient_id: "recipient".into(),
953 allowed_tenant_ids: vec!["tenant".into()],
954 allowed_project_ids: vec!["project".into()],
955 allowed_content_types: vec![
956 crate::core::a2a_transport::TransportContentType::EvidenceBundle,
957 ],
958 allowed_classifications: vec![lean_ctx_protocol::DataClassification::Internal],
959 max_hops: 4,
960 max_payload_bytes: 1024,
961 retry_count: 0,
962 retry_delay_ms: 0,
963 };
964 let transport = RemoteTransport::for_peer_table(
965 RelayPeerTableV1 {
966 schema_version: 1,
967 peers: vec![peer],
968 },
969 "origin",
970 Duration::from_secs(5),
971 false,
972 )
973 .unwrap();
974 assert!(
975 transport.config.endpoint_url.is_empty(),
976 "{:?}",
977 transport.config.endpoint_url
978 );
979 assert!(transport.config.auth_token.is_none());
980 assert!(transport.config.signing_key.is_none());
981 assert!(transport.config.recipient_id.is_none());
982 assert!(transport.config.tenant_id.is_none());
983 assert!(transport.config.project_id.is_none());
984 let restored: RemoteTransport =
985 serde_json::from_str(&serde_json::to_string(&transport).unwrap()).unwrap();
986 restored.config.validate().unwrap();
987 for current in [&transport, &restored] {
988 assert!(matches!(current.deliver(&envelope("payload")).await,
989 Err(TransportError::SerializationError(message))
990 if message == "peer transport requires explicit peer delivery"));
991 }
992 let mut mixed = transport.config.clone();
993 mixed.auth_token = Some("legacy-bearer".into());
994 assert!(RemoteTransport::new(mixed).is_err());
995 let mut incomplete = transport.config.clone();
996 incomplete.local_peer_id = None;
997 assert!(RemoteTransport::new(incomplete).is_err());
998 let mut empty = transport.config.clone();
999 empty.peers.clear();
1000 assert!(RemoteTransport::new(empty).is_err());
1001 let mut malformed = restored;
1002 malformed.config.timeout = Duration::ZERO;
1003 assert!(matches!(
1004 malformed
1005 .deliver_to("destination", &envelope("payload"))
1006 .await,
1007 Err(TransportError::SerializationError(_))
1008 ));
1009 }
1010 use crate::core::a2a_transport::{AgentIdentityV1, TransportContentType};
1011
1012 #[cfg(feature = "http-server")]
1013 #[tokio::test]
1014 async fn relay_clients_do_not_follow_redirects_after_deserialization() {
1015 use std::sync::{
1016 Arc,
1017 atomic::{AtomicUsize, Ordering},
1018 };
1019 let hits = Arc::new(AtomicUsize::new(0));
1020 let received = hits.clone();
1021 let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1022 let target_url = format!("http://{}/sink", target_listener.local_addr().unwrap());
1023 let target = tokio::spawn(async move {
1024 axum::serve(
1025 target_listener,
1026 Router::new().route(
1027 "/sink",
1028 post(move || {
1029 let received = received.clone();
1030 async move {
1031 received.fetch_add(1, Ordering::SeqCst);
1032 StatusCode::OK
1033 }
1034 }),
1035 ),
1036 )
1037 .await
1038 .unwrap();
1039 });
1040 for status in [
1041 StatusCode::TEMPORARY_REDIRECT,
1042 StatusCode::PERMANENT_REDIRECT,
1043 ] {
1044 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1045 let endpoint = format!("http://{}", listener.local_addr().unwrap());
1046 let location = target_url.clone();
1047 let source = tokio::spawn(async move {
1048 axum::serve(
1049 listener,
1050 Router::new().route(
1051 "/a2a/deliver",
1052 post(move || {
1053 let location = location.clone();
1054 async move { (status, [(axum::http::header::LOCATION, location)]) }
1055 }),
1056 ),
1057 )
1058 .await
1059 .unwrap();
1060 });
1061 let original = RemoteTransport::new(RemoteTransportConfig {
1062 endpoint_url: endpoint.clone(),
1063 auth_token: Some("bearer".into()),
1064 signing_key: Some("signing-key".into()),
1065 recipient_id: Some("recipient".into()),
1066 tenant_id: Some("tenant".into()),
1067 project_id: Some("project".into()),
1068 ..RemoteTransportConfig::default()
1069 })
1070 .unwrap();
1071 let restored: RemoteTransport =
1072 serde_json::from_str(&serde_json::to_string(&original).unwrap()).unwrap();
1073 for transport in [original, restored] {
1074 let response = transport
1075 .client
1076 .post(format!("{endpoint}/a2a/deliver"))
1077 .timeout(Duration::from_secs(5))
1078 .body("private context")
1079 .send()
1080 .await
1081 .unwrap();
1082 assert_eq!(response.status(), status);
1083 assert_eq!(hits.load(Ordering::SeqCst), 0);
1084 }
1085 source.abort();
1086 let _ = source.await;
1087 }
1088 target.abort();
1089 let _ = target.await;
1090 }
1091
1092 fn envelope(payload: &str) -> TransportEnvelopeV1 {
1093 TransportEnvelopeV1 {
1094 format_version: 1,
1095 sent_at: Utc::now(),
1096 sender: AgentIdentityV1 {
1097 agent_id: "sender".to_string(),
1098 agent_type: "test".to_string(),
1099 daemon_fingerprint: "fingerprint".to_string(),
1100 capabilities: Vec::new(),
1101 },
1102 recipient: Some("recipient".to_string()),
1103 content_type: TransportContentType::A2AMessage,
1104 payload_json: payload.to_string(),
1105 signature: None,
1106 metadata: HashMap::new(),
1107 }
1108 }
1109
1110 #[test]
1111 fn default_config_has_bounded_transport_values() {
1112 let config = RemoteTransportConfig {
1113 endpoint_url: "https://agent.example/api/".to_string(),
1114 auth_token: Some("secret".to_string()),
1115 signing_key: Some("signing-secret".to_string()),
1116 recipient_id: Some("recipient".to_string()),
1117 tenant_id: Some("tenant-a".to_string()),
1118 project_id: Some("project-a".to_string()),
1119 ..RemoteTransportConfig::default()
1120 };
1121
1122 assert!(config.validate().is_ok());
1123 assert_eq!(config.timeout, Duration::from_secs(30));
1124 assert_eq!(config.max_payload_bytes, 2_000_000);
1125 assert_eq!(config.retry_count, 2);
1126 assert_eq!(config.retry_delay, Duration::from_secs(1));
1127 assert_eq!(
1128 config.delivery_url().expect("valid URL").as_str(),
1129 "https://agent.example/api/a2a/deliver"
1130 );
1131 for peer_id in ["p", "legacy", ""] {
1132 let mut mixed = config.clone();
1133 mixed.peer_id = Some(peer_id.into());
1134 let restored: RemoteTransportConfig =
1135 serde_json::from_str(&serde_json::to_string(&mixed).unwrap()).unwrap();
1136 assert!(restored.validate().unwrap_err().contains("peer_id"));
1137 assert!(RemoteTransport::new(restored).is_err());
1138 }
1139 }
1140
1141 #[cfg(feature = "http-server")]
1142 #[tokio::test]
1143 async fn task_response_is_complete_bounded_utf8_and_explicitly_unverified() {
1144 let mut task = envelope("{}");
1145 task.content_type = TransportContentType::A2ATask;
1146 for size in [1, MAX_TASK_RESPONSE_BYTES] {
1147 let body = "x".repeat(size);
1148 let response = reqwest::Response::from(axum::http::Response::new(body.clone()));
1149 assert_eq!(
1150 read_task_response(response, &task).await.unwrap(),
1151 Some(body)
1152 );
1153 }
1154 for body in [
1155 Vec::new(),
1156 vec![b'x'; MAX_TASK_RESPONSE_BYTES + 1],
1157 vec![0xff],
1158 ] {
1159 let response = reqwest::Response::from(axum::http::Response::new(body));
1160 assert!(read_task_response(response, &task).await.is_err());
1161 }
1162 let response = reqwest::Response::from(axum::http::Response::new(vec![0xff]));
1163 assert_eq!(
1164 read_task_response(response, &envelope("{}")).await.unwrap(),
1165 None
1166 );
1167 }
1168
1169 #[cfg(feature = "http-server")]
1170 #[tokio::test]
1171 async fn interrupted_task_response_is_not_accepted_as_complete() {
1172 let mut task = envelope("{}");
1173 task.content_type = TransportContentType::A2ATask;
1174 let chunks = futures::stream::iter([
1175 Ok::<_, std::io::Error>(vec![b'{']),
1176 Err(std::io::Error::other("interrupted response")),
1177 ]);
1178 let response = reqwest::Response::from(axum::http::Response::new(
1179 reqwest::Body::wrap_stream(chunks),
1180 ));
1181 assert!(read_task_response(response, &task).await.is_err());
1182 }
1183
1184 #[test]
1185 fn config_validation_rejects_unbounded_or_unsupported_values() {
1186 let zero_timeout = RemoteTransportConfig {
1187 endpoint_url: "https://agent.example".to_string(),
1188 timeout: Duration::ZERO,
1189 auth_token: Some("secret".to_string()),
1190 signing_key: Some("signing-secret".to_string()),
1191 ..RemoteTransportConfig::default()
1192 };
1193 let unsupported_scheme = RemoteTransportConfig {
1194 endpoint_url: "file:///tmp/agent".to_string(),
1195 auth_token: Some("secret".to_string()),
1196 signing_key: Some("signing-secret".to_string()),
1197 ..RemoteTransportConfig::default()
1198 };
1199
1200 assert!(zero_timeout.validate().is_err());
1201 assert!(unsupported_scheme.validate().is_err());
1202 let insecure_remote = RemoteTransportConfig {
1203 endpoint_url: "http://agent.example".to_string(),
1204 auth_token: Some("bearer-secret".to_string()),
1205 signing_key: Some("signing-secret".to_string()),
1206 recipient_id: Some("recipient".to_string()),
1207 tenant_id: Some("tenant-a".to_string()),
1208 project_id: Some("project-a".to_string()),
1209 ..RemoteTransportConfig::default()
1210 };
1211 assert_eq!(
1212 insecure_remote.validate(),
1213 Err("endpoint_url must use HTTPS except for loopback hosts".to_string())
1214 );
1215 assert!(
1216 RemoteTransportConfig {
1217 endpoint_url: "http://[::1]:8080".to_string(),
1218 ..insecure_remote
1219 }
1220 .validate()
1221 .is_ok()
1222 );
1223 assert!(
1224 RemoteTransportConfig {
1225 endpoint_url: "https://user:secret@agent.example".to_string(),
1226 auth_token: Some("secret".to_string()),
1227 signing_key: Some("signing-secret".to_string()),
1228 recipient_id: Some("recipient".to_string()),
1229 tenant_id: Some("tenant-a".to_string()),
1230 project_id: Some("project-a".to_string()),
1231 ..RemoteTransportConfig::default()
1232 }
1233 .validate()
1234 .is_err()
1235 );
1236 assert!(
1237 RemoteTransportConfig {
1238 endpoint_url: "https://agent.example".to_string(),
1239 ..RemoteTransportConfig::default()
1240 }
1241 .validate()
1242 .is_err()
1243 );
1244 assert_eq!(
1245 RemoteTransportConfig {
1246 endpoint_url: "https://agent.example".to_string(),
1247 auth_token: Some("same-secret".to_string()),
1248 signing_key: Some("same-secret".to_string()),
1249 ..RemoteTransportConfig::default()
1250 }
1251 .validate(),
1252 Err("signing_key must be distinct from auth_token".to_string())
1253 );
1254 }
1255
1256 #[tokio::test]
1257 async fn delivery_rejects_recipient_outside_configured_authority() {
1258 let transport = RemoteTransport::new(RemoteTransportConfig {
1259 endpoint_url: "http://127.0.0.1:9".to_string(),
1260 auth_token: Some("bearer-secret".to_string()),
1261 signing_key: Some("signing-secret".to_string()),
1262 recipient_id: Some("different-recipient".to_string()),
1263 tenant_id: Some("tenant-a".to_string()),
1264 project_id: Some("project-a".to_string()),
1265 retry_count: 0,
1266 ..RemoteTransportConfig::default()
1267 })
1268 .expect("valid transport config");
1269
1270 assert_eq!(
1271 transport.deliver(&envelope("payload")).await,
1272 Err(TransportError::SerializationError(
1273 "envelope recipient does not match configured remote recipient".to_string()
1274 ))
1275 );
1276 }
1277
1278 #[cfg(feature = "http-server")]
1279 #[tokio::test]
1280 async fn remote_retry_refreshes_timestamp_and_signature_for_authority_receiver() {
1281 let signing_key = "retry-signing-secret";
1282 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1283 let address = listener.local_addr().unwrap();
1284 let app = Router::new().route(
1285 "/a2a/deliver",
1286 post(move |body: axum::body::Bytes| async move {
1287 let Ok(envelope) = serde_json::from_slice::<TransportEnvelopeV1>(&body) else {
1288 return StatusCode::BAD_REQUEST;
1289 };
1290 let age = Utc::now().signed_duration_since(envelope.sent_at);
1291 if age > chrono::Duration::seconds(300)
1292 || age < chrono::Duration::seconds(-30)
1293 || !envelope.verify_signature(signing_key.as_bytes())
1294 {
1295 StatusCode::UNAUTHORIZED
1296 } else {
1297 StatusCode::OK
1298 }
1299 }),
1300 );
1301 let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1302 let transport = RemoteTransport::new(RemoteTransportConfig {
1303 endpoint_url: format!("http://{address}"),
1304 auth_token: Some("bearer-secret".into()),
1305 signing_key: Some(signing_key.into()),
1306 recipient_id: Some("recipient".into()),
1307 tenant_id: Some("tenant-a".into()),
1308 project_id: Some("project-a".into()),
1309 retry_count: 0,
1310 ..RemoteTransportConfig::default()
1311 })
1312 .unwrap();
1313 let mut stale = envelope("payload");
1314 stale.sent_at = Utc::now() - chrono::Duration::minutes(10);
1315 stale.sign(signing_key.as_bytes()).unwrap();
1316 let letter = DeadLetter {
1317 id: "stale-letter".into(),
1318 peer_id: "legacy".into(),
1319 delivery_id: stale.stable_delivery_id().unwrap(),
1320 tenant_id: "tenant-a".into(),
1321 project_id: "project-a".into(),
1322 delivery: DeadLetterDelivery::RemoteHttp {
1323 endpoint_url: format!("http://{address}"),
1324 },
1325 original_message: serde_json::to_string(&stale).unwrap(),
1326 target_agent: "recipient".into(),
1327 error: "previous failure".into(),
1328 attempts: 1,
1329 first_failed_at: Utc::now().to_rfc3339(),
1330 last_failed_at: Utc::now().to_rfc3339(),
1331 };
1332
1333 assert_eq!(
1334 transport
1335 .retry_dead_letter(&letter)
1336 .await
1337 .unwrap()
1338 .remote_status,
1339 StatusCode::OK.as_u16()
1340 );
1341 server.abort();
1342 }
1343
1344 #[cfg(feature = "http-server")]
1345 #[tokio::test]
1346 async fn permanent_remote_failure_is_persisted_with_exact_scope() {
1347 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1348 let address = listener.local_addr().unwrap();
1349 let app = Router::new().route(
1350 "/a2a/deliver",
1351 post(|| async { (StatusCode::BAD_REQUEST, "scope denied") }),
1352 );
1353 let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1354 let suffix = uuid::Uuid::new_v4().to_string();
1355 let tenant_id = format!("tenant-{suffix}");
1356 let project_id = format!("project-{suffix}");
1357 let scope = crate::core::a2a::dlq::DlqScope::new(&tenant_id, &project_id).unwrap();
1358 let transport = RemoteTransport::new(RemoteTransportConfig {
1359 endpoint_url: format!("http://{address}"),
1360 auth_token: Some("bearer-secret".into()),
1361 signing_key: Some("signing-secret".into()),
1362 recipient_id: Some("recipient".into()),
1363 tenant_id: Some(tenant_id),
1364 project_id: Some(project_id),
1365 retry_count: 0,
1366 ..RemoteTransportConfig::default()
1367 })
1368 .unwrap();
1369
1370 assert!(matches!(
1371 transport.deliver(&envelope("payload")).await,
1372 Err(TransportError::RemoteError(400, _))
1373 ));
1374 let entries = crate::core::ocla::health::dead_letter_queue()
1375 .peek(&scope)
1376 .unwrap();
1377 assert_eq!(entries.len(), 1);
1378 assert_eq!(entries[0].target_agent, "recipient");
1379 assert!(matches!(
1380 entries[0].delivery,
1381 DeadLetterDelivery::RemoteHttp { .. }
1382 ));
1383 server.abort();
1384 }
1385
1386 #[test]
1387 fn payload_size_limit_reports_serialized_size() {
1388 let envelope = envelope("payload");
1389 let serialized = serde_json::to_vec(&envelope).expect("serializable envelope");
1390 let error = serialize_and_validate(&envelope, serialized.len() - 1)
1391 .expect_err("payload must exceed configured limit");
1392
1393 assert_eq!(error, TransportError::PayloadTooLarge(serialized.len()));
1394 }
1395
1396 #[test]
1397 fn transport_error_variants_are_serializable_and_distinct() {
1398 let variants = [
1399 TransportError::PayloadTooLarge(10),
1400 TransportError::Timeout,
1401 TransportError::RemoteError(400, "bad request".to_string()),
1402 TransportError::SerializationError("invalid JSON".to_string()),
1403 TransportError::Exhausted(2),
1404 TransportError::DeadLetterFailure("store unavailable".to_string()),
1405 ];
1406
1407 for variant in variants {
1408 let json = serde_json::to_string(&variant).expect("serialize error");
1409 let decoded = serde_json::from_str(&json).expect("deserialize error");
1410 assert_eq!(variant, decoded);
1411 }
1412 }
1413
1414 #[test]
1415 fn receipt_round_trips_without_losing_delivery_fields() {
1416 let receipt = DeliveryReceipt {
1417 envelope_id: "envelope:abc".to_string(),
1418 delivered_at: Utc::now(),
1419 remote_status: 202,
1420 round_trip_ms: 17,
1421 unverified_task_response: None,
1422 };
1423
1424 let json = serde_json::to_string(&receipt).expect("serialize receipt");
1425 let decoded: DeliveryReceipt = serde_json::from_str(&json).expect("deserialize receipt");
1426 assert_eq!(decoded, receipt);
1427 }
1428
1429 #[test]
1430 fn envelope_ids_are_deterministic_and_content_addressed() {
1431 let first = serialize_and_validate(&envelope("one"), usize::MAX).expect("serialize");
1432 let first_again = first.clone();
1433 let second = serialize_and_validate(&envelope("two"), usize::MAX).expect("serialize");
1434
1435 assert_eq!(envelope_id(&first), envelope_id(&first_again));
1436 assert_ne!(envelope_id(&first), envelope_id(&second));
1437 }
1438}