1use std::panic::{AssertUnwindSafe, catch_unwind};
2use std::time::Duration;
3
4use candid::Principal;
5use pocket_ic::{ErrorCode, PocketIc, RejectResponse};
6
7use super::{CanisterInstallError, PocketIcDiagnosticsExt, transport};
8
9#[non_exhaustive]
14pub struct InstallSpec {
15 pub wasm: Vec<u8>,
16 pub init_bytes: Vec<u8>,
17 pub cycles: u128,
18 pub install_sender: Option<Principal>,
19 pub label: Option<String>,
20}
21
22impl InstallSpec {
23 #[must_use]
25 pub const fn new(wasm: Vec<u8>, init_bytes: Vec<u8>, cycles: u128) -> Self {
26 Self {
27 wasm,
28 init_bytes,
29 cycles,
30 install_sender: None,
31 label: None,
32 }
33 }
34
35 #[must_use]
37 pub const fn install_sender(mut self, sender: Principal) -> Self {
38 self.install_sender = Some(sender);
39 self
40 }
41
42 #[must_use]
44 pub fn label(mut self, label: impl Into<String>) -> Self {
45 self.label = Some(label.into());
46 self
47 }
48}
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub struct RetryPolicy {
53 max_attempts: usize,
54 cooldown: Duration,
55}
56
57impl RetryPolicy {
58 #[must_use]
60 pub const fn new(max_attempts: usize, cooldown: Duration) -> Self {
61 assert!(
62 max_attempts > 0,
63 "retry policy requires at least one attempt"
64 );
65 Self {
66 max_attempts,
67 cooldown,
68 }
69 }
70
71 #[must_use]
73 pub const fn max_attempts(self) -> usize {
74 self.max_attempts
75 }
76
77 #[must_use]
79 pub const fn cooldown(self) -> Duration {
80 self.cooldown
81 }
82}
83
84pub trait CanisterInstallExt {
86 #[must_use]
88 fn create_and_install_with_args(
89 &self,
90 wasm: Vec<u8>,
91 init_bytes: Vec<u8>,
92 install_cycles: u128,
93 ) -> Principal;
94
95 fn try_create_and_install_with_args(
97 &self,
98 wasm: Vec<u8>,
99 init_bytes: Vec<u8>,
100 install_cycles: u128,
101 ) -> Result<Principal, CanisterInstallError>;
102
103 #[must_use]
105 fn create_and_install(&self, spec: InstallSpec) -> Principal;
106
107 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
109
110 #[must_use]
112 fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
113 where
114 I: IntoIterator<Item = InstallSpec>;
115
116 fn try_create_and_install_many<I>(
118 &self,
119 specs: I,
120 ) -> Result<Vec<Principal>, CanisterInstallError>
121 where
122 I: IntoIterator<Item = InstallSpec>;
123
124 fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
126
127 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
129 where
130 F: FnMut() -> Result<T, RejectResponse>;
131}
132
133impl CanisterInstallExt for PocketIc {
134 fn create_and_install_with_args(
139 &self,
140 wasm: Vec<u8>,
141 init_bytes: Vec<u8>,
142 install_cycles: u128,
143 ) -> Principal {
144 self.try_create_and_install_with_args(wasm, init_bytes, install_cycles)
145 .unwrap_or_else(|err| panic!("{err}"))
146 }
147
148 fn try_create_and_install_with_args(
150 &self,
151 wasm: Vec<u8>,
152 init_bytes: Vec<u8>,
153 install_cycles: u128,
154 ) -> Result<Principal, CanisterInstallError> {
155 self.try_create_and_install(InstallSpec::new(wasm, init_bytes, install_cycles))
156 }
157
158 fn create_and_install(&self, spec: InstallSpec) -> Principal {
160 self.try_create_and_install(spec)
161 .unwrap_or_else(|err| panic!("{err}"))
162 }
163
164 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
166 try_create_funded_and_install(self, spec)
167 }
168
169 fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
176 where
177 I: IntoIterator<Item = InstallSpec>,
178 {
179 self.try_create_and_install_many(specs)
180 .unwrap_or_else(|err| panic!("{err}"))
181 }
182
183 fn try_create_and_install_many<I>(
190 &self,
191 specs: I,
192 ) -> Result<Vec<Principal>, CanisterInstallError>
193 where
194 I: IntoIterator<Item = InstallSpec>,
195 {
196 specs
197 .into_iter()
198 .map(|spec| self.try_create_and_install(spec))
199 .collect()
200 }
201
202 fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
204 self.advance_time(cooldown);
205 self.tick();
206 self.tick();
207 }
208
209 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
210 where
211 F: FnMut() -> Result<T, RejectResponse>,
212 {
213 retry_install_code_with(policy, op, || {
214 self.wait_out_install_code_rate_limit(policy.cooldown());
215 })
216 }
217}
218
219fn try_create_funded_and_install(
221 pocket_ic: &PocketIc,
222 spec: InstallSpec,
223) -> Result<Principal, CanisterInstallError> {
224 let canister_id = pocket_ic.create_canister();
225 if spec.cycles > 0 {
226 let _ = pocket_ic.add_cycles(canister_id, spec.cycles);
227 }
228
229 let install = catch_unwind(AssertUnwindSafe(|| {
230 pocket_ic.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
231 }));
232 if let Err(payload) = install {
233 let message = transport::panic_payload_to_string(payload.as_ref());
234 let context = if let Some(label) = &spec.label {
235 format!("install_canister trapped ({label})")
236 } else {
237 "install_canister trapped".to_string()
238 };
239 let _ = catch_unwind(AssertUnwindSafe(|| {
242 pocket_ic.dump_canister_debug(canister_id, &context);
243 }));
244
245 return if let Some(label) = spec.label {
246 Err(CanisterInstallError::labeled(canister_id, label, message))
247 } else {
248 Err(CanisterInstallError::new(canister_id, message))
249 };
250 }
251
252 Ok(canister_id)
253}
254
255fn is_install_code_rate_limited(response: &RejectResponse) -> bool {
256 response.error_code == ErrorCode::CanisterInstallCodeRateLimited
257}
258
259fn retry_install_code_with<T, F, W>(
260 policy: RetryPolicy,
261 mut op: F,
262 mut wait_out_cooldown: W,
263) -> Result<T, RejectResponse>
264where
265 F: FnMut() -> Result<T, RejectResponse>,
266 W: FnMut(),
267{
268 for attempt in 1..=policy.max_attempts() {
269 match op() {
270 Ok(value) => return Ok(value),
271 Err(err) if is_install_code_rate_limited(&err) && attempt < policy.max_attempts() => {
272 wait_out_cooldown();
273 }
274 Err(err) => return Err(err),
275 }
276 }
277
278 unreachable!("RetryPolicy guarantees at least one attempt")
279}
280
281#[cfg(test)]
282mod tests {
283 use std::{cell::Cell, time::Duration};
284
285 use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
286
287 use super::{RetryPolicy, retry_install_code_with};
288
289 fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
290 RejectResponse {
291 reject_code: RejectCode::SysTransient,
292 reject_message: message.to_string(),
293 error_code,
294 certified: false,
295 }
296 }
297
298 #[test]
299 fn retry_policy_counts_the_first_attempt() {
300 let attempts = Cell::new(0);
301 let waits = Cell::new(0);
302 let rate_limited = rejection(
303 ErrorCode::CanisterInstallCodeRateLimited,
304 "install-code rate limit",
305 );
306 let result = retry_install_code_with(
307 RetryPolicy::new(3, Duration::from_secs(1)),
308 || {
309 attempts.set(attempts.get() + 1);
310 Err::<(), _>(rate_limited.clone())
311 },
312 || waits.set(waits.get() + 1),
313 );
314
315 assert_eq!(result, Err(rate_limited));
316 assert_eq!(attempts.get(), 3);
317 assert_eq!(waits.get(), 2);
318 }
319
320 #[test]
321 fn retry_policy_stops_on_non_rate_limit_failure() {
322 let attempts = Cell::new(0);
323 let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
324 let result = retry_install_code_with(
325 RetryPolicy::new(3, Duration::from_secs(1)),
326 || {
327 attempts.set(attempts.get() + 1);
328 Err::<(), _>(not_retryable.clone())
329 },
330 || panic!("non-rate-limit failure must not wait"),
331 );
332
333 assert_eq!(result, Err(not_retryable));
334 assert_eq!(attempts.get(), 1);
335 }
336}