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:
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",