commit 70abf6eb6bcbf07f4074b6fba237add4681c00eb
parent 91006304a47ff335c6f36fbbcfcb68ecfc76f6c7
Author: triesap <tyson@radroots.org>
Date: Sun, 20 Sep 2026 15:47:58 +0000
sdk: expose frozen upload retry policy
- Query bounded retry delays from immutable upload transactions
- Share existing exponential delay calculation with the transport
- Preserve unsigned and cancelled operation refusal in wrapper tests
- Verify SDK coverage and the complete producer graph
Diffstat:
4 files changed, 83 insertions(+), 9 deletions(-)
diff --git a/contracts/api_baselines/radroots_sdk.txt b/contracts/api_baselines/radroots_sdk.txt
@@ -660,6 +660,7 @@ impl radroots_sdk::transport::BlossomUploadTransaction
pub const fn radroots_sdk::transport::BlossomUploadTransaction::config_fingerprint(&self) -> radroots_sdk::transport::BlossomConfigFingerprint
pub const fn radroots_sdk::transport::BlossomUploadTransaction::expected_url(&self) -> &radroots_blossom::url::BlobUrl
pub const fn radroots_sdk::transport::BlossomUploadTransaction::request(&self) -> &radroots_sdk::transport::BlossomUploadRequest
+pub fn radroots_sdk::transport::BlossomUploadTransaction::retry_delay_after(&self, u8) -> core::option::Option<core::time::Duration>
impl core::fmt::Debug for radroots_sdk::transport::BlossomUploadTransaction
pub fn radroots_sdk::transport::BlossomUploadTransaction::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct radroots_sdk::transport::DaemonConfig
diff --git a/crates/sdk/src/adapters/blossom.rs b/crates/sdk/src/adapters/blossom.rs
@@ -1112,12 +1112,7 @@ async fn retry_delay(
phase: BlossomPhase,
possible_orphan: bool,
) -> Result<(), BlossomError> {
- let exponent = u32::from(attempt.saturating_sub(1)).min(16);
- let factor = 1_u32 << exponent;
- let delay = config
- .initial_retry_delay()
- .saturating_mul(factor)
- .min(Duration::from_secs(30));
+ let delay = config.retry_delay(attempt);
tokio::select! {
biased;
_ = cancellation.cancelled() => Err(failure(
diff --git a/crates/sdk/src/sync.rs b/crates/sdk/src/sync.rs
@@ -493,6 +493,10 @@ mod tests {
}
fn push_request() -> PushRequest {
+ push_request_for_id(8)
+ }
+
+ fn push_request_for_id(id: u8) -> PushRequest {
let actor = Actor::new(
PublicKey::from_hex(PUBLIC_KEY).expect("public key"),
ActorSource::ExplicitPublicKey,
@@ -512,8 +516,8 @@ mod tests {
)
.expect("authored plan");
PushRequest::new(
- SyncId::new([8; 16]).expect("operation id"),
- IdempotencyKey::parse("sdk-sync-wrapper").expect("idempotency key"),
+ SyncId::new([id; 16]).expect("operation id"),
+ IdempotencyKey::parse(format!("sdk-sync-wrapper-{id}")).expect("idempotency key"),
actor,
plan,
TargetSet::new(vec![target()]).expect("targets"),
@@ -600,7 +604,7 @@ mod tests {
);
assert_eq!(
operations.deliver_push(operation_id).await,
- Err(Error::MissingSink)
+ Err(Error::InvalidSignerOutput)
);
let status = operations
@@ -677,6 +681,10 @@ mod tests {
let submitted = push_request();
assert_eq!(
operations.submit_push(submitted).await,
+ Err(Error::SigningCancelled)
+ );
+ assert_eq!(
+ operations.submit_push(push_request_for_id(9)).await,
Err(Error::MissingSigner)
);
}
diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs
@@ -420,6 +420,13 @@ impl BlossomConfig {
pub(crate) const fn initial_retry_delay(&self) -> Duration {
self.initial_retry_delay
}
+
+ pub(crate) fn retry_delay(&self, attempt: u8) -> Duration {
+ let exponent = u32::from(attempt.saturating_sub(1)).min(16);
+ self.initial_retry_delay()
+ .saturating_mul(1_u32 << exponent)
+ .min(MAX_BLOSSOM_RETRY_DELAY)
+ }
}
/// Stable, non-secret identity of a completely validated Blossom configuration.
@@ -820,6 +827,18 @@ impl BlossomEndpointEvidence {
#[cfg(feature = "blossom")]
impl BlossomUploadTransaction {
+ /// Returns the frozen policy's minimum delay before another attempt.
+ /// Zero or exhausted completed-attempt counts admit no retry. This is a
+ /// pure policy query, not evidence of native inactivity, renewed signing
+ /// access, or absence of remote effects. The caller owns those prerequisites
+ /// and the durable attempt count; current slot configuration cannot change
+ /// this transaction's budget.
+ #[must_use]
+ pub fn retry_delay_after(&self, completed_attempts: u8) -> Option<Duration> {
+ (completed_attempts > 0 && completed_attempts < self.config.max_attempts())
+ .then(|| self.config.retry_delay(completed_attempts))
+ }
+
#[must_use]
pub const fn config_fingerprint(&self) -> BlossomConfigFingerprint {
self.config_fingerprint
@@ -2531,6 +2550,57 @@ mod tests {
}
#[cfg(feature = "blossom")]
+ #[test]
+ fn upload_retry_policy_is_bounded_and_frozen_in_the_transaction() {
+ let profile = public_blossom_profile("https://media.example").unwrap();
+ for (maximum, initial_ms) in [(1, 250), (3, 250), (5, 20_000)] {
+ let config = BlossomConfig::from_profile(profile.clone())
+ .with_network_policy(
+ Duration::from_secs(10),
+ Duration::from_secs(60),
+ maximum,
+ Duration::from_millis(initial_ms),
+ )
+ .unwrap();
+ let slot = BlossomSlot::new();
+ slot.configure(config.clone()).unwrap();
+ let transaction = slot.prepare_upload(blossom_request("ignored")).unwrap();
+ for completed in 0..=u8::MAX {
+ let expected = if completed > 0 && completed < maximum {
+ Some(Duration::from_millis(
+ (initial_ms * (1 << (completed - 1))).min(30_000),
+ ))
+ } else {
+ None
+ };
+ assert_eq!(transaction.retry_delay_after(completed), expected);
+ }
+ slot.configure(
+ BlossomConfig::from_profile(profile.clone())
+ .with_network_policy(
+ Duration::from_secs(10),
+ Duration::from_secs(60),
+ 2,
+ Duration::from_millis(7),
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ let later = slot.prepare_upload(blossom_request("ignored")).unwrap();
+ assert_ne!(transaction.config_fingerprint(), later.config_fingerprint());
+ assert_eq!(later.retry_delay_after(1), Some(Duration::from_millis(7)));
+ assert_eq!(later.retry_delay_after(2), None);
+ assert_eq!(transaction.config_fingerprint(), config.fingerprint());
+ assert_eq!(
+ transaction.retry_delay_after(1),
+ (maximum > 1).then_some(Duration::from_millis(initial_ms))
+ );
+ assert_eq!(config.retry_delay(0), Duration::from_millis(initial_ms));
+ assert_eq!(config.retry_delay(u8::MAX), Duration::from_secs(30));
+ }
+ }
+
+ #[cfg(feature = "blossom")]
fn simulator_blossom_profile(origin: &str) -> Result<BlossomProfile, BlossomError> {
BlossomProfile::new(
BlossomHostKind::Simulator,