commit ba79ad1f6641b3ec86ffad50c1672f49134e78ab
parent 2dd31d71067eed4281d22bbfbaff5125223fd56f
Author: triesap <tyson@radroots.org>
Date: Mon, 15 Jun 2026 17:21:03 -0700
sdk: add runtime error taxonomy
- Add structured SDK runtime errors for actor authorization, signer mismatch, relay targets, idempotency, order status, and sync setup.
- Route known authority, listing, outbox, relay transport, order status, and sync failures through product-matchable variants.
- Preserve redacted display text with pubkey and digest prefixes plus relay URL userinfo redaction.
- Update runtime and all-features tests to match structured failures without parsing strings.
Diffstat:
9 files changed, 392 insertions(+), 55 deletions(-)
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -13,6 +13,7 @@ pub enum RadrootsSdkRecoveryAction {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsSdkPartialLocalMutationFailure {
OutboxEnqueue,
+ OutboxIdempotencyConflict,
}
#[cfg(feature = "runtime")]
@@ -30,17 +31,80 @@ pub struct RadrootsSdkPartialLocalMutationError {
#[cfg(feature = "runtime")]
#[derive(Debug)]
pub enum RadrootsSdkError {
- Io { path: PathBuf, message: String },
+ Io {
+ path: PathBuf,
+ message: String,
+ },
ClockBeforeUnixEpoch,
- TimestampOutOfRange { value: u64 },
- Authority { message: String },
- EventStore { message: String },
- InvalidRequest { message: String },
- ListingDraft { message: String },
- ListingMutation { message: String },
- Outbox { message: String },
- RelayTransport { message: String },
- Projection { message: String },
+ TimestampOutOfRange {
+ value: u64,
+ },
+ UnauthorizedActor {
+ operation: String,
+ reason: String,
+ },
+ SignerPubkeyMismatch {
+ operation: String,
+ expected_pubkey_prefix: String,
+ signer_pubkey_prefix: String,
+ },
+ EmptyTargetRelays {
+ operation: String,
+ },
+ RelayTargetLimitExceeded {
+ max: usize,
+ actual: usize,
+ },
+ InvalidRelayUrl {
+ url: String,
+ reason: String,
+ },
+ IdempotencyConflict {
+ operation_kind: String,
+ expected_pubkey_prefix: String,
+ existing_digest_prefix: String,
+ new_digest_prefix: String,
+ },
+ OrderStatusLimitInvalid {
+ limit: u32,
+ min: u32,
+ max: u32,
+ },
+ InvalidOrderId {
+ value: String,
+ message: String,
+ },
+ ProductSyncUnsupported {
+ operation: &'static str,
+ required_feature: &'static str,
+ },
+ ProductSyncRelaySetupFailure {
+ message: String,
+ },
+ Authority {
+ message: String,
+ },
+ EventStore {
+ message: String,
+ },
+ InvalidRequest {
+ message: String,
+ },
+ ListingDraft {
+ message: String,
+ },
+ ListingMutation {
+ message: String,
+ },
+ Outbox {
+ message: String,
+ },
+ RelayTransport {
+ message: String,
+ },
+ Projection {
+ message: String,
+ },
PartialLocalMutation(RadrootsSdkPartialLocalMutationError),
}
@@ -65,6 +129,50 @@ impl RadrootsSdkError {
failure: RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue,
})
}
+
+ pub fn partial_outbox_idempotency_conflict_mutation(
+ event_id: impl Into<String>,
+ operation_kind: impl Into<String>,
+ idempotency_digest_prefix: impl Into<String>,
+ ) -> Self {
+ Self::PartialLocalMutation(RadrootsSdkPartialLocalMutationError {
+ event_id: Some(event_id.into()),
+ operation_kind: operation_kind.into(),
+ idempotency_digest_prefix: Some(idempotency_digest_prefix.into()),
+ stored: true,
+ queued: false,
+ recovery: RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey,
+ failure: RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict,
+ })
+ }
+
+ pub(crate) fn empty_target_relays(operation: impl Into<String>) -> Self {
+ Self::EmptyTargetRelays {
+ operation: operation.into(),
+ }
+ }
+
+ pub(crate) fn relay_target_limit_exceeded(max: usize, actual: usize) -> Self {
+ Self::RelayTargetLimitExceeded { max, actual }
+ }
+
+ pub(crate) fn invalid_relay_url(url: impl Into<String>, reason: impl Into<String>) -> Self {
+ Self::InvalidRelayUrl {
+ url: redacted_relay_url(url.into()),
+ reason: reason.into(),
+ }
+ }
+
+ pub(crate) fn order_status_limit_invalid(limit: u32, min: u32, max: u32) -> Self {
+ Self::OrderStatusLimitInvalid { limit, min, max }
+ }
+
+ pub(crate) fn invalid_order_id(value: impl Into<String>, message: impl Into<String>) -> Self {
+ Self::InvalidOrderId {
+ value: value.into(),
+ message: message.into(),
+ }
+ }
}
#[cfg(feature = "runtime")]
@@ -81,6 +189,55 @@ impl fmt::Display for RadrootsSdkError {
"sdk timestamp {value} exceeds Nostr u32 created_at range"
)
}
+ Self::UnauthorizedActor { operation, reason } => {
+ write!(f, "sdk unauthorized actor for {operation}: {reason}")
+ }
+ Self::SignerPubkeyMismatch {
+ operation,
+ expected_pubkey_prefix,
+ signer_pubkey_prefix,
+ } => write!(
+ f,
+ "sdk signer pubkey mismatch for {operation}: expected_pubkey_prefix={expected_pubkey_prefix}, signer_pubkey_prefix={signer_pubkey_prefix}"
+ ),
+ Self::EmptyTargetRelays { operation } => {
+ write!(f, "sdk empty target relays for {operation}")
+ }
+ Self::RelayTargetLimitExceeded { max, actual } => {
+ write!(
+ f,
+ "sdk relay target limit exceeded: max={max}, actual={actual}"
+ )
+ }
+ Self::InvalidRelayUrl { url, reason } => {
+ write!(f, "sdk invalid relay URL `{url}`: {reason}")
+ }
+ Self::IdempotencyConflict {
+ operation_kind,
+ expected_pubkey_prefix,
+ existing_digest_prefix,
+ new_digest_prefix,
+ } => write!(
+ f,
+ "sdk idempotency conflict for {operation_kind}: expected_pubkey_prefix={expected_pubkey_prefix}, existing_digest_prefix={existing_digest_prefix}, new_digest_prefix={new_digest_prefix}"
+ ),
+ Self::OrderStatusLimitInvalid { limit, min, max } => write!(
+ f,
+ "sdk order status limit invalid: limit={limit}, min={min}, max={max}"
+ ),
+ Self::InvalidOrderId { value, message } => {
+ write!(f, "sdk invalid order id `{value}`: {message}")
+ }
+ Self::ProductSyncUnsupported {
+ operation,
+ required_feature,
+ } => write!(
+ f,
+ "sdk product sync operation {operation} requires feature `{required_feature}`"
+ ),
+ Self::ProductSyncRelaySetupFailure { message } => {
+ write!(f, "sdk product sync relay setup failed: {message}")
+ }
Self::Authority { message } => write!(f, "sdk authority error: {message}"),
Self::EventStore { message } => write!(f, "sdk event store error: {message}"),
Self::InvalidRequest { message } => write!(f, "sdk invalid request: {message}"),
@@ -117,8 +274,36 @@ impl std::error::Error for RadrootsSdkError {}
#[cfg(feature = "runtime")]
impl From<radroots_authority::RadrootsAuthorityError> for RadrootsSdkError {
fn from(error: radroots_authority::RadrootsAuthorityError) -> Self {
- Self::Authority {
- message: error.to_string(),
+ match error {
+ radroots_authority::RadrootsAuthorityError::ActorRoleUnsatisfied {
+ contract_id,
+ required_role,
+ } => Self::UnauthorizedActor {
+ operation: contract_id,
+ reason: format!("missing role {required_role:?}"),
+ },
+ radroots_authority::RadrootsAuthorityError::ActorPubkeyMismatch {
+ expected_pubkey,
+ actor_pubkey,
+ } => Self::UnauthorizedActor {
+ operation: "event authorization".to_owned(),
+ reason: format!(
+ "actor_pubkey_prefix={} expected_pubkey_prefix={}",
+ redacted_prefix(actor_pubkey.as_str()),
+ redacted_prefix(expected_pubkey.as_str())
+ ),
+ },
+ radroots_authority::RadrootsAuthorityError::SignerPubkeyMismatch {
+ expected_pubkey,
+ signer_pubkey,
+ } => Self::SignerPubkeyMismatch {
+ operation: "event signing".to_owned(),
+ expected_pubkey_prefix: redacted_prefix(expected_pubkey.as_str()),
+ signer_pubkey_prefix: redacted_prefix(signer_pubkey.as_str()),
+ },
+ error => Self::Authority {
+ message: error.to_string(),
+ },
}
}
}
@@ -135,8 +320,16 @@ impl From<radroots_event_store::RadrootsEventStoreError> for RadrootsSdkError {
#[cfg(feature = "runtime")]
impl From<radroots_trade::listing::RadrootsListingDraftError> for RadrootsSdkError {
fn from(error: radroots_trade::listing::RadrootsListingDraftError) -> Self {
- Self::ListingDraft {
- message: error.to_string(),
+ match error {
+ radroots_trade::listing::RadrootsListingDraftError::ActorRoleUnsatisfied {
+ required_role,
+ } => Self::UnauthorizedActor {
+ operation: "listing.prepare_publish".to_owned(),
+ reason: format!("missing role {required_role:?}"),
+ },
+ error => Self::ListingDraft {
+ message: error.to_string(),
+ },
}
}
}
@@ -153,8 +346,25 @@ impl From<radroots_trade::listing::RadrootsListingMutationError> for RadrootsSdk
#[cfg(feature = "runtime")]
impl From<radroots_outbox::RadrootsOutboxError> for RadrootsSdkError {
fn from(error: radroots_outbox::RadrootsOutboxError) -> Self {
- Self::Outbox {
- message: error.to_string(),
+ match error {
+ radroots_outbox::RadrootsOutboxError::EmptyTargetRelays => {
+ Self::empty_target_relays("outbox enqueue")
+ }
+ radroots_outbox::RadrootsOutboxError::IdempotencyConflict {
+ operation_kind,
+ expected_pubkey,
+ existing_digest,
+ new_digest,
+ ..
+ } => Self::IdempotencyConflict {
+ operation_kind,
+ expected_pubkey_prefix: redacted_prefix(expected_pubkey.as_str()),
+ existing_digest_prefix: redacted_prefix(existing_digest.as_str()),
+ new_digest_prefix: redacted_prefix(new_digest.as_str()),
+ },
+ error => Self::Outbox {
+ message: error.to_string(),
+ },
}
}
}
@@ -162,8 +372,53 @@ impl From<radroots_outbox::RadrootsOutboxError> for RadrootsSdkError {
#[cfg(feature = "runtime")]
impl From<radroots_relay_transport::RadrootsRelayTransportError> for RadrootsSdkError {
fn from(error: radroots_relay_transport::RadrootsRelayTransportError) -> Self {
- Self::RelayTransport {
- message: error.to_string(),
+ match error {
+ radroots_relay_transport::RadrootsRelayTransportError::RelayUrlParse {
+ url,
+ reason,
+ } => Self::invalid_relay_url(url, reason),
+ radroots_relay_transport::RadrootsRelayTransportError::WsRequiresLocalPolicy {
+ url,
+ } => Self::invalid_relay_url(url, "ws relay URL requires localhost policy"),
+ radroots_relay_transport::RadrootsRelayTransportError::UnsupportedRelayScheme {
+ url,
+ scheme,
+ } => Self::invalid_relay_url(url, format!("unsupported scheme `{scheme}`")),
+ radroots_relay_transport::RadrootsRelayTransportError::RelayUrlUserinfo { url } => {
+ Self::invalid_relay_url(url, "relay URL must not include userinfo")
+ }
+ radroots_relay_transport::RadrootsRelayTransportError::EmptyRelayHost { url } => {
+ Self::invalid_relay_url(url, "relay URL must include a host")
+ }
+ radroots_relay_transport::RadrootsRelayTransportError::RelayUrlQueryOrFragment {
+ url,
+ } => Self::invalid_relay_url(url, "relay URL must not include query or fragment"),
+ radroots_relay_transport::RadrootsRelayTransportError::EmptyTargetSet => {
+ Self::empty_target_relays("relay publish")
+ }
+ #[cfg(feature = "runtime")]
+ radroots_relay_transport::RadrootsRelayTransportError::Outbox(error) => error.into(),
+ error => Self::RelayTransport {
+ message: error.to_string(),
+ },
}
}
}
+
+#[cfg(feature = "runtime")]
+fn redacted_prefix(value: &str) -> String {
+ value.chars().take(12).collect()
+}
+
+#[cfg(feature = "runtime")]
+fn redacted_relay_url(value: String) -> String {
+ let Some((scheme, rest)) = value.split_once("://") else {
+ return value;
+ };
+ let authority = rest.split('/').next().unwrap_or(rest);
+ let Some((_, after_userinfo)) = authority.rsplit_once('@') else {
+ return value;
+ };
+ let path = rest.strip_prefix(authority).unwrap_or_default();
+ format!("{scheme}://<redacted>@{after_userinfo}{path}")
+}
diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs
@@ -227,12 +227,23 @@ impl<'sdk> ListingsClient<'sdk> {
._outbox
.enqueue_signed_operation(outbox_input)
.await
- .map_err(|_| {
- RadrootsSdkError::partial_outbox_enqueue_mutation(
- signed_event_id.as_str(),
- LISTING_PUBLISH_OPERATION_KIND,
- partial_failure_digest_prefix.as_str(),
- )
+ .map_err(|error| {
+ if matches!(
+ error,
+ radroots_outbox::RadrootsOutboxError::IdempotencyConflict { .. }
+ ) {
+ RadrootsSdkError::partial_outbox_idempotency_conflict_mutation(
+ signed_event_id.as_str(),
+ LISTING_PUBLISH_OPERATION_KIND,
+ partial_failure_digest_prefix.as_str(),
+ )
+ } else {
+ RadrootsSdkError::partial_outbox_enqueue_mutation(
+ signed_event_id.as_str(),
+ LISTING_PUBLISH_OPERATION_KIND,
+ partial_failure_digest_prefix.as_str(),
+ )
+ }
})?;
let idempotency_digest_prefix = digest_prefix(outbox_receipt.idempotency_digest.as_str());
Ok(ListingEnqueueReceipt {
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -36,9 +36,7 @@ impl OrderStatusRequest {
pub fn parse(order_id: &str) -> Result<Self, RadrootsSdkError> {
RadrootsOrderId::parse(order_id)
.map(Self::new)
- .map_err(|error| RadrootsSdkError::InvalidRequest {
- message: format!("order_id is invalid: {error}"),
- })
+ .map_err(|error| RadrootsSdkError::invalid_order_id(order_id, error.to_string()))
}
pub fn with_limit(mut self, limit: u32) -> Self {
@@ -48,11 +46,11 @@ impl OrderStatusRequest {
fn validate(&self) -> Result<(), RadrootsSdkError> {
if self.limit == 0 || self.limit > ORDER_STATUS_MAX_LIMIT {
- return Err(RadrootsSdkError::InvalidRequest {
- message: format!(
- "order status limit must be between 1 and {ORDER_STATUS_MAX_LIMIT}"
- ),
- });
+ return Err(RadrootsSdkError::order_status_limit_invalid(
+ self.limit,
+ 1,
+ ORDER_STATUS_MAX_LIMIT,
+ ));
}
Ok(())
}
diff --git a/crates/sdk/src/runtime_targets.rs b/crates/sdk/src/runtime_targets.rs
@@ -101,12 +101,15 @@ impl SdkRelayTargetSet {
fn from_normalized_set(normalized: BTreeSet<String>) -> Result<Self, RadrootsSdkError> {
if normalized.is_empty() {
- return Err(invalid_request("relay target set must not be empty"));
+ return Err(RadrootsSdkError::empty_target_relays(
+ "sdk relay target set",
+ ));
}
if normalized.len() > SDK_RELAY_TARGET_MAX_COUNT {
- return Err(invalid_request(format!(
- "relay target set must contain at most {SDK_RELAY_TARGET_MAX_COUNT} relays"
- )));
+ return Err(RadrootsSdkError::relay_target_limit_exceeded(
+ SDK_RELAY_TARGET_MAX_COUNT,
+ normalized.len(),
+ ));
}
Ok(Self {
relays: normalized.into_iter().collect(),
@@ -186,11 +189,11 @@ fn normalized_relay_url(
value: &str,
policy: SdkRelayUrlPolicy,
) -> Result<String, RadrootsSdkError> {
- let relay = RadrootsRelayUrl::parse(value, policy.relay_transport_policy())
- .map_err(|error| invalid_request(format!("invalid relay target: {error}")))?;
+ let relay = RadrootsRelayUrl::parse(value, policy.relay_transport_policy())?;
let normalized = relay.into_string();
if normalized.starts_with("ws://") && !is_local_ws_relay(normalized.as_str()) {
- return Err(invalid_request(
+ return Err(RadrootsSdkError::invalid_relay_url(
+ normalized,
"ws relay targets are limited to localhost, 127.0.0.1, or [::1]",
));
}
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -192,7 +192,7 @@ impl<'sdk> SyncClient<'sdk> {
#[cfg(feature = "relay-runtime")]
{
if self.sdk.relay_urls().is_empty() {
- return Err(RadrootsSdkError::InvalidRequest {
+ return Err(RadrootsSdkError::ProductSyncRelaySetupFailure {
message: "sync push requires configured relay URLs".to_owned(),
});
}
@@ -204,8 +204,9 @@ impl<'sdk> SyncClient<'sdk> {
#[cfg(not(feature = "relay-runtime"))]
{
let _ = request;
- Err(RadrootsSdkError::RelayTransport {
- message: "sync push requires the relay-runtime feature".to_owned(),
+ Err(RadrootsSdkError::ProductSyncUnsupported {
+ operation: "sync.push_outbox",
+ required_feature: "relay-runtime",
})
}
}
diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs
@@ -19,8 +19,8 @@ use radroots_events::{
use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState};
use radroots_sdk::{
ListingEnqueuePublishRequest, ListingPreparePublishRequest, RadrootsSdk, RadrootsSdkError,
- RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkMutationState, SdkRelayTargetPolicy,
- SdkRelayTargetSet, SdkRelayUrlPolicy,
+ RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp,
+ SdkMutationState, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy,
};
const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -213,7 +213,7 @@ async fn prepare_publish_rejects_non_seller_actor() {
.prepare_publish(request)
.expect_err("non seller");
- assert!(matches!(error, RadrootsSdkError::ListingDraft { .. }));
+ assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. }));
}
#[tokio::test]
@@ -288,7 +288,10 @@ async fn enqueue_publish_returns_sanitized_signer_errors() {
.expect_err("signer error");
let message = error.to_string();
- assert!(matches!(error, RadrootsSdkError::Authority { .. }));
+ assert!(matches!(
+ error,
+ RadrootsSdkError::SignerPubkeyMismatch { .. }
+ ));
assert!(!message.contains("raw"));
assert!(!message.contains("ffff"));
}
@@ -329,6 +332,7 @@ async fn enqueue_publish_reports_partial_local_mutation_after_outbox_conflict()
&& partial.event_id.is_some()
&& partial.operation_kind == "listing.publish.v1"
&& partial.idempotency_digest_prefix.is_some()
+ && partial.failure == RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict
&& partial.recovery == RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey
));
assert!(!error.to_string().contains("idem-d"));
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -229,15 +229,29 @@ async fn order_status_rejects_invalid_limits_before_querying() {
.await
.expect_err("too large");
- assert!(matches!(zero, RadrootsSdkError::InvalidRequest { .. }));
- assert!(matches!(too_large, RadrootsSdkError::InvalidRequest { .. }));
+ assert!(matches!(
+ zero,
+ RadrootsSdkError::OrderStatusLimitInvalid {
+ limit: 0,
+ min: 1,
+ max: ORDER_STATUS_MAX_LIMIT
+ }
+ ));
+ assert!(matches!(
+ too_large,
+ RadrootsSdkError::OrderStatusLimitInvalid {
+ limit,
+ min: 1,
+ max: ORDER_STATUS_MAX_LIMIT
+ } if limit == ORDER_STATUS_MAX_LIMIT + 1
+ ));
}
#[test]
fn order_status_parse_rejects_invalid_order_ids() {
let error = OrderStatusRequest::parse("bad order id").expect_err("invalid order id");
- assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+ assert!(matches!(error, RadrootsSdkError::InvalidOrderId { .. }));
}
#[tokio::test]
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -45,10 +45,24 @@ async fn sdk_builder_rejects_ws_relay_without_localhost_policy() {
assert!(matches!(
result,
- Err(RadrootsSdkError::InvalidRequest { .. })
+ Err(RadrootsSdkError::InvalidRelayUrl { .. })
));
}
+#[test]
+fn invalid_relay_url_errors_redact_userinfo() {
+ let error = SdkRelayTargetSet::new(
+ ["wss://user:password@relay.example.com"],
+ SdkRelayUrlPolicy::Public,
+ )
+ .expect_err("invalid relay");
+ let message = error.to_string();
+
+ assert!(matches!(error, RadrootsSdkError::InvalidRelayUrl { .. }));
+ assert!(message.contains("<redacted>@relay.example.com"));
+ assert!(!message.contains("password"));
+}
+
#[tokio::test]
async fn sdk_builder_allows_only_local_ws_targets_with_localhost_policy() {
let sdk = RadrootsSdk::builder()
@@ -70,7 +84,7 @@ async fn sdk_builder_allows_only_local_ws_targets_with_localhost_policy() {
assert!(matches!(
result,
- Err(RadrootsSdkError::InvalidRequest { .. })
+ Err(RadrootsSdkError::InvalidRelayUrl { .. })
));
}
@@ -166,7 +180,7 @@ fn relay_target_set_validates_normalizes_dedupes_sorts_and_caps() {
assert!(matches!(
SdkRelayTargetSet::new(Vec::<String>::new(), SdkRelayUrlPolicy::Public),
- Err(RadrootsSdkError::InvalidRequest { .. })
+ Err(RadrootsSdkError::EmptyTargetRelays { .. })
));
let too_many = (0..=SDK_RELAY_TARGET_MAX_COUNT)
@@ -174,7 +188,10 @@ fn relay_target_set_validates_normalizes_dedupes_sorts_and_caps() {
.collect::<Vec<_>>();
assert!(matches!(
SdkRelayTargetSet::new(too_many, SdkRelayUrlPolicy::Public),
- Err(RadrootsSdkError::InvalidRequest { .. })
+ Err(RadrootsSdkError::RelayTargetLimitExceeded {
+ max: SDK_RELAY_TARGET_MAX_COUNT,
+ actual
+ }) if actual == SDK_RELAY_TARGET_MAX_COUNT + 1
));
}
@@ -199,3 +216,31 @@ fn idempotency_key_validation_is_bounded_and_debug_redacted() {
Err(RadrootsSdkError::InvalidRequest { .. })
));
}
+
+#[test]
+fn outbox_idempotency_conflict_maps_to_structured_sdk_error() {
+ let error = RadrootsSdkError::from(radroots_outbox::RadrootsOutboxError::IdempotencyConflict {
+ operation_kind: "listing.publish.v1".to_owned(),
+ expected_pubkey: "a".repeat(64),
+ idempotency_key: "secret-idempotency-key".to_owned(),
+ existing_digest: "b".repeat(64),
+ new_digest: "c".repeat(64),
+ });
+ let message = error.to_string();
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::IdempotencyConflict {
+ operation_kind,
+ expected_pubkey_prefix,
+ existing_digest_prefix,
+ new_digest_prefix,
+ } if operation_kind == "listing.publish.v1"
+ && expected_pubkey_prefix == "aaaaaaaaaaaa"
+ && existing_digest_prefix == "bbbbbbbbbbbb"
+ && new_digest_prefix == "cccccccccccc"
+ ));
+ assert!(!message.contains("secret-idempotency-key"));
+ assert!(!message.contains(&"b".repeat(64)));
+ assert!(!message.contains(&"c".repeat(64)));
+}
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -221,7 +221,10 @@ async fn product_push_outbox_without_relay_runtime_returns_structured_error() {
.await
.expect_err("unsupported product push");
- assert!(matches!(error, RadrootsSdkError::RelayTransport { .. }));
+ assert!(matches!(
+ error,
+ RadrootsSdkError::ProductSyncUnsupported { .. }
+ ));
}
#[cfg(feature = "relay-runtime")]
@@ -250,7 +253,10 @@ async fn product_push_outbox_requires_configured_relays() {
.await
.expect_err("missing configured relays");
- assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+ assert!(matches!(
+ error,
+ RadrootsSdkError::ProductSyncRelaySetupFailure { .. }
+ ));
}
#[tokio::test]