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
57#[non_exhaustive]
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum RetryPolicyError {
61 ZeroMaxAttempts,
63}
64
65impl std::fmt::Display for RetryPolicyError {
66 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 Self::ZeroMaxAttempts => {
69 formatter.write_str("retry policy requires at least one attempt")
70 }
71 }
72 }
73}
74
75impl std::error::Error for RetryPolicyError {}
76
77impl RetryPolicy {
78 pub const fn try_new(
80 max_attempts: usize,
81 cooldown: Duration,
82 ) -> Result<Self, RetryPolicyError> {
83 if max_attempts == 0 {
84 Err(RetryPolicyError::ZeroMaxAttempts)
85 } else {
86 Ok(Self {
87 max_attempts,
88 cooldown,
89 })
90 }
91 }
92
93 #[must_use]
95 pub const fn max_attempts(self) -> usize {
96 self.max_attempts
97 }
98
99 #[must_use]
101 pub const fn cooldown(self) -> Duration {
102 self.cooldown
103 }
104}
105
106pub trait CanisterInstallExt {
108 #[must_use]
110 fn create_and_install_with_args(
111 &self,
112 wasm: Vec<u8>,
113 init_bytes: Vec<u8>,
114 install_cycles: u128,
115 ) -> Principal;
116
117 fn try_create_and_install_with_args(
119 &self,
120 wasm: Vec<u8>,
121 init_bytes: Vec<u8>,
122 install_cycles: u128,
123 ) -> Result<Principal, CanisterInstallError>;
124
125 #[must_use]
127 fn create_and_install(&self, spec: InstallSpec) -> Principal;
128
129 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
131
132 #[must_use]
134 fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
135 where
136 I: IntoIterator<Item = InstallSpec>;
137
138 fn try_create_and_install_many<I>(
140 &self,
141 specs: I,
142 ) -> Result<Vec<Principal>, CanisterInstallError>
143 where
144 I: IntoIterator<Item = InstallSpec>;
145
146 fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
148
149 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
151 where
152 F: FnMut() -> Result<T, RejectResponse>;
153}
154
155impl CanisterInstallExt for PocketIc {
156 fn create_and_install_with_args(
161 &self,
162 wasm: Vec<u8>,
163 init_bytes: Vec<u8>,
164 install_cycles: u128,
165 ) -> Principal {
166 self.try_create_and_install_with_args(wasm, init_bytes, install_cycles)
167 .unwrap_or_else(|err| panic!("{err}"))
168 }
169
170 fn try_create_and_install_with_args(
172 &self,
173 wasm: Vec<u8>,
174 init_bytes: Vec<u8>,
175 install_cycles: u128,
176 ) -> Result<Principal, CanisterInstallError> {
177 self.try_create_and_install(InstallSpec::new(wasm, init_bytes, install_cycles))
178 }
179
180 fn create_and_install(&self, spec: InstallSpec) -> Principal {
182 self.try_create_and_install(spec)
183 .unwrap_or_else(|err| panic!("{err}"))
184 }
185
186 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
188 try_create_funded_and_install(self, spec)
189 }
190
191 fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
198 where
199 I: IntoIterator<Item = InstallSpec>,
200 {
201 self.try_create_and_install_many(specs)
202 .unwrap_or_else(|err| panic!("{err}"))
203 }
204
205 fn try_create_and_install_many<I>(
212 &self,
213 specs: I,
214 ) -> Result<Vec<Principal>, CanisterInstallError>
215 where
216 I: IntoIterator<Item = InstallSpec>,
217 {
218 specs
219 .into_iter()
220 .map(|spec| self.try_create_and_install(spec))
221 .collect()
222 }
223
224 fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
226 self.advance_time(cooldown);
227 self.tick();
228 self.tick();
229 }
230
231 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
232 where
233 F: FnMut() -> Result<T, RejectResponse>,
234 {
235 retry_install_code_with(policy, op, || {
236 self.wait_out_install_code_rate_limit(policy.cooldown());
237 })
238 }
239}
240
241fn try_create_funded_and_install(
243 pocket_ic: &PocketIc,
244 spec: InstallSpec,
245) -> Result<Principal, CanisterInstallError> {
246 let canister_id = pocket_ic.create_canister();
247 if spec.cycles > 0 {
248 let _ = pocket_ic.add_cycles(canister_id, spec.cycles);
249 }
250
251 let install = catch_unwind(AssertUnwindSafe(|| {
252 pocket_ic.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
253 }));
254 if let Err(payload) = install {
255 let message = transport::panic_payload_to_string(payload.as_ref());
256 let context = if let Some(label) = &spec.label {
257 format!("install_canister trapped ({label})")
258 } else {
259 "install_canister trapped".to_string()
260 };
261 let _ = catch_unwind(AssertUnwindSafe(|| {
264 pocket_ic.dump_canister_debug(canister_id, &context);
265 }));
266
267 return if let Some(label) = spec.label {
268 Err(CanisterInstallError::labeled(canister_id, label, message))
269 } else {
270 Err(CanisterInstallError::new(canister_id, message))
271 };
272 }
273
274 Ok(canister_id)
275}
276
277fn is_install_code_rate_limited(response: &RejectResponse) -> bool {
278 response.error_code == ErrorCode::CanisterInstallCodeRateLimited
279}
280
281fn retry_install_code_with<T, F, W>(
282 policy: RetryPolicy,
283 mut op: F,
284 mut wait_out_cooldown: W,
285) -> Result<T, RejectResponse>
286where
287 F: FnMut() -> Result<T, RejectResponse>,
288 W: FnMut(),
289{
290 for attempt in 1..=policy.max_attempts() {
291 match op() {
292 Ok(value) => return Ok(value),
293 Err(err) if is_install_code_rate_limited(&err) && attempt < policy.max_attempts() => {
294 wait_out_cooldown();
295 }
296 Err(err) => return Err(err),
297 }
298 }
299
300 unreachable!("RetryPolicy guarantees at least one attempt")
301}
302
303#[cfg(test)]
304mod tests {
305 use std::{cell::Cell, time::Duration};
306
307 use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
308
309 use super::{RetryPolicy, RetryPolicyError, retry_install_code_with};
310
311 fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
312 RejectResponse {
313 reject_code: RejectCode::SysTransient,
314 reject_message: message.to_string(),
315 error_code,
316 certified: false,
317 }
318 }
319
320 #[test]
321 fn retry_policy_counts_the_first_attempt() {
322 let attempts = Cell::new(0);
323 let waits = Cell::new(0);
324 let rate_limited = rejection(
325 ErrorCode::CanisterInstallCodeRateLimited,
326 "install-code rate limit",
327 );
328 let result = retry_install_code_with(
329 RetryPolicy::try_new(3, Duration::from_secs(1)).expect("valid retry policy"),
330 || {
331 attempts.set(attempts.get() + 1);
332 Err::<(), _>(rate_limited.clone())
333 },
334 || waits.set(waits.get() + 1),
335 );
336
337 assert_eq!(result, Err(rate_limited));
338 assert_eq!(attempts.get(), 3);
339 assert_eq!(waits.get(), 2);
340 }
341
342 #[test]
343 fn retry_policy_stops_on_non_rate_limit_failure() {
344 let attempts = Cell::new(0);
345 let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
346 let result = retry_install_code_with(
347 RetryPolicy::try_new(3, Duration::from_secs(1)).expect("valid retry policy"),
348 || {
349 attempts.set(attempts.get() + 1);
350 Err::<(), _>(not_retryable.clone())
351 },
352 || panic!("non-rate-limit failure must not wait"),
353 );
354
355 assert_eq!(result, Err(not_retryable));
356 assert_eq!(attempts.get(), 1);
357 }
358
359 #[test]
360 fn retry_policy_rejects_zero_attempts() {
361 assert_eq!(
362 RetryPolicy::try_new(0, Duration::from_secs(1)),
363 Err(RetryPolicyError::ZeroMaxAttempts)
364 );
365 }
366}