Status: Implemented —
src/retry.rs(retry_with_backoff,RetryConfig,is_retryable)
AnchorKit includes robust retry logic with exponential backoff for handling transient failures in anchor communications.
use anchorkit::retry::{RetryConfig, retry_with_backoff, is_retryable};
let config = RetryConfig::default(); // max_attempts=3, base_delay_ms=100, max_delay_ms=5000, backoff_multiplier=2
let result = retry_with_backoff(
&config,
|attempt| fetch_stellar_toml(attempt),
|e| is_retryable(e.code as u32),
|delay_ms| std::thread::sleep(std::time::Duration::from_millis(delay_ms)),
);The helper is_retryable(code) classifies only transient, availability, and cache-related failure codes as retryable. It returns true when the numeric error code matches one of the following ErrorCode variants:
ServicesNotConfiguredAttestationNotFoundStaleQuoteNoQuotesAvailableCacheExpiredCacheNotFound
Note: RateLimitExceeded is intentionally NOT retryable. The rate window only clears after window_length ledgers, so retrying in a tight backoff loop would burn through every attempt and still fail. Callers that need to recover from a rate limit should wait for the window to reset (or call the admin reset path) before issuing the next request.
All other ErrorCode values are considered non-retryable and should stop immediately.
Note:
is_retryable(code)only classifies the numeric error codes above. The lists below describe the broader retry categories used by AnchorKit, while the helper itself is implemented on a concise set of transient codes.
| Error code | Retryable? | Reason |
|---|---|---|
AlreadyInitialized |
No | Permanent contract state error |
AttestorAlreadyRegistered |
No | Duplicate registration |
AttestorNotRegistered |
No | Invalid anchor state |
UnauthorizedAttestor |
No | Auth failure |
InvalidTimestamp |
No | Bad request data |
ReplayAttack |
No | Security violation |
InvalidQuote |
No | Bad quote data |
InvalidServiceType |
No | Invalid request parameter |
InvalidTransactionIntent |
No | Invalid transaction shape |
StaleQuote |
Yes | Quote expired; retry after refresh |
ComplianceNotMet |
No | Permanent policy failure |
InvalidEndpointFormat |
No | Bad endpoint configuration |
NoQuotesAvailable |
Yes | Transient quote availability |
ServicesNotConfigured |
Yes | Anchor not ready yet |
ValidationError |
No | Invalid response payload |
RateLimitExceeded |
No | Rate window only clears after window_length ledgers; tight retry loops will fail |
NotInitialized |
No | Contract not ready |
AttestationNotFound |
Yes | Data may become available soon |
InvalidSep10Token |
No | Auth failure |
StorageCorrupted |
No | Persistent on-chain corruption |
CacheExpired |
Yes | Refreshable cache state |
CacheNotFound |
Yes | Cache miss; can refresh |
- Exponential Backoff: Delays increase exponentially between retries (configurable multiplier)
- Configurable Strategy: Customize max attempts, initial delay, max delay, and backoff multiplier
- Smart Error Classification: Automatically distinguishes retryable vs non-retryable errors
- Network Failure Handling: Retries on transport errors and timeouts
- Rate Limit Handling: Backs off when encountering 429 rate limit responses
- 5xx Response Handling: Retries on server errors
The following errors are automatically retried by is_retryable:
ServicesNotConfigured- Service temporarily unavailableAttestationNotFound- Attestation not yet availableStaleQuote- Quote expired, can fetch freshNoQuotesAvailable- No quotes currently availableCacheExpired- Cache expired, can refreshCacheNotFound- Cache miss, can fetch
The following errors are NOT retried (permanent failures):
AlreadyInitialized- Permanent contract state errorAttestorAlreadyRegistered- Duplicate registrationAttestorNotRegistered- Invalid anchor stateUnauthorizedAttestor- Authentication failureInvalidTimestamp- Bad request dataReplayAttack- Security violationInvalidQuote- Invalid quote dataInvalidServiceType- Invalid request parameterInvalidTransactionIntent- Invalid transaction shapeComplianceNotMet- Permanent policy failureInvalidEndpointFormat- Bad endpoint configurationValidationError- Invalid response payloadRateLimitExceeded- Rate window requires ledger-based resetNotInitialized- Contract not readyInvalidSep10Token- Authentication failureStorageCorrupted- Persistent on-chain corruption
use anchorkit::retry::RetryConfig;
let config = RetryConfig::default();
// max_attempts: 3
// base_delay_ms: 100
// max_delay_ms: 5000
// backoff_multiplier: 2use anchorkit::retry::RetryConfig;
// Aggressive: many attempts, short delays
let aggressive = RetryConfig::new(
10, // max_attempts
10, // base_delay_ms
1000, // max_delay_ms
2 // backoff_multiplier
);
// Conservative: few attempts, long delays
let conservative = RetryConfig::new(
3, // max_attempts
1000, // base_delay_ms
10000, // max_delay_ms
3 // backoff_multiplier
);
// Custom for rate limiting: longer delays
let rate_limit = RetryConfig::new(
5, // max_attempts
500, // base_delay_ms
30000, // max_delay_ms (30 seconds)
3 // backoff_multiplier
);use anchorkit::retry::{retry_with_backoff, RetryConfig, is_retryable};
use anchorkit::{AnchorKitError, ErrorCode};
let config = RetryConfig::default();
let mut attempts = 0;
let result = retry_with_backoff(
&config,
|_| {
attempts += 1;
Err::<(), _>(AnchorKitError::invalid_quote())
},
|e: &AnchorKitError| is_retryable(e.code as u32),
|_| {},
);
assert_eq!(attempts, 1);
assert!(matches!(result, Err(err) if err.code == ErrorCode::InvalidQuote));use anchorkit::retry::{RetryConfig, retry_with_backoff};
let config = RetryConfig::default();
let result = retry_with_backoff(
&config,
|attempt| {
println!("Attempt {}", attempt);
// Your operation here
make_network_request()
},
|e| is_retryable_error(e),
|delay_ms| std::thread::sleep(std::time::Duration::from_millis(delay_ms)),
);
match result {
Ok(value) => println!("Success: {:?}", value),
Err(error) => println!("Failed: {:?}", error),
}use anchorkit::retry::{retry_with_backoff, RetryConfig, is_retryable};
let config = RetryConfig::new(5, 100, 5000, 2);
let result = retry_with_backoff(
&config,
|attempt| {
println!("Attempt {}", attempt + 1);
fetch_anchor_data()
},
|e| is_retryable(e.code as u32),
|delay_ms| std::thread::sleep(std::time::Duration::from_millis(delay_ms)),
);
match result {
Ok(data) => println!("Got data: {:?}", data),
Err(error) => println!("Failed after retries: {:?}", error),
}use anchorkit::retry::{RetryConfig, retry_with_backoff};
// Configure longer delays for specific scenarios
let config = RetryConfig::new(
5, // max_attempts
500, // base_delay_ms
30000, // max_delay_ms (30 seconds)
3 // backoff_multiplier (aggressive backoff)
);
let result = retry_with_backoff(
&config,
|attempt| {
match make_api_call() {
Ok(response) => Ok(response),
Err(e) if should_retry(&e) => {
println!("Retryable error on attempt {}, backing off...", attempt + 1);
Err(e)
}
Err(e) => Err(e),
}
},
|e| should_retry(e),
|delay_ms| std::thread::sleep(std::time::Duration::from_millis(delay_ms)),
);use anchorkit::retry::{RetryConfig, retry_with_backoff, is_retryable};
let config = RetryConfig::new(4, 100, 5000, 2);
let result = retry_with_backoff(
&config,
|attempt| {
match fetch_anchor_data() {
Ok(data) => Ok(data),
Err(e) if is_retryable(e.code as u32) => {
println!("Transient error on attempt {}, retrying...", attempt + 1);
Err(e)
}
Err(e) => Err(e),
}
},
|e| is_retryable(e.code as u32),
|delay_ms| std::thread::sleep(std::time::Duration::from_millis(delay_ms)),
);The delay between retries uses full jitter: a uniformly random value between 0 and the exponential backoff cap. This spreads retries evenly across the retry window, preventing thundering-herd problems when many clients retry simultaneously.
delay = uniform_random(0, min(base_delay_ms * multiplier^attempt, max_delay_ms))
Rather than a small perturbation around a deterministic value, each retry picks a fresh random value in the entire allowed range.
max_attempts: 3
base_delay_ms: 100
backoff_multiplier: 2
max_delay_ms: 5000
Attempt 0: 0–100ms
Attempt 1: 0–200ms
Attempt 2: 0–400ms
All delays are uniformly random within their range — not centered around the exponential value.
max_attempts: 5
base_delay_ms: 50
backoff_multiplier: 3
max_delay_ms: 5000
Attempt 0: 0–50ms
Attempt 1: 0–150ms
Attempt 2: 0–450ms
Attempt 3: 0–1350ms
Attempt 4: 0–4050ms
max_attempts: 5
base_delay_ms: 500
backoff_multiplier: 3
max_delay_ms: 30000
Attempt 0: 0–500ms
Attempt 1: 0–1500ms
Attempt 2: 0–4500ms
Attempt 3: 0–13500ms
Attempt 4: 0–30000ms (capped at max_delay_ms)
Why full jitter: Full jitter provides better load distribution than additive jitter when many clients retry on the same schedule. See AWS Exponential Backoff And Jitter.
- Transient failures: Moderate attempts (3-5), short delays (100-500ms)
- Service unavailability: Fewer attempts (3-4), longer delays (500-1000ms), higher multiplier (3)
- Cache misses: Moderate attempts (3-5), moderate delays (200-500ms)
// For user-facing operations: keep max delay low
let user_facing = RetryConfig::new(3, 100, 2000, 2);
// For background operations: can use longer delays
let background = RetryConfig::new(5, 500, 30000, 3);// For synchronous code
let result = retry_with_backoff(
&config,
|attempt| operation(attempt),
|e| is_retryable(e.code as u32),
|delay_ms| std::thread::sleep(std::time::Duration::from_millis(delay_ms)),
);
// For testing (no actual sleep)
let result = retry_with_backoff(
&config,
|attempt| operation(attempt),
|e| is_retryable(e.code as u32),
|_| {}, // no-op sleep for tests
);let result = retry_with_backoff(
&config,
|attempt| {
match operation() {
Err(e) if !is_retryable(e.code as u32) => {
// Non-retryable, fail fast
return Err(e);
}
other => other,
}
},
|e| is_retryable(e.code as u32),
|delay_ms| std::thread::sleep(std::time::Duration::from_millis(delay_ms)),
);The retry logic includes comprehensive tests:
# Run all retry tests
cargo test retry --lib
# Run specific test categories
cargo test test_success_on_first_try --lib
cargo test test_exhausted_retries --lib
cargo test test_delay_increases_exponentially --libThe retry logic is designed to work with any operation that returns a Result:
use anchorkit::retry::{RetryConfig, retry_with_backoff, is_retryable};
let config = RetryConfig::default();
let result = retry_with_backoff(
&config,
|attempt| {
// Your operation that might fail
perform_operation(attempt)
},
|e| is_retryable(e.code as u32),
|delay_ms| std::thread::sleep(std::time::Duration::from_millis(delay_ms)),
);- ✅ Exponential backoff with configurable parameters
- ✅ Smart error classification (retryable vs non-retryable)
- ✅ Transient failure handling (service unavailability, cache misses)
- ✅ Configurable retry strategies
- ✅ Jitter to prevent retry storms
- ✅ Comprehensive test coverage
- ✅ Simple function-based API with
retry_with_backoff