commit 2de5fda8382b4548f960f12e5b85de980677958d
parent 6624524c834a20482e5aed7153c9f727f40a0c00
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:
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)