1use std::{num::NonZeroUsize, time::Duration};
2
3use crate::pocket_ic::{ClientError, ErrorCode, PocketIc};
4use ic_platform_types::principal::ExternalPrincipal as Principal;
5
6use super::CanisterInstallError;
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>;
46
47 fn wait_out_install_code_rate_limit(&self, cooldown: Duration) -> Result<(), ClientError>;
53
54 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, ClientError>
64 where
65 F: FnMut() -> Result<T, ClientError>;
66}
67
68impl CanisterInstallExt for PocketIc {
69 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
71 let canister_id =
72 self.create_canister()
73 .map_err(|source| CanisterInstallError::CreateCanister {
74 label: spec.label.clone(),
75 source,
76 })?;
77 if spec.cycles > 0 {
78 self.add_cycles(canister_id, spec.cycles)
79 .map_err(|source| CanisterInstallError::AddCycles {
80 canister_id,
81 label: spec.label.clone(),
82 source,
83 })?;
84 }
85
86 self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender)
88 .map_err(|source| CanisterInstallError::InstallCode {
89 canister_id,
90 label: spec.label,
91 source,
92 })?;
93
94 Ok(canister_id)
95 }
96
97 fn wait_out_install_code_rate_limit(&self, cooldown: Duration) -> Result<(), ClientError> {
99 super::time::advance_and_tick(self, cooldown, 2)
100 }
101
102 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, ClientError>
103 where
104 F: FnMut() -> Result<T, ClientError>,
105 {
106 retry_install_code_with(policy, op, || {
107 self.wait_out_install_code_rate_limit(policy.cooldown)
108 })
109 }
110}
111
112fn retry_install_code_with<T, F, W>(
113 policy: RetryPolicy,
114 mut op: F,
115 mut wait_out_cooldown: W,
116) -> Result<T, ClientError>
117where
118 F: FnMut() -> Result<T, ClientError>,
119 W: FnMut() -> Result<(), ClientError>,
120{
121 let mut retries = 1..policy.max_attempts.get();
122 loop {
123 match op() {
124 Err(ClientError::Canister(err))
125 if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
126 && retries.next().is_some() =>
127 {
128 wait_out_cooldown()?;
129 }
130 result => return result,
131 }
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use std::{
138 cell::{Cell, RefCell},
139 num::NonZeroUsize,
140 time::Duration,
141 };
142
143 use crate::pocket_ic::{ErrorCode, RejectCode, RejectResponse};
144
145 use super::{ClientError, RetryPolicy, retry_install_code_with};
146
147 fn rejection(error_code: ErrorCode, message: &str) -> ClientError {
148 ClientError::Canister(RejectResponse {
149 reject_code: RejectCode::SysTransient,
150 reject_message: message.to_string(),
151 error_code,
152 certified: false,
153 })
154 }
155
156 #[test]
157 fn retry_policy_counts_the_first_attempt() {
158 for max_attempts in [1, 3] {
159 let attempts = Cell::new(0);
160 let waits = Cell::new(0);
161 let result = retry_install_code_with(
162 RetryPolicy {
163 max_attempts: NonZeroUsize::new(max_attempts).unwrap(),
164 cooldown: Duration::from_secs(1),
165 },
166 || {
167 attempts.set(attempts.get() + 1);
168 Err::<(), _>(rejection(
169 ErrorCode::CanisterInstallCodeRateLimited,
170 &format!("rate limit on attempt {}", attempts.get()),
171 ))
172 },
173 || {
174 waits.set(waits.get() + 1);
175 Ok(())
176 },
177 );
178
179 assert_eq!(
180 result.unwrap_err().to_string(),
181 rejection(
182 ErrorCode::CanisterInstallCodeRateLimited,
183 &format!("rate limit on attempt {max_attempts}"),
184 )
185 .to_string()
186 );
187 assert_eq!(attempts.get(), max_attempts);
188 assert_eq!(waits.get(), max_attempts - 1);
189 }
190 }
191
192 #[test]
193 fn retry_policy_waits_only_between_retryable_attempts() {
194 for success_attempt in [1, 3] {
195 let events = RefCell::new(Vec::new());
196 let attempts = Cell::new(0);
197 let result = retry_install_code_with(
198 RetryPolicy {
199 max_attempts: NonZeroUsize::new(3).unwrap(),
200 cooldown: Duration::from_secs(1),
201 },
202 || {
203 events.borrow_mut().push("attempt");
204 attempts.set(attempts.get() + 1);
205 if attempts.get() == success_attempt {
206 Ok(42)
207 } else {
208 Err(rejection(
209 ErrorCode::CanisterInstallCodeRateLimited,
210 "rate limit",
211 ))
212 }
213 },
214 || {
215 events.borrow_mut().push("wait");
216 Ok(())
217 },
218 );
219 assert_eq!(result.unwrap(), 42);
220 assert_eq!(attempts.get(), success_attempt);
221 assert_eq!(
222 events.into_inner(),
223 if success_attempt == 1 {
224 vec!["attempt"]
225 } else {
226 vec!["attempt", "wait", "attempt", "wait", "attempt"]
227 }
228 );
229 }
230 }
231
232 #[test]
233 fn retry_policy_stops_on_non_rate_limit_failure() {
234 let attempts = Cell::new(0);
235 let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
236 let result = retry_install_code_with(
237 RetryPolicy {
238 max_attempts: NonZeroUsize::new(3).unwrap(),
239 cooldown: Duration::from_secs(1),
240 },
241 || {
242 attempts.set(attempts.get() + 1);
243 Err::<(), _>(rejection(
244 ErrorCode::CanisterRejectedMessage,
245 "not retryable",
246 ))
247 },
248 || panic!("non-rate-limit failure must not wait"),
249 );
250
251 assert_eq!(result.unwrap_err().to_string(), not_retryable.to_string());
252 assert_eq!(attempts.get(), 1);
253 }
254
255 #[test]
256 fn client_and_cooldown_failures_stop_without_replaying_installation() {
257 for fail_during_cooldown in [false, true] {
258 let attempts = Cell::new(0);
259 let error = retry_install_code_with(
260 RetryPolicy {
261 max_attempts: NonZeroUsize::new(3).unwrap(),
262 cooldown: Duration::from_secs(1),
263 },
264 || {
265 attempts.set(attempts.get() + 1);
266 Err::<(), _>(if fail_during_cooldown {
267 rejection(ErrorCode::CanisterInstallCodeRateLimited, "rate limit")
268 } else {
269 ClientError::Protocol("uncertain operation".into())
270 })
271 },
272 || Err(ClientError::Protocol("cooldown failed".into())),
273 )
274 .unwrap_err();
275 assert_eq!(attempts.get(), 1);
276 assert!(
277 matches!(error, ClientError::Protocol(message) if message == if fail_during_cooldown { "cooldown failed" } else { "uncertain operation" })
278 );
279 }
280 }
281}