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    /// Sequentially create and install multiple canisters, stopping at the first failure.
45    fn try_create_and_install_many<I>(
46        &self,
47        specs: I,
48    ) -> Result<Vec<Principal>, CanisterInstallError>
49    where
50        I: IntoIterator<Item = InstallSpec>;
51
52    /// Advance simulated time once, then tick twice for an install-code cooldown.
53    /// Zero duration skips time reads/writes while retaining both ticks.
54    fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
55
56    /// Retry only while PocketIC reports install-code rate limiting.
57    ///
58    /// `RetryPolicy::max_attempts` includes the initial call. Before each
59    /// retry, this advances simulated time by the configured cooldown and
60    /// executes two PocketIC ticks. Other rejections are returned unchanged.
61    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
62    where
63        F: FnMut() -> Result<T, RejectResponse>;
64}
65
66impl CanisterInstallExt for PocketIc {
67    /// Install one arbitrary wasm module from a generic install specification.
68    fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
69        let canister_id = capture_operation(|| self.create_canister()).map_err(|source| {
70            CanisterInstallError::CreateCanister {
71                label: spec.label.clone(),
72                source,
73            }
74        })?;
75        if spec.cycles > 0 {
76            capture_operation(|| self.add_cycles(canister_id, spec.cycles)).map_err(|source| {
77                CanisterInstallError::AddCycles {
78                    canister_id,
79                    label: spec.label.clone(),
80                    source,
81                }
82            })?;
83        }
84
85        // Move the Wasm and init payload while retaining the label for failures.
86        let install = capture_operation(|| {
87            self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
88        });
89        if let Err(source) = install {
90            return Err(CanisterInstallError::InstallCode {
91                canister_id,
92                label: spec.label,
93                source,
94            });
95        }
96
97        Ok(canister_id)
98    }
99
100    /// Sequentially install multiple arbitrary wasm modules into this PocketIC instance.
101    ///
102    /// Installs are attempted in iterator order. If one install fails, earlier
103    /// installs remain in the PocketIC instance, the failed canister may exist
104    /// with the id retained in the funding or installation error variant, and later
105    /// installs are not attempted.
106    fn try_create_and_install_many<I>(
107        &self,
108        specs: I,
109    ) -> Result<Vec<Principal>, CanisterInstallError>
110    where
111        I: IntoIterator<Item = InstallSpec>,
112    {
113        specs
114            .into_iter()
115            .map(|spec| self.try_create_and_install(spec))
116            .collect()
117    }
118
119    /// Wait out the PocketIC `install_code` cooldown window inside the same instance.
120    fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
121        super::time::advance_and_tick(self, cooldown, 2);
122    }
123
124    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
125    where
126        F: FnMut() -> Result<T, RejectResponse>,
127    {
128        retry_install_code_with(policy, op, || {
129            self.wait_out_install_code_rate_limit(policy.cooldown);
130        })
131    }
132}
133
134fn retry_install_code_with<T, F, W>(
135    policy: RetryPolicy,
136    mut op: F,
137    mut wait_out_cooldown: W,
138) -> Result<T, RejectResponse>
139where
140    F: FnMut() -> Result<T, RejectResponse>,
141    W: FnMut(),
142{
143    let mut retries = 1..policy.max_attempts.get();
144    loop {
145        match op() {
146            Err(err)
147                if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
148                    && retries.next().is_some() =>
149            {
150                wait_out_cooldown();
151            }
152            result => return result,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use std::{
160        cell::{Cell, RefCell},
161        num::NonZeroUsize,
162        time::Duration,
163    };
164
165    use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
166
167    use super::{RetryPolicy, retry_install_code_with};
168
169    fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
170        RejectResponse {
171            reject_code: RejectCode::SysTransient,
172            reject_message: message.to_string(),
173            error_code,
174            certified: false,
175        }
176    }
177
178    #[test]
179    fn retry_policy_counts_the_first_attempt() {
180        for max_attempts in [1, 3] {
181            let attempts = Cell::new(0);
182            let waits = Cell::new(0);
183            let result = retry_install_code_with(
184                RetryPolicy {
185                    max_attempts: NonZeroUsize::new(max_attempts).unwrap(),
186                    cooldown: Duration::from_secs(1),
187                },
188                || {
189                    attempts.set(attempts.get() + 1);
190                    Err::<(), _>(rejection(
191                        ErrorCode::CanisterInstallCodeRateLimited,
192                        &format!("rate limit on attempt {}", attempts.get()),
193                    ))
194                },
195                || waits.set(waits.get() + 1),
196            );
197
198            assert_eq!(
199                result,
200                Err(rejection(
201                    ErrorCode::CanisterInstallCodeRateLimited,
202                    &format!("rate limit on attempt {max_attempts}"),
203                ))
204            );
205            assert_eq!(attempts.get(), max_attempts);
206            assert_eq!(waits.get(), max_attempts - 1);
207        }
208    }
209
210    #[test]
211    fn retry_policy_waits_only_between_retryable_attempts() {
212        for success_attempt in [1, 3] {
213            let events = RefCell::new(Vec::new());
214            let attempts = Cell::new(0);
215            let result = retry_install_code_with(
216                RetryPolicy {
217                    max_attempts: NonZeroUsize::new(3).unwrap(),
218                    cooldown: Duration::from_secs(1),
219                },
220                || {
221                    events.borrow_mut().push("attempt");
222                    attempts.set(attempts.get() + 1);
223                    if attempts.get() == success_attempt {
224                        Ok(42)
225                    } else {
226                        Err(rejection(
227                            ErrorCode::CanisterInstallCodeRateLimited,
228                            "rate limit",
229                        ))
230                    }
231                },
232                || events.borrow_mut().push("wait"),
233            );
234            assert_eq!(result, Ok(42));
235            assert_eq!(attempts.get(), success_attempt);
236            assert_eq!(
237                events.into_inner(),
238                if success_attempt == 1 {
239                    vec!["attempt"]
240                } else {
241                    vec!["attempt", "wait", "attempt", "wait", "attempt"]
242                }
243            );
244        }
245    }
246
247    #[test]
248    fn retry_policy_stops_on_non_rate_limit_failure() {
249        let attempts = Cell::new(0);
250        let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
251        let result = retry_install_code_with(
252            RetryPolicy {
253                max_attempts: NonZeroUsize::new(3).unwrap(),
254                cooldown: Duration::from_secs(1),
255            },
256            || {
257                attempts.set(attempts.get() + 1);
258                Err::<(), _>(not_retryable.clone())
259            },
260            || panic!("non-rate-limit failure must not wait"),
261        );
262
263        assert_eq!(result, Err(not_retryable));
264        assert_eq!(attempts.get(), 1);
265    }
266}