lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
Mcrates/sdk/src/error.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/src/sync_runtime.rs | 46++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/src/transport.rs | 9+++++++++
Mcrates/sdk/tests/runtime_foundation.rs | 24+++++++++++++++++++++++-
Mcrates/sdk/tests/sync_runtime.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/unit/error_tests.rs | 11+++++++++++
Mcrates/sdk/tests/unit/transport_tests.rs | 30++++++++++++++++++++++++++++--
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()),