Skip to main content

ic_testkit/pic/
lifecycle.rs

1use std::{num::NonZeroUsize, time::Duration};
2
3use candid::Principal;
4use pocket_ic::{ErrorCode, PocketIc, RejectResponse};
5
6use super::{CanisterInstallError, transport::capture_operation};
7
8/// Inputs and diagnostic context for one generic canister installation.
9pub struct InstallSpec {
10    /// Wasm module installed into the newly created canister.
11    pub wasm: Vec<u8>,
12    /// Raw Candid or application-specific initialization bytes.
13    pub init_bytes: Vec<u8>,
14    /// Extra cycles added after PocketIC creates the canister and before install.
15    pub cycles: u128,
16    /// Optional sender for the management-canister install operation.
17    pub install_sender: Option<Principal>,
18    /// Optional human-readable context included in install errors.
19    pub label: Option<String>,
20}
21
22/// Retry limits and simulated cooldown for install-code operations.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub struct RetryPolicy {
25    /// Maximum attempts, including the initial call.
26    pub max_attempts: NonZeroUsize,
27    /// Simulated cooldown between retryable attempts.
28    pub cooldown: Duration,
29}
30
31/// Generic canister installation and structured install-code retry policy.
32///
33/// The extension creates canisters using PocketIC defaults. `InstallSpec::cycles`
34/// is an additional top-up, not the complete initial balance.
35pub trait CanisterInstallExt {
36    /// Create and install one canister from a reusable specification.
37    ///
38    /// Captures PocketIC panics during creation, cycle funding, and installation.
39    /// The error identifies the stage and retains an id when creation succeeded.
40    /// Callers choose whether to collect diagnostics; this performs no diagnostic
41    /// requests or error logging.
42    fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
43
44    /// Advance simulated time once, then tick twice for an install-code cooldown.
45    /// Zero duration skips time reads/writes while retaining both ticks.
46    fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
47
48    /// Retry only while PocketIC reports install-code rate limiting.
49    ///
50    /// `RetryPolicy::max_attempts` includes the initial call. Before each
51    /// retry, this advances simulated time by the configured cooldown and
52    /// executes two PocketIC ticks. Other rejections are returned unchanged.
53    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
54    where
55        F: FnMut() -> Result<T, RejectResponse>;
56}
57
58impl CanisterInstallExt for PocketIc {
59    /// Install one arbitrary wasm module from a generic install specification.
60    fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
61        let canister_id = capture_operation(|| self.create_canister()).map_err(|source| {
62            CanisterInstallError::CreateCanister {
63                label: spec.label.clone(),
64                source,
65            }
66        })?;
67        if spec.cycles > 0 {
68            capture_operation(|| self.add_cycles(canister_id, spec.cycles)).map_err(|source| {
69                CanisterInstallError::AddCycles {
70                    canister_id,
71                    label: spec.label.clone(),
72                    source,
73                }
74            })?;
75        }
76
77        // Move the Wasm and init payload while retaining the label for failures.
78        let install = capture_operation(|| {
79            self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
80        });
81        if let Err(source) = install {
82            return Err(CanisterInstallError::InstallCode {
83                canister_id,
84                label: spec.label,
85                source,
86            });
87        }
88
89        Ok(canister_id)
90    }
91
92    /// Wait out the PocketIC `install_code` cooldown window inside the same instance.
93    fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
94        super::time::advance_and_tick(self, cooldown, 2);
95    }
96
97    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
98    where
99        F: FnMut() -> Result<T, RejectResponse>,
100    {
101        retry_install_code_with(policy, op, || {
102            self.wait_out_install_code_rate_limit(policy.cooldown);
103        })
104    }
105}
106
107fn retry_install_code_with<T, F, W>(
108    policy: RetryPolicy,
109    mut op: F,
110    mut wait_out_cooldown: W,
111) -> Result<T, RejectResponse>
112where
113    F: FnMut() -> Result<T, RejectResponse>,
114    W: FnMut(),
115{
116    let mut retries = 1..policy.max_attempts.get();
117    loop {
118        match op() {
119            Err(err)
120                if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
121                    && retries.next().is_some() =>
122            {
123                wait_out_cooldown();
124            }
125            result => return result,
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use std::{
133        cell::{Cell, RefCell},
134        num::NonZeroUsize,
135        time::Duration,
136    };
137
138    use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
139
140    use super::{RetryPolicy, retry_install_code_with};
141
142    fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
143        RejectResponse {
144            reject_code: RejectCode::SysTransient,
145            reject_message: message.to_string(),
146            error_code,
147            certified: false,
148        }
149    }
150
151    #[test]
152    fn retry_policy_counts_the_first_attempt() {
153        for max_attempts in [1, 3] {
154            let attempts = Cell::new(0);
155            let waits = Cell::new(0);
156            let result = retry_install_code_with(
157                RetryPolicy {
158                    max_attempts: NonZeroUsize::new(max_attempts).unwrap(),
159                    cooldown: Duration::from_secs(1),
160                },
161                || {
162                    attempts.set(attempts.get() + 1);
163                    Err::<(), _>(rejection(
164                        ErrorCode::CanisterInstallCodeRateLimited,
165                        &format!("rate limit on attempt {}", attempts.get()),
166                    ))
167                },
168                || waits.set(waits.get() + 1),
169            );
170
171            assert_eq!(
172                result,
173                Err(rejection(
174                    ErrorCode::CanisterInstallCodeRateLimited,
175                    &format!("rate limit on attempt {max_attempts}"),
176                ))
177            );
178            assert_eq!(attempts.get(), max_attempts);
179            assert_eq!(waits.get(), max_attempts - 1);
180        }
181    }
182
183    #[test]
184    fn retry_policy_waits_only_between_retryable_attempts() {
185        for success_attempt in [1, 3] {
186            let events = RefCell::new(Vec::new());
187            let attempts = Cell::new(0);
188            let result = retry_install_code_with(
189                RetryPolicy {
190                    max_attempts: NonZeroUsize::new(3).unwrap(),
191                    cooldown: Duration::from_secs(1),
192                },
193                || {
194                    events.borrow_mut().push("attempt");
195                    attempts.set(attempts.get() + 1);
196                    if attempts.get() == success_attempt {
197                        Ok(42)
198                    } else {
199                        Err(rejection(
200                            ErrorCode::CanisterInstallCodeRateLimited,
201                            "rate limit",
202                        ))
203                    }
204                },
205                || events.borrow_mut().push("wait"),
206            );
207            assert_eq!(result, Ok(42));
208            assert_eq!(attempts.get(), success_attempt);
209            assert_eq!(
210                events.into_inner(),
211                if success_attempt == 1 {
212                    vec!["attempt"]
213                } else {
214                    vec!["attempt", "wait", "attempt", "wait", "attempt"]
215                }
216            );
217        }
218    }
219
220    #[test]
221    fn retry_policy_stops_on_non_rate_limit_failure() {
222        let attempts = Cell::new(0);
223        let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
224        let result = retry_install_code_with(
225            RetryPolicy {
226                max_attempts: NonZeroUsize::new(3).unwrap(),
227                cooldown: Duration::from_secs(1),
228            },
229            || {
230                attempts.set(attempts.get() + 1);
231                Err::<(), _>(not_retryable.clone())
232            },
233            || panic!("non-rate-limit failure must not wait"),
234        );
235
236        assert_eq!(result, Err(not_retryable));
237        assert_eq!(attempts.get(), 1);
238    }
239}