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