Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@

### Fixes

* [FIX][rust] Transaction submissions no longer retry `Unavailable` responses, avoiding a duplicate submission when the node may already have accepted the first request; read-only RPCs keep their existing retry behavior. (#2441)

* [FIX][rust] `ChainAnchor` deserialization no longer panics on crafted input: a partial blockchain whose tracked leaf is missing an ancestor sibling, or whose block-map key disagrees with its header, is rejected as an invalid value, and anchors tracking more blocks than a transaction can reference are rejected early with the new `ChainAnchorError::TooManyTrackedBlocks` ([#2421](https://github.com/0xMiden/rust-sdk/pull/2421)).
* [FIX][rust] `Client::execute_transaction_at` now fails with the new `ChainAnchorError::AnchoredTransactionExpired` when the executed transaction's expiration block has already been reached, instead of handing back a transaction the network would reject after proving ([#2421](https://github.com/0xMiden/rust-sdk/pull/2421)).
* [FIX][rust] A request that sets `ignore_invalid_input_notes` but carries no input notes, or whose notes are all screened out, no longer fails with an out-of-range note-count error from the consumption checker ([#2421](https://github.com/0xMiden/rust-sdk/pull/2421)).
Expand Down
11 changes: 7 additions & 4 deletions crates/rust-client/src/rpc/tonic_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,10 @@ impl GrpcClient {
/// Executes an RPC call and automatically retries transient failures.
///
/// The provided closure is invoked with a freshly connected [`ApiClient`] on each attempt.
/// Retries are delegated to [`retry::RetryState`], which currently handles gRPC
/// [`tonic::Code::ResourceExhausted`] and [`tonic::Code::Unavailable`] responses, including
/// honoring cooldown delays when the node provides them.
/// Retries are delegated to [`retry::RetryState`]. Read-only calls retry gRPC
/// [`tonic::Code::ResourceExhausted`] and [`tonic::Code::Unavailable`] responses, while
/// transaction submissions retry only [`tonic::Code::ResourceExhausted`] because an
/// `Unavailable` response may arrive after the node has already accepted the submission.
///
/// Returns the first successful gRPC response. If the call keeps failing after retries are
/// exhausted, or if the error is not retryable, this returns the corresponding [`RpcError`]
Expand All @@ -310,13 +311,15 @@ impl GrpcClient {
mut call: impl FnMut(ApiClient) -> RpcFuture<Result<tonic::Response<T>, Status>>,
) -> Result<tonic::Response<T>, RpcError> {
let mut retry_state = retry::RetryState::new(self.max_retries, self.retry_interval_ms);
let retry_unavailable =
!matches!(endpoint, RpcEndpoint::SubmitProvenTx | RpcEndpoint::SubmitProvenBatch);

loop {
let rpc_api = self.ensure_connected().await?;

match call(rpc_api).await {
Ok(response) => return Ok(response),
Err(status) if retry_state.should_retry(&status).await => {},
Err(status) if retry_state.should_retry(&status, retry_unavailable).await => {},
Err(status) => return Err(self.rpc_error_from_status(endpoint, status)),
}
}
Expand Down
24 changes: 19 additions & 5 deletions crates/rust-client/src/rpc/tonic_client/retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ impl RetryState {
/// Returns `true` after waiting the requested cooldown when the error is retryable and the
/// attempt limit has not been reached. Returns `false` for non-retryable statuses or once the
/// retry budget is exhausted.
pub(super) async fn should_retry(&mut self, status: &Status) -> bool {
if self.attempt >= self.max_retries || !is_retryable(status) {
pub(super) async fn should_retry(&mut self, status: &Status, retry_unavailable: bool) -> bool {
if self.attempt >= self.max_retries || !is_retryable(status, retry_unavailable) {
return false;
}

Expand All @@ -64,8 +64,9 @@ impl RetryState {
// HELPERS
// ================================================================================================

fn is_retryable(status: &Status) -> bool {
matches!(status.code(), tonic::Code::ResourceExhausted | tonic::Code::Unavailable)
fn is_retryable(status: &Status, retry_unavailable: bool) -> bool {
matches!(status.code(), tonic::Code::ResourceExhausted)
|| (retry_unavailable && matches!(status.code(), tonic::Code::Unavailable))
}

fn retry_delay(status: &Status, fallback_ms: u64) -> Duration {
Expand Down Expand Up @@ -101,14 +102,27 @@ mod tests {
use tonic::metadata::MetadataMap;
use tonic::{Code, Status};

use super::{DEFAULT_RETRY_INTERVAL_MS, retry_delay};
use super::{DEFAULT_RETRY_INTERVAL_MS, is_retryable, retry_delay};

fn status_with_retry_after(retry_after: &str) -> Status {
let mut metadata = MetadataMap::new();
metadata.insert("retry-after", retry_after.parse().unwrap());
Status::with_metadata(Code::ResourceExhausted, "Too Many Requests! Wait for 0s", metadata)
}

#[test]
fn unavailable_retry_can_be_disabled_for_submissions() {
let status = Status::unavailable("temporarily unavailable");
assert!(!is_retryable(&status, false));
assert!(is_retryable(&status, true));
}

#[test]
fn resource_exhausted_remains_retryable_for_submissions() {
let status = Status::resource_exhausted("rate limited");
assert!(is_retryable(&status, false));
}

#[test]
fn zero_retry_after_uses_fallback_delay() {
assert_eq!(
Expand Down