sdk

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

commit 3c15e9f5bfa247d7f71383c8b0a98166864c6ee6
parent c50cc8d53a6e841838c119d150f469e1d5df677b
Author: triesap <tyson@radroots.org>
Date:   Fri, 19 Jun 2026 15:39:20 -0700

sdk: expose order enqueue retry advice

- add workflow idempotency receipt metadata
- add structured retry advice to order enqueue receipts
- assert replayed enqueue state without string inference
- cover partial mutation recovery detail JSON

Diffstat:
Mcrates/sdk/src/lib.rs | 5+++--
Mcrates/sdk/src/orders_runtime.rs | 40++++++++++++++++++++++++++++++++++++----
Mcrates/sdk/tests/orders_runtime.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/source_boundary.rs | 2++
4 files changed, 128 insertions(+), 8 deletions(-)

diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -78,8 +78,9 @@ pub use crate::orders_runtime::{ OrderRevisionProposalPrepareRequest, OrderRevisionProposalReceipt, OrderSettlementStateKind, OrderStatusEligibility, OrderStatusEvidenceSummary, OrderStatusKind, OrderStatusNextActionKind, OrderStatusReceipt, OrderStatusRequest, OrderSubmitEnqueueRequest, OrderSubmitPlan, - OrderSubmitPrepareRequest, OrderSubmitReceipt, OrderWorkflowEnqueueReceipt, OrderWorkflowKind, - OrderWorkflowPlan, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, + OrderSubmitPrepareRequest, OrderSubmitReceipt, OrderWorkflowEnqueueReceipt, + OrderWorkflowIdempotencyReceipt, OrderWorkflowKind, OrderWorkflowPlan, + OrderWorkflowRetryAdvice, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, }; #[cfg(feature = "runtime")] pub use crate::product_clients::{FarmsClient, ListingsClient, OrdersClient, SyncClient}; diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -1,7 +1,7 @@ #[cfg(feature = "runtime")] use crate::{ - OrdersClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState, - SdkRelayTargetPolicy, SdkRelayUrlPolicy, + OrdersClient, RadrootsSdkError, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, + SdkIdempotencyKey, SdkMutationState, SdkRelayTargetPolicy, SdkRelayUrlPolicy, actor_json::SdkActorContextJson, order, workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, @@ -143,6 +143,24 @@ pub struct OrderWorkflowEnqueueReceipt { pub outbox_event_id: i64, pub state: SdkMutationState, pub idempotency_digest_prefix: Option<String>, + pub idempotency: OrderWorkflowIdempotencyReceipt, + pub retry: OrderWorkflowRetryAdvice, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct OrderWorkflowIdempotencyReceipt { + pub digest_prefix: Option<String>, + pub replayed_existing_operation: bool, + pub safe_to_retry_with_same_idempotency_key: bool, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct OrderWorkflowRetryAdvice { + pub retryable_after_error: bool, + pub safe_to_retry_enqueue_with_same_idempotency_key: bool, + pub recovery_actions: Vec<RadrootsSdkRecoveryAction>, } #[cfg(feature = "runtime")] @@ -3228,6 +3246,10 @@ fn order_workflow_enqueue_receipt( expected_event_id: RadrootsEventId, enqueue: &crate::workflow_runtime::SdkWorkflowEnqueueReceipt, ) -> OrderWorkflowEnqueueReceipt { + let state = SdkMutationState::from(enqueue.state); + let digest_prefix = Some(enqueue.idempotency_digest_prefix.clone()); + let safe_retry_same_key = true; + let replayed_existing_operation = state == SdkMutationState::AlreadyQueued; OrderWorkflowEnqueueReceipt { kind, operation_kind: kind.operation_kind(), @@ -3236,8 +3258,18 @@ fn order_workflow_enqueue_receipt( local_event_seq: enqueue.local_event_seq, outbox_operation_id: enqueue.outbox_operation_id, outbox_event_id: enqueue.outbox_event_id, - state: enqueue.state.into(), - idempotency_digest_prefix: Some(enqueue.idempotency_digest_prefix.clone()), + state, + idempotency_digest_prefix: digest_prefix.clone(), + idempotency: OrderWorkflowIdempotencyReceipt { + digest_prefix, + replayed_existing_operation, + safe_to_retry_with_same_idempotency_key: safe_retry_same_key, + }, + retry: OrderWorkflowRetryAdvice { + retryable_after_error: false, + safe_to_retry_enqueue_with_same_idempotency_key: safe_retry_same_key, + recovery_actions: Vec::new(), + }, } } diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -439,6 +439,25 @@ async fn order_submit_enqueue_stores_event_queues_outbox_and_status_sees_request receipt.workflow.idempotency_digest_prefix, receipt.idempotency_digest_prefix ); + assert_eq!( + receipt.workflow.idempotency.digest_prefix, + receipt.idempotency_digest_prefix + ); + assert!(!receipt.workflow.idempotency.replayed_existing_operation); + assert!( + receipt + .workflow + .idempotency + .safe_to_retry_with_same_idempotency_key + ); + assert!(!receipt.workflow.retry.retryable_after_error); + assert!( + receipt + .workflow + .retry + .safe_to_retry_enqueue_with_same_idempotency_key + ); + assert!(receipt.workflow.retry.recovery_actions.is_empty()); assert_eq!(receipt.expected_event_id, prepared.expected_event_id); assert_eq!(receipt.signed_event_id, receipt.expected_event_id); assert_eq!(receipt.local_event_seq, 1); @@ -584,6 +603,32 @@ async fn order_submit_enqueue_derives_order_independent_idempotency_key() { second_receipt.idempotency_digest_prefix ); assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued); + assert!( + !first_receipt + .workflow + .idempotency + .replayed_existing_operation + ); + assert!( + second_receipt + .workflow + .idempotency + .replayed_existing_operation + ); + assert!( + second_receipt + .workflow + .idempotency + .safe_to_retry_with_same_idempotency_key + ); + assert!( + second_receipt + .workflow + .retry + .safe_to_retry_enqueue_with_same_idempotency_key + ); + assert!(!second_receipt.workflow.retry.retryable_after_error); + assert!(second_receipt.workflow.retry.recovery_actions.is_empty()); let paths = sdk.storage_paths().expect("paths"); let outbox = RadrootsOutbox::open_file(&paths.outbox_path) @@ -702,6 +747,18 @@ async fn order_submit_enqueue_reports_partial_local_mutation_after_outbox_confli && partial.failure == RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict && partial.recovery == RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey )); + assert!(error.retryable()); + assert_eq!( + error.recovery_actions(), + vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey] + ); + let detail = error.detail_json(); + assert_eq!(detail["code"], "partial_local_mutation"); + assert_eq!(detail["retryable"], true); + assert_eq!( + detail["recovery_actions"], + serde_json::json!(["retry_operation_with_same_idempotency_key"]) + ); assert!( !error .to_string() @@ -802,7 +859,17 @@ async fn order_submit_runtime_dtos_serialize_deterministically() { "outbox_operation_id": 1, "outbox_event_id": 1, "state": "stored_and_queued", - "idempotency_digest_prefix": receipt.workflow.idempotency_digest_prefix.as_deref() + "idempotency_digest_prefix": receipt.workflow.idempotency_digest_prefix.as_deref(), + "idempotency": { + "digest_prefix": receipt.workflow.idempotency.digest_prefix.as_deref(), + "replayed_existing_operation": false, + "safe_to_retry_with_same_idempotency_key": true + }, + "retry": { + "retryable_after_error": false, + "safe_to_retry_enqueue_with_same_idempotency_key": true, + "recovery_actions": [] + } }, "order_id": receipt.order_id.as_str(), "listing_addr": receipt.listing_addr.as_str(), @@ -1433,6 +1500,14 @@ async fn order_decision_runtime_dtos_serialize_deterministically() { receipt.workflow.idempotency_digest_prefix, receipt.idempotency_digest_prefix ); + assert!( + receipt + .workflow + .idempotency + .safe_to_retry_with_same_idempotency_key + ); + assert!(!receipt.workflow.retry.retryable_after_error); + assert!(receipt.workflow.retry.recovery_actions.is_empty()); let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); assert_eq!( @@ -1447,7 +1522,17 @@ async fn order_decision_runtime_dtos_serialize_deterministically() { "outbox_operation_id": 1, "outbox_event_id": 1, "state": "stored_and_queued", - "idempotency_digest_prefix": receipt.workflow.idempotency_digest_prefix.as_deref() + "idempotency_digest_prefix": receipt.workflow.idempotency_digest_prefix.as_deref(), + "idempotency": { + "digest_prefix": receipt.workflow.idempotency.digest_prefix.as_deref(), + "replayed_existing_operation": false, + "safe_to_retry_with_same_idempotency_key": true + }, + "retry": { + "retryable_after_error": false, + "safe_to_retry_enqueue_with_same_idempotency_key": true, + "recovery_actions": [] + } }, "order_id": receipt.order_id.as_str(), "listing_addr": receipt.listing_addr.as_str(), diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -92,8 +92,10 @@ const REQUIRED_ORDER_RUNTIME_EXPORTS: &[&str] = &[ "OrderSubmitPrepareRequest", "OrderSubmitReceipt", "OrderWorkflowEnqueueReceipt", + "OrderWorkflowIdempotencyReceipt", "OrderWorkflowKind", "OrderWorkflowPlan", + "OrderWorkflowRetryAdvice", "SdkOrderStatusIssue", "SdkOrderStatusIssueKind", "SdkOrderStatusSource",