sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 630651c55900455d1c8acd3aa671dd919bfb45f8
parent ad2543821b705477956180e7600d05958069c2ee
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 03:40:44 +0000

transport: return proxy error receipts

- validate reordered explicit target outcomes through the protocol model
- remove the SDK-local synthetic proxy transport job path
- return retryable SDK receipts for proxy transport failures
- cover adapter and sync-runtime proxy receipt behavior

Diffstat:
Mcrates/sdk/src/adapters/radrootsd.rs | 22+---------------------
Mcrates/sdk/src/sync_runtime.rs | 80++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/sdk/tests/sync_runtime.rs | 13++++++++++++-
Mcrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 41++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 45+++++++++++++++++++++++++++++++++++----------
5 files changed, 135 insertions(+), 66 deletions(-)

diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -5,7 +5,7 @@ use radroots_events::draft::RadrootsSignedNostrEvent; use radroots_transport_publish_protocol::{ METHOD_EVENT, SignedNostrEventWire, TransportPublishDeliveryPolicy, TransportPublishEventRequest, TransportPublishEventResponse, TransportPublishProtocolError, - TransportPublishTarget, TransportPublishTargetOutcome, TransportPublishTargetPolicy, + TransportPublishTargetPolicy, }; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -335,9 +335,6 @@ fn validate_transport_publish_response_for_request( if response.job.target_policy != request.target_policy { return Err(response_mismatch("target_policy")); } - if let TransportPublishTargetPolicy::ExplicitTargets { targets } = &request.target_policy { - validate_explicit_response_targets(targets, response.job.targets.as_slice())?; - } Ok(()) } @@ -347,23 +344,6 @@ fn response_mismatch(field: &str) -> RadrootsdError { )) } -fn validate_explicit_response_targets( - request_targets: &[TransportPublishTarget], - response_targets: &[TransportPublishTargetOutcome], -) -> Result<(), RadrootsdError> { - if request_targets.len() != response_targets.len() { - return Err(response_mismatch("explicit_targets")); - } - for (request_target, response_target) in request_targets.iter().zip(response_targets) { - if response_target.transport_kind != request_target.transport_kind - || response_target.endpoint_uri != request_target.endpoint_uri - { - return Err(response_mismatch("explicit_targets")); - } - } - Ok(()) -} - #[cfg(test)] #[path = "../../tests/unit/adapters_radrootsd_tests.rs"] mod tests; diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -688,7 +688,7 @@ impl<'sdk> SyncClient<'sdk> { publish_now_ms, ) .await?; - receipt.push_event(push_proxy_event_receipt(claimed.outbox_event_id, publish)?); + receipt.push_event(publish); } Ok(receipt) } @@ -770,7 +770,7 @@ async fn push_proxy_claimed_outbox_event( claimed: &RadrootsOutboxClaimedEvent, next_attempt_delay_ms: i64, now_ms: i64, -) -> Result<TransportPublishJobView, RadrootsSdkError> { +) -> Result<PushOutboxEventReceipt, RadrootsSdkError> { let signed_event = claimed.signed_event.clone().ok_or( radroots_transport_nostr::RadrootsRelayTransportError::MissingSignedOutboxEvent( claimed.outbox_event_id, @@ -799,7 +799,7 @@ async fn push_proxy_claimed_outbox_event( .await?; let request = RadrootsdProxyPublishRequest { signed_event: signed_event.clone(), - delivery_policy, + delivery_policy: delivery_policy.clone(), target_policy, idempotency_key: Some(proxy_outbox_idempotency_key( claimed.outbox_event_id, @@ -823,11 +823,16 @@ async fn push_proxy_claimed_outbox_event( now_ms, ) .await?; - return Ok(proxy_transport_error_job(&signed_event)); + return proxy_transport_error_receipt( + claimed, + &signed_event, + &delivery_policy, + message, + ); } }; complete_proxy_publish_attempt(sync, claimed, &publish, next_attempt_delay_ms, now_ms).await?; - Ok(publish) + push_proxy_event_receipt(claimed.outbox_event_id, publish) } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] @@ -836,7 +841,7 @@ async fn fail_proxy_local_validation( claimed: &RadrootsOutboxClaimedEvent, error: RadrootsSdkError, now_ms: i64, -) -> Result<TransportPublishJobView, RadrootsSdkError> { +) -> Result<PushOutboxEventReceipt, RadrootsSdkError> { let message = error.to_string(); sync.sdk ._outbox @@ -1211,36 +1216,45 @@ async fn complete_missing_proxy_delivery_target( } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] -fn proxy_transport_error_job( - event: &radroots_events::draft::RadrootsSignedNostrEvent, -) -> TransportPublishJobView { - TransportPublishJobView { - job_id: "radroots-sdk-transport-error".to_owned(), - status: TransportPublishJobStatus::DeliveryUnsatisfiedRetryable, - terminal: false, - delivery_satisfied: false, - event_id: event.id.clone(), - pubkey: event.pubkey.clone(), - event_kind: event.kind, - target_policy: TransportPublishTargetPolicy::nostr( - NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, - Vec::new(), - ), - delivery_policy: TransportPublishDeliveryPolicy::Any, - target_count: 1, - acknowledged_count: 0, - retryable_count: 1, - terminal_count: 0, - requested_at_ms: 0, - completed_at_ms: None, - last_error: Some("radrootsd proxy publish failed".to_owned()), - targets: Vec::new(), - } +fn proxy_error_message(error: &RadrootsdError) -> String { + format!("radrootsd proxy publish failed: {error}") } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] -fn proxy_error_message(error: &RadrootsdError) -> String { - format!("radrootsd proxy publish failed: {error}") +fn proxy_transport_error_receipt( + claimed: &RadrootsOutboxClaimedEvent, + event: &radroots_events::draft::RadrootsSignedNostrEvent, + delivery_policy: &TransportPublishDeliveryPolicy, + message: String, +) -> Result<PushOutboxEventReceipt, RadrootsSdkError> { + let ready_targets = claimed + .delivery_targets + .iter() + .filter(|target| target.status.is_ready_for_attempt()) + .collect::<Vec<_>>(); + let target_count = ready_targets.len(); + let event_id = push_receipt_event_id(event.id.as_str(), "proxy transport failure event id")?; + Ok(PushOutboxEventReceipt { + event_id, + outbox_event_id: claimed.outbox_event_id, + final_state: PushOutboxEventState::PublishRetryable, + attempted_count: 0, + accepted_count: 0, + retryable_count: target_count, + terminal_count: 0, + quorum: delivery_policy.required_target_count(target_count), + quorum_met: false, + targets: ready_targets + .into_iter() + .map(|target| PushOutboxTargetReceipt { + transport_kind: target.transport_kind.canonical_label(), + endpoint_uri: target.endpoint_uri.as_str().to_owned(), + outcome_kind: PushOutboxTargetOutcomeKind::ConnectionFailed, + attempted: false, + message: Some(message.clone()), + }) + .collect(), + }) } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -1817,7 +1817,18 @@ async fn product_push_outbox_radrootsd_proxy_error_and_terminal_paths_update_out retryable.events[0].final_state, PushOutboxEventState::PublishRetryable ); - assert!(retryable.events[0].targets.is_empty()); + assert_eq!(retryable.events[0].targets.len(), 1); + assert_eq!( + retryable.events[0].targets[0].outcome_kind, + PushOutboxTargetOutcomeKind::ConnectionFailed + ); + assert!(!retryable.events[0].targets[0].attempted); + assert!( + retryable.events[0].targets[0] + .message + .as_deref() + .is_some_and(|error| error.contains("radrootsd proxy publish failed")) + ); let retryable_status = retryable_sdk .sync() .status(SyncStatusRequest::new()) diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs @@ -673,6 +673,45 @@ async fn publish_signed_event_rejects_mismatched_explicit_target_response() { } #[tokio::test] +async fn publish_signed_event_accepts_reordered_explicit_target_outcomes() { + let mut response_job = explicit_nostr_job( + vec![ + "wss://relay-a.example.com".to_owned(), + "wss://relay-b.example.com".to_owned(), + ], + TransportPublishDeliveryPolicy::Any, + ); + response_job.targets.reverse(); + let response_json = publish_response_json_for_job(response_job); + let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); + let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint)); + + let response = adapter + .publish_signed_event(RadrootsdProxyPublishRequest { + signed_event: signed_event(), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay-a.example.com"), + TransportPublishTarget::nostr("wss://relay-b.example.com"), + ]), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: Some("idem-explicit-reordered-outcomes".to_owned()), + timeout_ms: None, + }) + .await + .expect("reordered explicit target outcomes"); + + assert!(response.job.delivery_satisfied); + assert_eq!( + response.job.targets[0].endpoint_uri, + "wss://relay-b.example.com" + ); + assert_eq!( + response.job.targets[1].endpoint_uri, + "wss://relay-a.example.com" + ); +} + +#[tokio::test] async fn publish_signed_event_rejects_mismatched_explicit_target_outcomes() { let mut response_job = explicit_nostr_job( vec!["wss://relay.example.com".to_owned()], @@ -697,7 +736,7 @@ async fn publish_signed_event_rejects_mismatched_explicit_target_outcomes() { .expect_err("explicit target outcome mismatch"); assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "explicit_targets"); + assert_message(error, "explicit target policy"); } #[tokio::test] diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -1,7 +1,7 @@ #[cfg(feature = "radrootsd-proxy")] use super::{ CLAIM_OWNER, complete_proxy_publish_attempt, proxy_delivery_policy_from_remaining, - proxy_error_message, proxy_outbox_idempotency_key, proxy_transport_error_job, + proxy_error_message, proxy_outbox_idempotency_key, proxy_transport_error_receipt, push_proxy_claimed_outbox_event, push_proxy_event_receipt, transport_publish_target_from_outbox_target, }; @@ -702,15 +702,27 @@ async fn proxy_push_empty_queue_and_private_helpers_are_deterministic() { "radroots-sdk-outbox-7-3-event-id-5" ); - let signed_event = ProxyFixtureSigner::new() - .sign_frozen_draft(&proxy_frozen_draft("proxy-transport-error-job")) - .expect("signed event"); - let proxy_job = proxy_transport_error_job(&signed_event); - assert_eq!(proxy_job.event_id, signed_event.id); - assert_eq!(proxy_job.target_count, 1); - assert_eq!(proxy_job.retryable_count, 1); - assert!(!proxy_job.delivery_satisfied); - assert!(proxy_job.targets.is_empty()); + let (_sdk, claimed) = claimed_proxy_event("proxy-transport-error-receipt").await; + let signed_event = claimed.signed_event.as_ref().expect("signed event"); + let message = proxy_error_message(&RadrootsdError::Http("connection refused".to_owned())); + let receipt = proxy_transport_error_receipt( + &claimed, + signed_event, + &TransportPublishDeliveryPolicy::All, + message.clone(), + ) + .expect("proxy transport error receipt"); + assert_eq!(receipt.event_id, signed_event.id); + assert_eq!(receipt.final_state, PushOutboxEventState::PublishRetryable); + assert_eq!(receipt.retryable_count, 1); + assert!(!receipt.quorum_met); + assert_eq!(receipt.targets.len(), 1); + assert_eq!( + receipt.targets[0].outcome_kind, + PushOutboxTargetOutcomeKind::ConnectionFailed + ); + assert!(!receipt.targets[0].attempted); + assert_eq!(receipt.targets[0].message.as_ref(), Some(&message)); assert_eq!( proxy_error_message(&RadrootsdError::Http("connection refused".to_owned())), "radrootsd proxy publish failed: connection refused" @@ -868,6 +880,19 @@ async fn proxy_claim_publish_marks_retryable_transport_errors() { .expect("transport error job"); assert_eq!(receipt.retryable_count, 1); + assert_eq!(receipt.final_state, PushOutboxEventState::PublishRetryable); + assert_eq!(receipt.targets.len(), 1); + assert_eq!( + receipt.targets[0].outcome_kind, + PushOutboxTargetOutcomeKind::ConnectionFailed + ); + assert!(!receipt.targets[0].attempted); + assert!( + receipt.targets[0] + .message + .as_deref() + .is_some_and(|message| message.contains("radrootsd proxy publish failed")) + ); let stored = sdk ._outbox .get_event(claimed.outbox_event_id)