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