use std::{num::NonZeroUsize, time::Duration};
use candid::Principal;
use pocket_ic::{ErrorCode, PocketIc, RejectResponse};
use super::{CanisterInstallError, transport::capture_operation};
pub struct InstallSpec {
pub wasm: Vec<u8>,
pub init_bytes: Vec<u8>,
pub cycles: u128,
pub install_sender: Option<Principal>,
pub label: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryPolicy {
pub max_attempts: NonZeroUsize,
pub cooldown: Duration,
}
pub trait CanisterInstallExt {
fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
where
F: FnMut() -> Result<T, RejectResponse>;
}
impl CanisterInstallExt for PocketIc {
fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
let canister_id = capture_operation(|| self.create_canister()).map_err(|source| {
CanisterInstallError::CreateCanister {
label: spec.label.clone(),
source,
}
})?;
if spec.cycles > 0 {
capture_operation(|| self.add_cycles(canister_id, spec.cycles)).map_err(|source| {
CanisterInstallError::AddCycles {
canister_id,
label: spec.label.clone(),
source,
}
})?;
}
let install = capture_operation(|| {
self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
});
if let Err(source) = install {
return Err(CanisterInstallError::InstallCode {
canister_id,
label: spec.label,
source,
});
}
Ok(canister_id)
}
fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
super::time::advance_and_tick(self, cooldown, 2);
}
fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
where
F: FnMut() -> Result<T, RejectResponse>,
{
retry_install_code_with(policy, op, || {
self.wait_out_install_code_rate_limit(policy.cooldown);
})
}
}
fn retry_install_code_with<T, F, W>(
policy: RetryPolicy,
mut op: F,
mut wait_out_cooldown: W,
) -> Result<T, RejectResponse>
where
F: FnMut() -> Result<T, RejectResponse>,
W: FnMut(),
{
let mut retries = 1..policy.max_attempts.get();
loop {
match op() {
Err(err)
if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
&& retries.next().is_some() =>
{
wait_out_cooldown();
}
result => return result,
}
}
}
#[cfg(test)]
mod tests {
use std::{
cell::{Cell, RefCell},
num::NonZeroUsize,
time::Duration,
};
use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
use super::{RetryPolicy, retry_install_code_with};
fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
RejectResponse {
reject_code: RejectCode::SysTransient,
reject_message: message.to_string(),
error_code,
certified: false,
}
}
#[test]
fn retry_policy_counts_the_first_attempt() {
for max_attempts in [1, 3] {
let attempts = Cell::new(0);
let waits = Cell::new(0);
let result = retry_install_code_with(
RetryPolicy {
max_attempts: NonZeroUsize::new(max_attempts).unwrap(),
cooldown: Duration::from_secs(1),
},
|| {
attempts.set(attempts.get() + 1);
Err::<(), _>(rejection(
ErrorCode::CanisterInstallCodeRateLimited,
&format!("rate limit on attempt {}", attempts.get()),
))
},
|| waits.set(waits.get() + 1),
);
assert_eq!(
result,
Err(rejection(
ErrorCode::CanisterInstallCodeRateLimited,
&format!("rate limit on attempt {max_attempts}"),
))
);
assert_eq!(attempts.get(), max_attempts);
assert_eq!(waits.get(), max_attempts - 1);
}
}
#[test]
fn retry_policy_waits_only_between_retryable_attempts() {
for success_attempt in [1, 3] {
let events = RefCell::new(Vec::new());
let attempts = Cell::new(0);
let result = retry_install_code_with(
RetryPolicy {
max_attempts: NonZeroUsize::new(3).unwrap(),
cooldown: Duration::from_secs(1),
},
|| {
events.borrow_mut().push("attempt");
attempts.set(attempts.get() + 1);
if attempts.get() == success_attempt {
Ok(42)
} else {
Err(rejection(
ErrorCode::CanisterInstallCodeRateLimited,
"rate limit",
))
}
},
|| events.borrow_mut().push("wait"),
);
assert_eq!(result, Ok(42));
assert_eq!(attempts.get(), success_attempt);
assert_eq!(
events.into_inner(),
if success_attempt == 1 {
vec!["attempt"]
} else {
vec!["attempt", "wait", "attempt", "wait", "attempt"]
}
);
}
}
#[test]
fn retry_policy_stops_on_non_rate_limit_failure() {
let attempts = Cell::new(0);
let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
let result = retry_install_code_with(
RetryPolicy {
max_attempts: NonZeroUsize::new(3).unwrap(),
cooldown: Duration::from_secs(1),
},
|| {
attempts.set(attempts.get() + 1);
Err::<(), _>(not_retryable.clone())
},
|| panic!("non-rate-limit failure must not wait"),
);
assert_eq!(result, Err(not_retryable));
assert_eq!(attempts.get(), 1);
}
}