Skip to main content

ic_testkit/pic/
lifecycle.rs

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