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 wait_out_install_code_rate_limit(&self, cooldown: Duration);
47
48 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
54 where
55 F: FnMut() -> Result<T, RejectResponse>;
56}
57
58impl CanisterInstallExt for PocketIc {
59 fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
61 let canister_id = capture_operation(|| self.create_canister()).map_err(|source| {
62 CanisterInstallError::CreateCanister {
63 label: spec.label.clone(),
64 source,
65 }
66 })?;
67 if spec.cycles > 0 {
68 capture_operation(|| self.add_cycles(canister_id, spec.cycles)).map_err(|source| {
69 CanisterInstallError::AddCycles {
70 canister_id,
71 label: spec.label.clone(),
72 source,
73 }
74 })?;
75 }
76
77 let install = capture_operation(|| {
79 self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
80 });
81 if let Err(source) = install {
82 return Err(CanisterInstallError::InstallCode {
83 canister_id,
84 label: spec.label,
85 source,
86 });
87 }
88
89 Ok(canister_id)
90 }
91
92 fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
94 super::time::advance_and_tick(self, cooldown, 2);
95 }
96
97 fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
98 where
99 F: FnMut() -> Result<T, RejectResponse>,
100 {
101 retry_install_code_with(policy, op, || {
102 self.wait_out_install_code_rate_limit(policy.cooldown);
103 })
104 }
105}
106
107fn retry_install_code_with<T, F, W>(
108 policy: RetryPolicy,
109 mut op: F,
110 mut wait_out_cooldown: W,
111) -> Result<T, RejectResponse>
112where
113 F: FnMut() -> Result<T, RejectResponse>,
114 W: FnMut(),
115{
116 let mut retries = 1..policy.max_attempts.get();
117 loop {
118 match op() {
119 Err(err)
120 if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
121 && retries.next().is_some() =>
122 {
123 wait_out_cooldown();
124 }
125 result => return result,
126 }
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use std::{
133 cell::{Cell, RefCell},
134 num::NonZeroUsize,
135 time::Duration,
136 };
137
138 use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
139
140 use super::{RetryPolicy, retry_install_code_with};
141
142 fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
143 RejectResponse {
144 reject_code: RejectCode::SysTransient,
145 reject_message: message.to_string(),
146 error_code,
147 certified: false,
148 }
149 }
150
151 #[test]
152 fn retry_policy_counts_the_first_attempt() {
153 for max_attempts in [1, 3] {
154 let attempts = Cell::new(0);
155 let waits = Cell::new(0);
156 let result = retry_install_code_with(
157 RetryPolicy {
158 max_attempts: NonZeroUsize::new(max_attempts).unwrap(),
159 cooldown: Duration::from_secs(1),
160 },
161 || {
162 attempts.set(attempts.get() + 1);
163 Err::<(), _>(rejection(
164 ErrorCode::CanisterInstallCodeRateLimited,
165 &format!("rate limit on attempt {}", attempts.get()),
166 ))
167 },
168 || waits.set(waits.get() + 1),
169 );
170
171 assert_eq!(
172 result,
173 Err(rejection(
174 ErrorCode::CanisterInstallCodeRateLimited,
175 &format!("rate limit on attempt {max_attempts}"),
176 ))
177 );
178 assert_eq!(attempts.get(), max_attempts);
179 assert_eq!(waits.get(), max_attempts - 1);
180 }
181 }
182
183 #[test]
184 fn retry_policy_waits_only_between_retryable_attempts() {
185 for success_attempt in [1, 3] {
186 let events = RefCell::new(Vec::new());
187 let attempts = Cell::new(0);
188 let result = retry_install_code_with(
189 RetryPolicy {
190 max_attempts: NonZeroUsize::new(3).unwrap(),
191 cooldown: Duration::from_secs(1),
192 },
193 || {
194 events.borrow_mut().push("attempt");
195 attempts.set(attempts.get() + 1);
196 if attempts.get() == success_attempt {
197 Ok(42)
198 } else {
199 Err(rejection(
200 ErrorCode::CanisterInstallCodeRateLimited,
201 "rate limit",
202 ))
203 }
204 },
205 || events.borrow_mut().push("wait"),
206 );
207 assert_eq!(result, Ok(42));
208 assert_eq!(attempts.get(), success_attempt);
209 assert_eq!(
210 events.into_inner(),
211 if success_attempt == 1 {
212 vec!["attempt"]
213 } else {
214 vec!["attempt", "wait", "attempt", "wait", "attempt"]
215 }
216 );
217 }
218 }
219
220 #[test]
221 fn retry_policy_stops_on_non_rate_limit_failure() {
222 let attempts = Cell::new(0);
223 let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
224 let result = retry_install_code_with(
225 RetryPolicy {
226 max_attempts: NonZeroUsize::new(3).unwrap(),
227 cooldown: Duration::from_secs(1),
228 },
229 || {
230 attempts.set(attempts.get() + 1);
231 Err::<(), _>(not_retryable.clone())
232 },
233 || panic!("non-rate-limit failure must not wait"),
234 );
235
236 assert_eq!(result, Err(not_retryable));
237 assert_eq!(attempts.get(), 1);
238 }
239}