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]
11pub struct InstallSpec {
12 pub wasm: Vec<u8>,
14 pub init_bytes: Vec<u8>,
16 pub cycles: u128,
18 pub install_sender: Option<Principal>,
20 pub label: Option<String>,
22}
23
24impl InstallSpec {
25 #[must_use]
27 pub const fn new(wasm: Vec<u8>, init_bytes: Vec<u8>, cycles: u128) -> Self {
28 Self {
29 wasm,
30 init_bytes,
31 cycles,
32 install_sender: None,
33 label: None,
34 }
35 }
36
37 #[must_use]
39 pub const fn install_sender(mut self, sender: Principal) -> Self {
40 self.install_sender = Some(sender);
41 self
42 }
43
44 #[must_use]
46 pub fn label(mut self, label: impl Into<String>) -> Self {
47 self.label = Some(label.into());
48 self
49 }
50}
51
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub struct RetryPolicy {
55 max_attempts: usize,
56 cooldown: Duration,
57}
58
59#[non_exhaustive]
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum RetryPolicyError {
63 ZeroMaxAttempts,
65}
66
67impl std::fmt::Display for RetryPolicyError {
68 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 match self {
70 Self::ZeroMaxAttempts => {
71 formatter.write_str("retry policy requires at least one attempt")
72 }
73 }
74 }
75}
76
77impl std::error::Error for RetryPolicyError {}
78
79impl RetryPolicy {
80 pub const fn try_new(
82 max_attempts: usize,
83 cooldown: Duration,
84 ) -> Result<Self, RetryPolicyError> {
85 if max_attempts == 0 {
86 Err(RetryPolicyError::ZeroMaxAttempts)
87 } else {
88 Ok(Self {
89 max_attempts,
90 cooldown,
91 })
92 }
93 }
94
95 #[must_use]
97 pub const fn max_attempts(self) -> usize {
98 self.max_attempts
99 }
100
101 #[must_use]
103 pub const fn cooldown(self) -> Duration {
104 self.cooldown
105 }
106}
107
108pub trait CanisterInstallExt {
113 #[must_use]
115 fn create_and_install_with_args(
116 &self,
117 wasm: Vec<u8>,
118 init_bytes: Vec<u8>,
119 install_cycles: u128,
120 ) -> Principal;
121
122 fn try_create_and_install_with_args(
124 &self,
125 wasm: Vec<u8>,
126 init_bytes: Vec<u8>,
127 install_cycles: u128,
128 ) -> Result<Principal, CanisterInstallError>;
129
130 #[must_use]
132 fn create_and_install(&self, spec: InstallSpec) -> Principal;
133
134 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
136
137 #[must_use]
139 fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
140 where
141 I: IntoIterator<Item = InstallSpec>;
142
143 fn try_create_and_install_many<I>(
145 &self,
146 specs: I,
147 ) -> Result<Vec<Principal>, CanisterInstallError>
148 where
149 I: IntoIterator<Item = InstallSpec>;
150
151 fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
153
154 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
160 where
161 F: FnMut() -> Result<T, RejectResponse>;
162}
163
164impl CanisterInstallExt for PocketIc {
165 fn create_and_install_with_args(
170 &self,
171 wasm: Vec<u8>,
172 init_bytes: Vec<u8>,
173 install_cycles: u128,
174 ) -> Principal {
175 self.try_create_and_install_with_args(wasm, init_bytes, install_cycles)
176 .unwrap_or_else(|err| panic!("{err}"))
177 }
178
179 fn try_create_and_install_with_args(
181 &self,
182 wasm: Vec<u8>,
183 init_bytes: Vec<u8>,
184 install_cycles: u128,
185 ) -> Result<Principal, CanisterInstallError> {
186 self.try_create_and_install(InstallSpec::new(wasm, init_bytes, install_cycles))
187 }
188
189 fn create_and_install(&self, spec: InstallSpec) -> Principal {
191 self.try_create_and_install(spec)
192 .unwrap_or_else(|err| panic!("{err}"))
193 }
194
195 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
197 try_create_funded_and_install(self, spec)
198 }
199
200 fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
207 where
208 I: IntoIterator<Item = InstallSpec>,
209 {
210 self.try_create_and_install_many(specs)
211 .unwrap_or_else(|err| panic!("{err}"))
212 }
213
214 fn try_create_and_install_many<I>(
221 &self,
222 specs: I,
223 ) -> Result<Vec<Principal>, CanisterInstallError>
224 where
225 I: IntoIterator<Item = InstallSpec>,
226 {
227 specs
228 .into_iter()
229 .map(|spec| self.try_create_and_install(spec))
230 .collect()
231 }
232
233 fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
235 self.advance_time(cooldown);
236 self.tick();
237 self.tick();
238 }
239
240 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
241 where
242 F: FnMut() -> Result<T, RejectResponse>,
243 {
244 retry_install_code_with(policy, op, || {
245 self.wait_out_install_code_rate_limit(policy.cooldown());
246 })
247 }
248}
249
250fn try_create_funded_and_install(
252 pocket_ic: &PocketIc,
253 spec: InstallSpec,
254) -> Result<Principal, CanisterInstallError> {
255 let canister_id = pocket_ic.create_canister();
256 if spec.cycles > 0 {
257 let _ = pocket_ic.add_cycles(canister_id, spec.cycles);
258 }
259
260 let install = catch_unwind(AssertUnwindSafe(|| {
261 pocket_ic.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
262 }));
263 if let Err(payload) = install {
264 let message = transport::panic_payload_to_string(payload.as_ref());
265 let context = if let Some(label) = &spec.label {
266 format!("install_canister trapped ({label})")
267 } else {
268 "install_canister trapped".to_string()
269 };
270 let _ = catch_unwind(AssertUnwindSafe(|| {
273 pocket_ic.dump_canister_debug(canister_id, &context);
274 }));
275
276 return if let Some(label) = spec.label {
277 Err(CanisterInstallError::labeled(canister_id, label, message))
278 } else {
279 Err(CanisterInstallError::new(canister_id, message))
280 };
281 }
282
283 Ok(canister_id)
284}
285
286fn is_install_code_rate_limited(response: &RejectResponse) -> bool {
287 response.error_code == ErrorCode::CanisterInstallCodeRateLimited
288}
289
290fn retry_install_code_with<T, F, W>(
291 policy: RetryPolicy,
292 mut op: F,
293 mut wait_out_cooldown: W,
294) -> Result<T, RejectResponse>
295where
296 F: FnMut() -> Result<T, RejectResponse>,
297 W: FnMut(),
298{
299 for attempt in 1..=policy.max_attempts() {
300 match op() {
301 Ok(value) => return Ok(value),
302 Err(err) if is_install_code_rate_limited(&err) && attempt < policy.max_attempts() => {
303 wait_out_cooldown();
304 }
305 Err(err) => return Err(err),
306 }
307 }
308
309 unreachable!("RetryPolicy guarantees at least one attempt")
310}
311
312#[cfg(test)]
313mod tests {
314 use std::{cell::Cell, time::Duration};
315
316 use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
317
318 use super::{RetryPolicy, RetryPolicyError, retry_install_code_with};
319
320 fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
321 RejectResponse {
322 reject_code: RejectCode::SysTransient,
323 reject_message: message.to_string(),
324 error_code,
325 certified: false,
326 }
327 }
328
329 #[test]
330 fn retry_policy_counts_the_first_attempt() {
331 let attempts = Cell::new(0);
332 let waits = Cell::new(0);
333 let rate_limited = rejection(
334 ErrorCode::CanisterInstallCodeRateLimited,
335 "install-code rate limit",
336 );
337 let result = retry_install_code_with(
338 RetryPolicy::try_new(3, Duration::from_secs(1)).expect("valid retry policy"),
339 || {
340 attempts.set(attempts.get() + 1);
341 Err::<(), _>(rate_limited.clone())
342 },
343 || waits.set(waits.get() + 1),
344 );
345
346 assert_eq!(result, Err(rate_limited));
347 assert_eq!(attempts.get(), 3);
348 assert_eq!(waits.get(), 2);
349 }
350
351 #[test]
352 fn retry_policy_stops_on_non_rate_limit_failure() {
353 let attempts = Cell::new(0);
354 let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
355 let result = retry_install_code_with(
356 RetryPolicy::try_new(3, Duration::from_secs(1)).expect("valid retry policy"),
357 || {
358 attempts.set(attempts.get() + 1);
359 Err::<(), _>(not_retryable.clone())
360 },
361 || panic!("non-rate-limit failure must not wait"),
362 );
363
364 assert_eq!(result, Err(not_retryable));
365 assert_eq!(attempts.get(), 1);
366 }
367
368 #[test]
369 fn retry_policy_rejects_zero_attempts() {
370 assert_eq!(
371 RetryPolicy::try_new(0, Duration::from_secs(1)),
372 Err(RetryPolicyError::ZeroMaxAttempts)
373 );
374 }
375}