1use std::{num::NonZeroUsize, time::Duration};
2
3use candid::Principal;
4use pocket_ic::{ErrorCode, PocketIc, RejectResponse};
5
6use super::{CanisterInstallError, transport::capture_operation};
7
8pub struct InstallSpec {
10 pub wasm: Vec<u8>,
12 pub init_bytes: Vec<u8>,
14 pub cycles: u128,
16 pub install_sender: Option<Principal>,
18 pub label: Option<String>,
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub struct RetryPolicy {
25 pub max_attempts: NonZeroUsize,
27 pub cooldown: Duration,
29}
30
31pub trait CanisterInstallExt {
36 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
43
44 fn try_create_and_install_many<I>(
46 &self,
47 specs: I,
48 ) -> Result<Vec<Principal>, CanisterInstallError>
49 where
50 I: IntoIterator<Item = InstallSpec>;
51
52 fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
55
56 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
62 where
63 F: FnMut() -> Result<T, RejectResponse>;
64}
65
66impl CanisterInstallExt for PocketIc {
67 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
69 let canister_id = capture_operation(|| self.create_canister()).map_err(|source| {
70 CanisterInstallError::CreateCanister {
71 label: spec.label.clone(),
72 source,
73 }
74 })?;
75 if spec.cycles > 0 {
76 capture_operation(|| self.add_cycles(canister_id, spec.cycles)).map_err(|source| {
77 CanisterInstallError::AddCycles {
78 canister_id,
79 label: spec.label.clone(),
80 source,
81 }
82 })?;
83 }
84
85 let install = capture_operation(|| {
87 self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
88 });
89 if let Err(source) = install {
90 return Err(CanisterInstallError::InstallCode {
91 canister_id,
92 label: spec.label,
93 source,
94 });
95 }
96
97 Ok(canister_id)
98 }
99
100 fn try_create_and_install_many<I>(
107 &self,
108 specs: I,
109 ) -> Result<Vec<Principal>, CanisterInstallError>
110 where
111 I: IntoIterator<Item = InstallSpec>,
112 {
113 specs
114 .into_iter()
115 .map(|spec| self.try_create_and_install(spec))
116 .collect()
117 }
118
119 fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
121 super::time::advance_and_tick(self, cooldown, 2);
122 }
123
124 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
125 where
126 F: FnMut() -> Result<T, RejectResponse>,
127 {
128 retry_install_code_with(policy, op, || {
129 self.wait_out_install_code_rate_limit(policy.cooldown);
130 })
131 }
132}
133
134fn retry_install_code_with<T, F, W>(
135 policy: RetryPolicy,
136 mut op: F,
137 mut wait_out_cooldown: W,
138) -> Result<T, RejectResponse>
139where
140 F: FnMut() -> Result<T, RejectResponse>,
141 W: FnMut(),
142{
143 let mut retries = 1..policy.max_attempts.get();
144 loop {
145 match op() {
146 Err(err)
147 if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
148 && retries.next().is_some() =>
149 {
150 wait_out_cooldown();
151 }
152 result => return result,
153 }
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use std::{
160 cell::{Cell, RefCell},
161 num::NonZeroUsize,
162 time::Duration,
163 };
164
165 use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
166
167 use super::{RetryPolicy, retry_install_code_with};
168
169 fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
170 RejectResponse {
171 reject_code: RejectCode::SysTransient,
172 reject_message: message.to_string(),
173 error_code,
174 certified: false,
175 }
176 }
177
178 #[test]
179 fn retry_policy_counts_the_first_attempt() {
180 for max_attempts in [1, 3] {
181 let attempts = Cell::new(0);
182 let waits = Cell::new(0);
183 let result = retry_install_code_with(
184 RetryPolicy {
185 max_attempts: NonZeroUsize::new(max_attempts).unwrap(),
186 cooldown: Duration::from_secs(1),
187 },
188 || {
189 attempts.set(attempts.get() + 1);
190 Err::<(), _>(rejection(
191 ErrorCode::CanisterInstallCodeRateLimited,
192 &format!("rate limit on attempt {}", attempts.get()),
193 ))
194 },
195 || waits.set(waits.get() + 1),
196 );
197
198 assert_eq!(
199 result,
200 Err(rejection(
201 ErrorCode::CanisterInstallCodeRateLimited,
202 &format!("rate limit on attempt {max_attempts}"),
203 ))
204 );
205 assert_eq!(attempts.get(), max_attempts);
206 assert_eq!(waits.get(), max_attempts - 1);
207 }
208 }
209
210 #[test]
211 fn retry_policy_waits_only_between_retryable_attempts() {
212 for success_attempt in [1, 3] {
213 let events = RefCell::new(Vec::new());
214 let attempts = Cell::new(0);
215 let result = retry_install_code_with(
216 RetryPolicy {
217 max_attempts: NonZeroUsize::new(3).unwrap(),
218 cooldown: Duration::from_secs(1),
219 },
220 || {
221 events.borrow_mut().push("attempt");
222 attempts.set(attempts.get() + 1);
223 if attempts.get() == success_attempt {
224 Ok(42)
225 } else {
226 Err(rejection(
227 ErrorCode::CanisterInstallCodeRateLimited,
228 "rate limit",
229 ))
230 }
231 },
232 || events.borrow_mut().push("wait"),
233 );
234 assert_eq!(result, Ok(42));
235 assert_eq!(attempts.get(), success_attempt);
236 assert_eq!(
237 events.into_inner(),
238 if success_attempt == 1 {
239 vec!["attempt"]
240 } else {
241 vec!["attempt", "wait", "attempt", "wait", "attempt"]
242 }
243 );
244 }
245 }
246
247 #[test]
248 fn retry_policy_stops_on_non_rate_limit_failure() {
249 let attempts = Cell::new(0);
250 let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
251 let result = retry_install_code_with(
252 RetryPolicy {
253 max_attempts: NonZeroUsize::new(3).unwrap(),
254 cooldown: Duration::from_secs(1),
255 },
256 || {
257 attempts.set(attempts.get() + 1);
258 Err::<(), _>(not_retryable.clone())
259 },
260 || panic!("non-rate-limit failure must not wait"),
261 );
262
263 assert_eq!(result, Err(not_retryable));
264 assert_eq!(attempts.get(), 1);
265 }
266}