1use std::panic::{AssertUnwindSafe, catch_unwind};
2use std::time::Duration;
3
4use candid::Principal;
5use pocket_ic::{ErrorCode, PocketIc, RejectResponse};
6
7use super::{
8 CanisterDiagnosticsRequest, CanisterInstallError, CanisterInstallPhase, PocketIcDiagnosticsExt,
9 PocketIcOperationError,
10};
11
12#[non_exhaustive]
14pub struct InstallSpec {
15 pub wasm: Vec<u8>,
17 pub init_bytes: Vec<u8>,
19 pub cycles: u128,
21 pub install_sender: Option<Principal>,
23 pub label: Option<String>,
25}
26
27impl InstallSpec {
28 #[must_use]
30 pub const fn new(wasm: Vec<u8>, init_bytes: Vec<u8>, cycles: u128) -> Self {
31 Self {
32 wasm,
33 init_bytes,
34 cycles,
35 install_sender: None,
36 label: None,
37 }
38 }
39
40 #[must_use]
42 pub const fn install_sender(mut self, sender: Principal) -> Self {
43 self.install_sender = Some(sender);
44 self
45 }
46
47 #[must_use]
49 pub fn label(mut self, label: impl Into<String>) -> Self {
50 self.label = Some(label.into());
51 self
52 }
53}
54
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub struct RetryPolicy {
58 max_attempts: usize,
59 cooldown: Duration,
60}
61
62#[non_exhaustive]
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub enum RetryPolicyError {
66 ZeroMaxAttempts,
68}
69
70impl std::fmt::Display for RetryPolicyError {
71 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::ZeroMaxAttempts => {
74 formatter.write_str("retry policy requires at least one attempt")
75 }
76 }
77 }
78}
79
80impl std::error::Error for RetryPolicyError {}
81
82impl RetryPolicy {
83 pub const fn try_new(
85 max_attempts: usize,
86 cooldown: Duration,
87 ) -> Result<Self, RetryPolicyError> {
88 if max_attempts == 0 {
89 Err(RetryPolicyError::ZeroMaxAttempts)
90 } else {
91 Ok(Self {
92 max_attempts,
93 cooldown,
94 })
95 }
96 }
97
98 #[must_use]
100 pub const fn max_attempts(self) -> usize {
101 self.max_attempts
102 }
103
104 #[must_use]
106 pub const fn cooldown(self) -> Duration {
107 self.cooldown
108 }
109}
110
111pub trait CanisterInstallExt {
116 #[must_use]
118 fn create_and_install(&self, spec: InstallSpec) -> Principal;
119
120 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
125
126 #[must_use]
128 fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
129 where
130 I: IntoIterator<Item = InstallSpec>;
131
132 fn try_create_and_install_many<I>(
134 &self,
135 specs: I,
136 ) -> Result<Vec<Principal>, CanisterInstallError>
137 where
138 I: IntoIterator<Item = InstallSpec>;
139
140 fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
142
143 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
149 where
150 F: FnMut() -> Result<T, RejectResponse>;
151}
152
153impl CanisterInstallExt for PocketIc {
154 fn create_and_install(&self, spec: InstallSpec) -> Principal {
156 self.try_create_and_install(spec)
157 .unwrap_or_else(|err| panic!("{err}"))
158 }
159
160 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
162 let canister_id =
163 try_install_step(CanisterInstallPhase::CreateCanister, None, &spec, || {
164 self.create_canister()
165 })?;
166 let diagnostic_sender = spec.install_sender.unwrap_or_else(Principal::anonymous);
167 if spec.cycles > 0 {
168 try_install_step(
169 CanisterInstallPhase::AddCycles,
170 Some(canister_id),
171 &spec,
172 || self.add_cycles(canister_id, spec.cycles),
173 )?;
174 }
175
176 let install = catch_unwind(AssertUnwindSafe(|| {
178 self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
179 }));
180 if let Err(payload) = install {
181 let source = PocketIcOperationError::from_panic(payload.as_ref());
182 let context = if let Some(label) = &spec.label {
183 format!("install_canister trapped ({label})")
184 } else {
185 "install_canister trapped".to_string()
186 };
187 let _ = catch_unwind(AssertUnwindSafe(|| {
190 let report = self.collect_canister_diagnostics(CanisterDiagnosticsRequest::new(
191 canister_id,
192 diagnostic_sender,
193 diagnostic_sender,
194 ));
195 eprintln!("{context}: {report}");
196 }));
197
198 return Err(CanisterInstallError::new(
199 CanisterInstallPhase::InstallCode,
200 Some(canister_id),
201 spec.label,
202 source,
203 ));
204 }
205
206 Ok(canister_id)
207 }
208
209 fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
216 where
217 I: IntoIterator<Item = InstallSpec>,
218 {
219 self.try_create_and_install_many(specs)
220 .unwrap_or_else(|err| panic!("{err}"))
221 }
222
223 fn try_create_and_install_many<I>(
230 &self,
231 specs: I,
232 ) -> Result<Vec<Principal>, CanisterInstallError>
233 where
234 I: IntoIterator<Item = InstallSpec>,
235 {
236 specs
237 .into_iter()
238 .map(|spec| self.try_create_and_install(spec))
239 .collect()
240 }
241
242 fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
244 self.advance_time(cooldown);
245 self.tick();
246 self.tick();
247 }
248
249 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
250 where
251 F: FnMut() -> Result<T, RejectResponse>,
252 {
253 retry_install_code_with(policy, op, || {
254 self.wait_out_install_code_rate_limit(policy.cooldown());
255 })
256 }
257}
258
259fn try_install_step<T>(
260 phase: CanisterInstallPhase,
261 canister_id: Option<Principal>,
262 spec: &InstallSpec,
263 operation: impl FnOnce() -> T,
264) -> Result<T, CanisterInstallError> {
265 catch_unwind(AssertUnwindSafe(operation)).map_err(|payload| {
266 CanisterInstallError::new(
267 phase,
268 canister_id,
269 spec.label.clone(),
270 PocketIcOperationError::from_panic(payload.as_ref()),
271 )
272 })
273}
274
275fn retry_install_code_with<T, F, W>(
276 policy: RetryPolicy,
277 mut op: F,
278 mut wait_out_cooldown: W,
279) -> Result<T, RejectResponse>
280where
281 F: FnMut() -> Result<T, RejectResponse>,
282 W: FnMut(),
283{
284 let mut retries = 1..policy.max_attempts();
285 loop {
286 match op() {
287 Err(err)
288 if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
289 && retries.next().is_some() =>
290 {
291 wait_out_cooldown();
292 }
293 result => return result,
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use std::{
301 cell::{Cell, RefCell},
302 time::Duration,
303 };
304
305 use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
306
307 use super::{RetryPolicy, RetryPolicyError, retry_install_code_with};
308
309 fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
310 RejectResponse {
311 reject_code: RejectCode::SysTransient,
312 reject_message: message.to_string(),
313 error_code,
314 certified: false,
315 }
316 }
317
318 #[test]
319 fn retry_policy_counts_the_first_attempt() {
320 for max_attempts in [1, 3] {
321 let attempts = Cell::new(0);
322 let waits = Cell::new(0);
323 let result = retry_install_code_with(
324 RetryPolicy::try_new(max_attempts, Duration::from_secs(1))
325 .expect("valid retry policy"),
326 || {
327 attempts.set(attempts.get() + 1);
328 Err::<(), _>(rejection(
329 ErrorCode::CanisterInstallCodeRateLimited,
330 &format!("rate limit on attempt {}", attempts.get()),
331 ))
332 },
333 || waits.set(waits.get() + 1),
334 );
335
336 assert_eq!(
337 result,
338 Err(rejection(
339 ErrorCode::CanisterInstallCodeRateLimited,
340 &format!("rate limit on attempt {max_attempts}"),
341 ))
342 );
343 assert_eq!(attempts.get(), max_attempts);
344 assert_eq!(waits.get(), max_attempts - 1);
345 }
346 }
347
348 #[test]
349 fn retry_policy_waits_only_between_retryable_attempts() {
350 for success_attempt in [1, 3] {
351 let events = RefCell::new(Vec::new());
352 let attempts = Cell::new(0);
353 let result = retry_install_code_with(
354 RetryPolicy::try_new(3, Duration::from_secs(1)).unwrap(),
355 || {
356 events.borrow_mut().push("attempt");
357 attempts.set(attempts.get() + 1);
358 if attempts.get() == success_attempt {
359 Ok(42)
360 } else {
361 Err(rejection(
362 ErrorCode::CanisterInstallCodeRateLimited,
363 "rate limit",
364 ))
365 }
366 },
367 || events.borrow_mut().push("wait"),
368 );
369 assert_eq!(result, Ok(42));
370 assert_eq!(attempts.get(), success_attempt);
371 assert_eq!(
372 events.into_inner(),
373 if success_attempt == 1 {
374 vec!["attempt"]
375 } else {
376 vec!["attempt", "wait", "attempt", "wait", "attempt"]
377 }
378 );
379 }
380 }
381
382 #[test]
383 fn retry_policy_stops_on_non_rate_limit_failure() {
384 let attempts = Cell::new(0);
385 let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
386 let result = retry_install_code_with(
387 RetryPolicy::try_new(3, Duration::from_secs(1)).expect("valid retry policy"),
388 || {
389 attempts.set(attempts.get() + 1);
390 Err::<(), _>(not_retryable.clone())
391 },
392 || panic!("non-rate-limit failure must not wait"),
393 );
394
395 assert_eq!(result, Err(not_retryable));
396 assert_eq!(attempts.get(), 1);
397 }
398
399 #[test]
400 fn retry_policy_rejects_zero_attempts() {
401 assert_eq!(
402 RetryPolicy::try_new(0, Duration::from_secs(1)),
403 Err(RetryPolicyError::ZeroMaxAttempts)
404 );
405 }
406}