1use std::{num::NonZeroUsize, 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: NonZeroUsize,
58 cooldown: Duration,
59}
60
61impl RetryPolicy {
62 #[must_use]
64 pub const fn new(max_attempts: NonZeroUsize, cooldown: Duration) -> Self {
65 Self {
66 max_attempts,
67 cooldown,
68 }
69 }
70
71 #[must_use]
73 pub const fn max_attempts(self) -> NonZeroUsize {
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 {
89 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
94
95 fn try_create_and_install_many<I>(
97 &self,
98 specs: I,
99 ) -> Result<Vec<Principal>, CanisterInstallError>
100 where
101 I: IntoIterator<Item = InstallSpec>;
102
103 fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
105
106 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
112 where
113 F: FnMut() -> Result<T, RejectResponse>;
114}
115
116impl CanisterInstallExt for PocketIc {
117 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
119 let canister_id =
120 try_install_step(CanisterInstallPhase::CreateCanister, None, &spec, || {
121 self.create_canister()
122 })?;
123 let diagnostic_sender = spec.install_sender.unwrap_or_else(Principal::anonymous);
124 if spec.cycles > 0 {
125 try_install_step(
126 CanisterInstallPhase::AddCycles,
127 Some(canister_id),
128 &spec,
129 || self.add_cycles(canister_id, spec.cycles),
130 )?;
131 }
132
133 let install = capture_operation(|| {
135 self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
136 });
137 if let Err(source) = install {
138 let context = if let Some(label) = &spec.label {
139 format!("install_canister trapped ({label})")
140 } else {
141 "install_canister trapped".to_string()
142 };
143 let _ = capture_operation(|| {
146 let report = self.collect_canister_diagnostics(CanisterDiagnosticsRequest::new(
147 canister_id,
148 diagnostic_sender,
149 diagnostic_sender,
150 ));
151 eprintln!("{context}: {report}");
152 });
153
154 return Err(CanisterInstallError::new(
155 CanisterInstallPhase::InstallCode,
156 Some(canister_id),
157 spec.label,
158 source,
159 ));
160 }
161
162 Ok(canister_id)
163 }
164
165 fn try_create_and_install_many<I>(
172 &self,
173 specs: I,
174 ) -> Result<Vec<Principal>, CanisterInstallError>
175 where
176 I: IntoIterator<Item = InstallSpec>,
177 {
178 specs
179 .into_iter()
180 .map(|spec| self.try_create_and_install(spec))
181 .collect()
182 }
183
184 fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
186 self.advance_time(cooldown);
187 self.tick();
188 self.tick();
189 }
190
191 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
192 where
193 F: FnMut() -> Result<T, RejectResponse>,
194 {
195 retry_install_code_with(policy, op, || {
196 self.wait_out_install_code_rate_limit(policy.cooldown());
197 })
198 }
199}
200
201fn try_install_step<T>(
202 phase: CanisterInstallPhase,
203 canister_id: Option<Principal>,
204 spec: &InstallSpec,
205 operation: impl FnOnce() -> T,
206) -> Result<T, CanisterInstallError> {
207 capture_operation(operation)
208 .map_err(|source| CanisterInstallError::new(phase, canister_id, spec.label.clone(), source))
209}
210
211fn retry_install_code_with<T, F, W>(
212 policy: RetryPolicy,
213 mut op: F,
214 mut wait_out_cooldown: W,
215) -> Result<T, RejectResponse>
216where
217 F: FnMut() -> Result<T, RejectResponse>,
218 W: FnMut(),
219{
220 let mut retries = 1..policy.max_attempts().get();
221 loop {
222 match op() {
223 Err(err)
224 if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
225 && retries.next().is_some() =>
226 {
227 wait_out_cooldown();
228 }
229 result => return result,
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use std::{
237 cell::{Cell, RefCell},
238 num::NonZeroUsize,
239 time::Duration,
240 };
241
242 use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
243
244 use super::{RetryPolicy, retry_install_code_with};
245
246 fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
247 RejectResponse {
248 reject_code: RejectCode::SysTransient,
249 reject_message: message.to_string(),
250 error_code,
251 certified: false,
252 }
253 }
254
255 #[test]
256 fn retry_policy_counts_the_first_attempt() {
257 for max_attempts in [1, 3] {
258 let attempts = Cell::new(0);
259 let waits = Cell::new(0);
260 let result = retry_install_code_with(
261 RetryPolicy::new(
262 NonZeroUsize::new(max_attempts).unwrap(),
263 Duration::from_secs(1),
264 ),
265 || {
266 attempts.set(attempts.get() + 1);
267 Err::<(), _>(rejection(
268 ErrorCode::CanisterInstallCodeRateLimited,
269 &format!("rate limit on attempt {}", attempts.get()),
270 ))
271 },
272 || waits.set(waits.get() + 1),
273 );
274
275 assert_eq!(
276 result,
277 Err(rejection(
278 ErrorCode::CanisterInstallCodeRateLimited,
279 &format!("rate limit on attempt {max_attempts}"),
280 ))
281 );
282 assert_eq!(attempts.get(), max_attempts);
283 assert_eq!(waits.get(), max_attempts - 1);
284 }
285 }
286
287 #[test]
288 fn retry_policy_waits_only_between_retryable_attempts() {
289 for success_attempt in [1, 3] {
290 let events = RefCell::new(Vec::new());
291 let attempts = Cell::new(0);
292 let result = retry_install_code_with(
293 RetryPolicy::new(NonZeroUsize::new(3).unwrap(), Duration::from_secs(1)),
294 || {
295 events.borrow_mut().push("attempt");
296 attempts.set(attempts.get() + 1);
297 if attempts.get() == success_attempt {
298 Ok(42)
299 } else {
300 Err(rejection(
301 ErrorCode::CanisterInstallCodeRateLimited,
302 "rate limit",
303 ))
304 }
305 },
306 || events.borrow_mut().push("wait"),
307 );
308 assert_eq!(result, Ok(42));
309 assert_eq!(attempts.get(), success_attempt);
310 assert_eq!(
311 events.into_inner(),
312 if success_attempt == 1 {
313 vec!["attempt"]
314 } else {
315 vec!["attempt", "wait", "attempt", "wait", "attempt"]
316 }
317 );
318 }
319 }
320
321 #[test]
322 fn retry_policy_stops_on_non_rate_limit_failure() {
323 let attempts = Cell::new(0);
324 let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
325 let result = retry_install_code_with(
326 RetryPolicy::new(NonZeroUsize::new(3).unwrap(), Duration::from_secs(1)),
327 || {
328 attempts.set(attempts.get() + 1);
329 Err::<(), _>(not_retryable.clone())
330 },
331 || panic!("non-rate-limit failure must not wait"),
332 );
333
334 assert_eq!(result, Err(not_retryable));
335 assert_eq!(attempts.get(), 1);
336 }
337}