commit 4c33070bfc26e31db05cd0ae0bd058ba02755dbe
parent b46e2a378ae90c25a0a6525d2c10ee0a6ab225ca
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Jul 2026 06:11:12 +0000
transport: specialize Reticulum preview push errors
- add structured Reticulum preview unavailable and deferred SDK error codes
- keep Reticulum-only push empty queues successful while rejecting deferred preview work explicitly
- expose Reticulum preview behavior names for typed status and error details
- cover Reticulum endpoint, error metadata, and product push behavior in SDK tests
Diffstat:
7 files changed, 270 insertions(+), 8 deletions(-)
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -4,6 +4,8 @@ use std::{fmt, path::PathBuf};
#[cfg(feature = "runtime")]
use crate::privacy::{PrivacyPreflightStatus, ProductSensitivityField};
#[cfg(feature = "runtime")]
+use crate::transport::ReticulumPreviewBehavior;
+#[cfg(feature = "runtime")]
use radroots_trade::identity::RadrootsTradeLocator;
#[cfg(feature = "runtime")]
use serde_json::{Value, json};
@@ -166,6 +168,11 @@ pub enum RadrootsSdkError {
operation: &'static str,
required_feature: &'static str,
},
+ ReticulumPreviewTransportUnavailable {
+ operation: String,
+ endpoint_uri: String,
+ behavior: ReticulumPreviewBehavior,
+ },
ProductSyncTransportSetupFailure {
message: String,
},
@@ -228,6 +235,14 @@ impl RadrootsSdkError {
Self::TradeAmbiguous { .. } => "trade_ambiguous",
Self::PrivacyPreflight { .. } => "privacy_preflight",
Self::ProductSyncUnsupported { .. } => "product_sync_unsupported",
+ Self::ReticulumPreviewTransportUnavailable { behavior, .. } => match behavior {
+ ReticulumPreviewBehavior::RejectDeliveryAttempts => {
+ "reticulum_preview_transport_unavailable"
+ }
+ ReticulumPreviewBehavior::DeferDeliveryPlans => {
+ "reticulum_preview_transport_deferred"
+ }
+ },
Self::ProductSyncTransportSetupFailure { .. } => "product_sync_transport_setup_failure",
Self::Authority { .. } => "authority",
Self::EventStore { .. } => "event_store",
@@ -287,7 +302,10 @@ impl RadrootsSdkError {
| Self::InvalidRequest { .. }
| Self::ListingDraft { .. }
| Self::ListingMutation { .. } => RadrootsSdkErrorClass::Request,
- Self::ProductSyncUnsupported { .. } => RadrootsSdkErrorClass::Unsupported,
+ Self::ProductSyncUnsupported { .. }
+ | Self::ReticulumPreviewTransportUnavailable { .. } => {
+ RadrootsSdkErrorClass::Unsupported
+ }
Self::ProductSyncTransportSetupFailure { .. }
| Self::Transport { .. }
| Self::SignerRequestTimedOut { .. }
@@ -357,6 +375,9 @@ impl RadrootsSdkError {
Self::ProductSyncUnsupported { .. } => {
vec![RadrootsSdkRecoveryAction::EnableRequiredFeature]
}
+ Self::ReticulumPreviewTransportUnavailable { .. } => {
+ vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets]
+ }
Self::ProductSyncTransportSetupFailure { .. } | Self::Transport { .. } => {
vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure]
}
@@ -454,6 +475,15 @@ impl RadrootsSdkError {
operation,
required_feature,
} => json!({ "operation": operation, "required_feature": required_feature }),
+ Self::ReticulumPreviewTransportUnavailable {
+ operation,
+ endpoint_uri,
+ behavior,
+ } => json!({
+ "operation": operation,
+ "endpoint_uri": endpoint_uri,
+ "behavior": behavior.as_str()
+ }),
Self::ProductSyncTransportSetupFailure { message }
| Self::Authority { message }
| Self::EventStore { message }
@@ -530,6 +560,18 @@ impl RadrootsSdkError {
}
}
+ pub(crate) fn reticulum_preview_transport_unavailable(
+ operation: impl Into<String>,
+ endpoint_uri: impl Into<String>,
+ behavior: ReticulumPreviewBehavior,
+ ) -> Self {
+ Self::ReticulumPreviewTransportUnavailable {
+ operation: operation.into(),
+ endpoint_uri: endpoint_uri.into(),
+ behavior,
+ }
+ }
+
pub(crate) fn trade_status_limit_invalid(limit: u32, min: u32, max: u32) -> Self {
Self::TradeStatusLimitInvalid { limit, min, max }
}
@@ -655,6 +697,15 @@ impl fmt::Display for RadrootsSdkError {
f,
"sdk product sync operation {operation} requires feature `{required_feature}`"
),
+ Self::ReticulumPreviewTransportUnavailable {
+ operation,
+ endpoint_uri,
+ behavior,
+ } => write!(
+ f,
+ "sdk product sync operation {operation} cannot deliver through Reticulum preview endpoint `{endpoint_uri}` with behavior `{}`",
+ behavior.as_str()
+ ),
Self::ProductSyncTransportSetupFailure { message } => {
write!(f, "sdk product sync transport setup failed: {message}")
}
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -525,15 +525,28 @@ impl<'sdk> SyncClient<'sdk> {
RadrootsdProxyPublishAdapter::new(radrootsd_proxy_config_from_profile(profile));
self.push_outbox_with_proxy_adapter(&adapter, request).await
}
- TransportProfile::LocalOnly | TransportProfile::ReticulumPreview { .. } => {
+ TransportProfile::LocalOnly => {
if self.push_outbox_has_no_ready_signed_work(&request).await? {
return Ok(PushOutboxReceipt::default());
}
Err(RadrootsSdkError::ProductSyncUnsupported {
operation: "sync.push_outbox",
- required_feature: "transport profile with Nostr delivery",
+ required_feature: "delivery-capable transport profile",
})
}
+ TransportProfile::ReticulumPreview { profile } => {
+ if self
+ .push_outbox_has_no_reticulum_preview_work(&request)
+ .await?
+ {
+ return Ok(PushOutboxReceipt::default());
+ }
+ Err(RadrootsSdkError::reticulum_preview_transport_unavailable(
+ "sync.push_outbox",
+ profile.endpoint_uri(),
+ profile.behavior(),
+ ))
+ }
}
}
@@ -547,6 +560,35 @@ impl<'sdk> SyncClient<'sdk> {
Ok(summary.ready_signed_events == 0)
}
+ async fn push_outbox_has_no_reticulum_preview_work(
+ &self,
+ request: &PushOutboxRequest,
+ ) -> Result<bool, RadrootsSdkError> {
+ request.validate()?;
+ if let Some(outbox_event_id) = request.outbox_event_id {
+ let Some(event) = self.sdk._outbox.get_event(outbox_event_id).await? else {
+ return Ok(true);
+ };
+ if !matches!(
+ event.state,
+ RadrootsOutboxEventState::Signed | RadrootsOutboxEventState::PublishRetryable
+ ) || event.signed_event.is_none()
+ {
+ return Ok(true);
+ }
+ let targets = self.sdk._outbox.delivery_targets(outbox_event_id).await?;
+ return Ok(!targets.iter().any(|target| {
+ target.transport_kind == RadrootsTransportKind::Reticulum
+ && (target.status.is_deferred_preview() || target.status.is_ready_for_attempt())
+ }));
+ }
+ let now_ms = sdk_now_ms(self.sdk)?;
+ let summary = self.sdk._outbox.status_summary(now_ms).await?;
+ Ok(summary.ready_signed_events == 0
+ && summary.pending_events == 0
+ && summary.retryable_events == 0)
+ }
+
pub async fn push_outbox_with_adapter<A>(
&self,
adapter: &A,
diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs
@@ -336,6 +336,15 @@ pub enum ReticulumPreviewBehavior {
DeferDeliveryPlans,
}
+impl ReticulumPreviewBehavior {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::RejectDeliveryAttempts => "reject_delivery_attempts",
+ Self::DeferDeliveryPlans => "defer_delivery_plans",
+ }
+ }
+}
+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct HybridProfile {
nostr: NostrProfile,
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -5,7 +5,7 @@ use radroots_sdk::{
BackupRequest, IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, NostrProfile,
NostrRelayUrlPolicy, RadrootsClient, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkErrorClass,
RadrootsSdkGeoNamesErrorKind, RadrootsSdkRecoveryAction, RadrootsSdkStorageConfig,
- RadrootsSdkTimestamp, RestoreRequest, SDK_IDEMPOTENCY_KEY_MAX_LEN,
+ RadrootsSdkTimestamp, RestoreRequest, ReticulumPreviewBehavior, SDK_IDEMPOTENCY_KEY_MAX_LEN,
SDK_TRANSPORT_TARGET_MAX_COUNT, SdkBackupState, SdkBackupVerification,
SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus,
SdkPrivateStoreStorageStatus, SdkRestoreState, SdkSqliteStoreStatus,
@@ -478,6 +478,28 @@ fn sdk_error_contract_methods_cover_all_variants() {
vec![RadrootsSdkRecoveryAction::EnableRequiredFeature],
),
(
+ RadrootsSdkError::ReticulumPreviewTransportUnavailable {
+ operation: "sync.push_outbox".to_owned(),
+ endpoint_uri: "reticulum:preview-unavailable".to_owned(),
+ behavior: ReticulumPreviewBehavior::RejectDeliveryAttempts,
+ },
+ "reticulum_preview_transport_unavailable",
+ RadrootsSdkErrorClass::Unsupported,
+ false,
+ vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets],
+ ),
+ (
+ RadrootsSdkError::ReticulumPreviewTransportUnavailable {
+ operation: "sync.push_outbox".to_owned(),
+ endpoint_uri: "reticulum:preview-unavailable".to_owned(),
+ behavior: ReticulumPreviewBehavior::DeferDeliveryPlans,
+ },
+ "reticulum_preview_transport_deferred",
+ RadrootsSdkErrorClass::Unsupported,
+ false,
+ vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets],
+ ),
+ (
RadrootsSdkError::ProductSyncTransportSetupFailure {
message: "relay setup".to_owned(),
},
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -25,8 +25,9 @@ use radroots_sdk::{
PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, PUSH_OUTBOX_DEFAULT_LIMIT,
PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt,
PushOutboxEventState, PushOutboxReceipt, PushOutboxRequest, PushOutboxTargetOutcomeKind,
- PushOutboxTargetReceipt, RadrootsClient, RadrootsSdkError, RadrootsSdkTimestamp,
- RestoreRequest, SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRestoreState,
+ PushOutboxTargetReceipt, RadrootsClient, RadrootsSdkError, RadrootsSdkErrorClass,
+ RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, RestoreRequest, ReticulumPreviewBehavior,
+ ReticulumPreviewProfile, SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRestoreState,
StorageStatusRequest, SyncStatusRequest, SyncStatusSource, TargetPolicy, TransportProfile,
};
use radroots_transport_nostr::{
@@ -455,6 +456,21 @@ async fn directory_sdk(relays: &[&str]) -> (tempfile::TempDir, RadrootsClient) {
(tempdir, sdk)
}
+async fn reticulum_preview_directory_sdk(
+ behavior: ReticulumPreviewBehavior,
+) -> (tempfile::TempDir, RadrootsClient) {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let profile = ReticulumPreviewProfile::preview_unavailable().with_behavior(behavior);
+ let sdk = RadrootsClient::builder()
+ .directory_storage(tempdir.path().join("sdk"))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .transport_profile(TransportProfile::reticulum_preview(profile))
+ .build()
+ .await
+ .expect("sdk");
+ (tempdir, sdk)
+}
+
async fn system_clock_directory_sdk(relays: &[&str]) -> (tempfile::TempDir, RadrootsClient) {
let tempdir = tempfile::tempdir().expect("tempdir");
let mut builder = RadrootsClient::builder().directory_storage(tempdir.path().join("sdk"));
@@ -1840,6 +1856,91 @@ async fn product_push_outbox_empty_queue_does_not_require_builder_relays() {
}
#[tokio::test]
+async fn sync_runtime_product_push_outbox_reticulum_preview_reports_specific_ready_work_error() {
+ let cases = [
+ (
+ ReticulumPreviewBehavior::RejectDeliveryAttempts,
+ "reticulum_preview_transport_unavailable",
+ ),
+ (
+ ReticulumPreviewBehavior::DeferDeliveryPlans,
+ "reticulum_preview_transport_deferred",
+ ),
+ ];
+
+ for (behavior, expected_code) in cases {
+ let (_tempdir, sdk) = reticulum_preview_directory_sdk(behavior).await;
+ let empty = sdk
+ .sync()
+ .push_outbox(PushOutboxRequest::new())
+ .await
+ .expect("empty Reticulum preview push");
+ assert_eq!(empty.attempted_events, 0);
+ assert!(empty.events.is_empty());
+
+ let enqueue = sdk
+ .listings()
+ .enqueue_publish_with_explicit_signer(
+ ListingEnqueuePublishRequest::new(
+ actor(),
+ listing(LISTING_A_D_TAG, "Reticulum Preview Coffee"),
+ TargetPolicy::use_transport_profile(),
+ ),
+ &FixtureSigner::new(SELLER),
+ )
+ .await
+ .expect("enqueue");
+
+ let error = sdk
+ .sync()
+ .push_outbox(PushOutboxRequest::new().with_limit(1))
+ .await
+ .expect_err("Reticulum preview is not delivery-capable");
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::ReticulumPreviewTransportUnavailable {
+ ref operation,
+ ref endpoint_uri,
+ behavior: returned_behavior,
+ } if operation == "sync.push_outbox"
+ && endpoint_uri == "reticulum:preview-unavailable"
+ && returned_behavior == behavior
+ ));
+ assert_eq!(error.code(), expected_code);
+ assert_eq!(error.class(), RadrootsSdkErrorClass::Unsupported);
+ assert!(!error.retryable());
+ assert_eq!(
+ error.recovery_actions(),
+ vec![RadrootsSdkRecoveryAction::ConfigureTransportTargets]
+ );
+ let detail = error.detail_json();
+ assert_eq!(detail["detail"]["operation"], "sync.push_outbox");
+ assert_eq!(
+ detail["detail"]["endpoint_uri"],
+ "reticulum:preview-unavailable"
+ );
+ assert_eq!(detail["detail"]["behavior"], behavior.as_str());
+ assert!(
+ !detail["message"]
+ .as_str()
+ .expect("message")
+ .contains("Nostr")
+ );
+
+ let status = sdk
+ .sync()
+ .status(SyncStatusRequest::new())
+ .await
+ .expect("status");
+ assert_eq!(status.outbox.ready_signed_events, 0);
+ assert_eq!(status.outbox.pending_events, 1);
+ assert_eq!(status.outbox.total_events, 1);
+ assert_eq!(enqueue.outbox_event_id, 1);
+ }
+}
+
+#[tokio::test]
async fn push_outbox_rejects_invalid_limits_before_claiming() {
let (_tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
let adapter = RadrootsMockRelayPublishAdapter::new();
diff --git a/crates/sdk/tests/unit/error_tests.rs b/crates/sdk/tests/unit/error_tests.rs
@@ -3,6 +3,7 @@ use super::{
RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, redacted_relay_url,
};
use crate::privacy::{PrivacyPreflightStatus, ProductSensitivityField};
+use crate::transport::ReticulumPreviewBehavior;
use radroots_authority::RadrootsAuthorityError;
use radroots_events::contract::RadrootsActorRole;
use radroots_geocoder::{GeoNamesAssetFetcher, GeoNamesBlockingHttpFetcher, GeocoderError};
@@ -366,6 +367,16 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() {
operation: "sync.push_outbox",
required_feature: "relay-runtime",
},
+ RadrootsSdkError::reticulum_preview_transport_unavailable(
+ "sync.push_outbox",
+ "reticulum:preview-unavailable",
+ ReticulumPreviewBehavior::RejectDeliveryAttempts,
+ ),
+ RadrootsSdkError::reticulum_preview_transport_unavailable(
+ "sync.push_outbox",
+ "reticulum:preview-unavailable",
+ ReticulumPreviewBehavior::DeferDeliveryPlans,
+ ),
RadrootsSdkError::ProductSyncTransportSetupFailure {
message: "offline".to_owned(),
},
diff --git a/crates/sdk/tests/unit/transport_tests.rs b/crates/sdk/tests/unit/transport_tests.rs
@@ -1,6 +1,6 @@
use super::{
- NostrProfile, NostrRelayUrlPolicy, PublishMode, SatisfactionPolicy, TargetPolicy, TargetSet,
- TransportProfile,
+ NostrProfile, NostrRelayUrlPolicy, PublishMode, ReticulumPreviewBehavior,
+ ReticulumPreviewProfile, SatisfactionPolicy, TargetPolicy, TargetSet, TransportProfile,
};
use crate::{RadrootsSdkError, SDK_TRANSPORT_TARGET_MAX_COUNT};
@@ -171,6 +171,32 @@ fn target_set_accessors_and_configured_relays_cover_empty_and_dedupe_paths() {
}
#[test]
+fn reticulum_preview_profile_uses_canonical_endpoint_and_behavior_names() {
+ let profile = ReticulumPreviewProfile::preview_unavailable();
+
+ assert_eq!(profile.endpoint_uri(), "reticulum:preview-unavailable");
+ assert_eq!(
+ profile.behavior(),
+ ReticulumPreviewBehavior::RejectDeliveryAttempts
+ );
+ assert_eq!(
+ ReticulumPreviewBehavior::RejectDeliveryAttempts.as_str(),
+ "reject_delivery_attempts"
+ );
+ assert_eq!(
+ ReticulumPreviewBehavior::DeferDeliveryPlans.as_str(),
+ "defer_delivery_plans"
+ );
+ assert_eq!(
+ serde_json::to_value(profile).expect("profile json"),
+ serde_json::json!({
+ "endpoint_uri": "reticulum:preview-unavailable",
+ "behavior": "reject_delivery_attempts"
+ })
+ );
+}
+
+#[test]
fn normalized_relays_reject_empty_and_over_limit_sets() {
assert!(matches!(
TargetSet::from_normalized_nostr_relays(Vec::new()),