ic_testkit/pic/
transport.rs1use std::any::Any;
2
3#[derive(Clone, Debug, Eq, PartialEq)]
5pub struct PocketIcOperationError {
6 message: String,
7 transport: bool,
8}
9
10impl PocketIcOperationError {
11 #[must_use]
13 pub fn new(message: impl Into<String>) -> Self {
14 let message = message.into();
15 let transport = message_is_dead_instance_transport_error(&message);
16 Self { message, transport }
17 }
18
19 pub(super) fn from_panic(payload: &(dyn Any + Send)) -> Self {
20 Self::new(panic_payload_to_string(payload))
21 }
22
23 #[must_use]
25 pub fn message(&self) -> &str {
26 &self.message
27 }
28
29 #[must_use]
31 pub const fn is_transport(&self) -> bool {
32 self.transport
33 }
34}
35
36impl std::fmt::Display for PocketIcOperationError {
37 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 formatter.write_str(&self.message)
39 }
40}
41
42impl std::error::Error for PocketIcOperationError {}
43
44pub(super) fn panic_payload_to_string(payload: &(dyn Any + Send)) -> String {
46 if let Some(message) = payload.downcast_ref::<String>() {
47 return message.clone();
48 }
49 if let Some(message) = payload.downcast_ref::<&'static str>() {
50 return (*message).to_string();
51 }
52
53 "non-string panic payload".to_string()
54}
55
56pub(super) fn panic_is_dead_instance_transport(payload: &(dyn Any + Send)) -> bool {
59 payload
60 .downcast_ref::<String>()
61 .map(String::as_str)
62 .or_else(|| payload.downcast_ref::<&'static str>().copied())
63 .is_some_and(message_is_dead_instance_transport_error)
64}
65
66pub(super) fn is_dead_instance_transport_error(message: &str) -> bool {
68 message_is_dead_instance_transport_error(message)
69}
70
71#[must_use]
88pub fn is_dead_pocket_ic_transport_error(error: &(dyn std::error::Error + 'static)) -> bool {
89 let mut current = Some(error);
90 while let Some(candidate) = current {
91 if candidate
92 .downcast_ref::<PocketIcOperationError>()
93 .is_some_and(PocketIcOperationError::is_transport)
94 {
95 return true;
96 }
97 if candidate
98 .downcast_ref::<super::CandidCallError>()
99 .is_some_and(|error| error.kind() == super::CandidCallErrorKind::Transport)
100 {
101 return true;
102 }
103 if message_is_dead_instance_transport_error(&candidate.to_string()) {
104 return true;
105 }
106 current = candidate.source();
107 }
108 false
109}
110
111fn message_is_dead_instance_transport_error(message: &str) -> bool {
112 let message = message
113 .strip_prefix("HTTP failure: ")
114 .or_else(|| message.strip_prefix("called `Result::unwrap()` on an `Err` value: "))
115 .unwrap_or(message);
116 let Some(error) = message.strip_prefix("reqwest::Error { kind: ") else {
117 return false;
118 };
119 let Some((kind, request)) = error.split_once(", url: \"") else {
120 return false;
121 };
122 if !matches!(kind, "Request" | "Body" | "Decode") {
123 return false;
124 }
125 let Some((url, source)) = request.split_once("\", source: ") else {
126 return false;
127 };
128 let Some(source) = source.strip_suffix(" }") else {
129 return false;
130 };
131 let Some(authority_and_path) = url
132 .strip_prefix("http://")
133 .or_else(|| url.strip_prefix("https://"))
134 else {
135 return false;
136 };
137 let Some((authority, path)) = authority_and_path.split_once('/') else {
138 return false;
139 };
140 let Some(instance_path) = path.strip_prefix("instances/") else {
141 return false;
142 };
143 let instance_id = instance_path.split('/').next().unwrap_or_default();
144 if authority.is_empty()
145 || instance_id.is_empty()
146 || !instance_id.bytes().all(|byte| byte.is_ascii_digit())
147 {
148 return false;
149 }
150 let source = source
151 .strip_prefix("hyper_util::client::legacy::Error(Connect, ")
152 .or_else(|| source.strip_prefix("hyper_util::client::legacy::Error(SendRequest, "))
153 .and_then(|source| source.strip_suffix(')'))
154 .unwrap_or(source);
155 (source.starts_with("ConnectError(") && source.contains("kind: ConnectionRefused,"))
156 || matches!(
157 source,
158 "hyper::Error(IncompleteMessage)"
159 | "hyper::Error(ChannelClosed)"
160 | "hyper::Error(Canceled, \"connection closed before message completed\")"
161 )
162}
163
164#[cfg(test)]
165mod tests {
166 use super::{
167 PocketIcOperationError, is_dead_instance_transport_error,
168 is_dead_pocket_ic_transport_error, panic_is_dead_instance_transport,
169 };
170
171 #[derive(Debug)]
172 struct WrapperError(std::io::Error);
173
174 const REFUSED: &str = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/update/tick\", source: hyper_util::client::legacy::Error(Connect, ConnectError(\"tcp connect error\", 127.0.0.1:1234, Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" })) }";
175 const INCOMPLETE: &str = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/read/get_time\", source: hyper::Error(IncompleteMessage) }";
176
177 impl std::fmt::Display for WrapperError {
178 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 formatter.write_str("wrapped PocketIC request failed")
180 }
181 }
182
183 impl std::error::Error for WrapperError {
184 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
185 Some(&self.0)
186 }
187 }
188
189 #[test]
190 fn dead_instance_transport_error_detects_connection_refused() {
191 assert!(is_dead_instance_transport_error(REFUSED));
192 assert!(is_dead_instance_transport_error(&format!(
193 "HTTP failure: {REFUSED}"
194 )));
195 }
196
197 #[test]
198 fn dead_instance_transport_error_detects_incomplete_message() {
199 assert!(is_dead_instance_transport_error(INCOMPLETE));
200 assert!(is_dead_instance_transport_error(&format!(
201 "called `Result::unwrap()` on an `Err` value: {INCOMPLETE}"
202 )));
203 }
204
205 #[test]
206 fn classify_pocket_ic_panic_marks_dead_instance_transport() {
207 let classified = PocketIcOperationError::new(INCOMPLETE);
208
209 assert!(classified.is_transport());
210 assert!(panic_is_dead_instance_transport(&INCOMPLETE));
211 }
212
213 #[test]
214 fn public_classifier_inspects_the_error_source_chain() {
215 let dead = WrapperError(std::io::Error::other(REFUSED));
216 assert!(is_dead_pocket_ic_transport_error(&dead));
217
218 let unrelated = WrapperError(std::io::Error::other("request rejected"));
219 assert!(!is_dead_pocket_ic_transport_error(&unrelated));
220 }
221
222 #[test]
223 fn snapshot_and_install_wrappers_preserve_transport_causes() {
224 use super::super::{CanisterInstallError, CanisterInstallPhase, ControllerSnapshotError};
225 let canister_id = candid::Principal::anonymous();
226 for message in [REFUSED, "unrelated application panic"] {
227 let expected = message == REFUSED;
228 let capture = ControllerSnapshotError::CapturePanicked {
229 canister_id,
230 source: PocketIcOperationError::new(message),
231 cleanup_failures: vec![],
232 };
233 let restore = ControllerSnapshotError::RestorePanicked {
234 canister_id,
235 source: PocketIcOperationError::new(message),
236 };
237 for snapshot in [capture, restore] {
238 assert_eq!(is_dead_pocket_ic_transport_error(&snapshot), expected);
239 let wrapper = WrapperError(std::io::Error::other(snapshot));
240 assert_eq!(is_dead_pocket_ic_transport_error(&wrapper), expected);
241 }
242 for phase in [
243 CanisterInstallPhase::CreateCanister,
244 CanisterInstallPhase::AddCycles,
245 CanisterInstallPhase::InstallCode,
246 ] {
247 let install = CanisterInstallError::new(
248 phase,
249 None,
250 Some("fixture".into()),
251 PocketIcOperationError::new(message),
252 );
253 assert_eq!(is_dead_pocket_ic_transport_error(&install), expected);
254 let wrapper = WrapperError(std::io::Error::other(install));
255 assert_eq!(is_dead_pocket_ic_transport_error(&wrapper), expected);
256 }
257 }
258 }
259
260 #[test]
261 fn unrelated_and_quoted_transport_text_does_not_qualify() {
262 for message in [
263 "application worker channel closed",
264 "fixture expected ConnectionRefused but observed another result",
265 "tcp connect error",
266 "connection closed before message completed",
267 "IncompleteMessage",
268 &format!("fixture quoted {REFUSED}"),
269 &REFUSED.replace("/instances/0/update/tick", "/application/worker"),
270 &REFUSED.replace("/instances/0/", "/instances/quoted/"),
271 &INCOMPLETE.replace(
272 "hyper::Error(IncompleteMessage)",
273 "Custom(\"channel closed\")",
274 ),
275 &INCOMPLETE.replace(
276 "hyper::Error(IncompleteMessage)",
277 "Custom(\"hyper::Error(IncompleteMessage)\")",
278 ),
279 ] {
280 let error = WrapperError(std::io::Error::other(message.to_owned()));
281 assert!(!is_dead_pocket_ic_transport_error(&error), "{message}");
282 assert!(!PocketIcOperationError::new(message).is_transport());
283 assert!(!panic_is_dead_instance_transport(&message.to_owned()));
284 }
285 let refused = std::io::Error::from(std::io::ErrorKind::ConnectionRefused);
286 assert!(!is_dead_pocket_ic_transport_error(&refused));
287 assert!(!is_dead_pocket_ic_transport_error(&WrapperError(refused)));
288 }
289
290 #[test]
291 fn structured_channel_closure_on_an_instance_request_qualifies() {
292 let message = INCOMPLETE.replace("IncompleteMessage", "ChannelClosed");
293 assert!(is_dead_instance_transport_error(&message));
294 }
295
296 #[test]
297 fn contextual_call_errors_use_their_structured_kind() {
298 let context = super::super::CandidCallContext::new(
299 "query_call",
300 candid::Principal::anonymous(),
301 candid::Principal::anonymous(),
302 "get",
303 );
304 let error = super::super::CandidCallError::transport(context.clone(), REFUSED);
305 assert!(is_dead_pocket_ic_transport_error(&error));
306 let error = super::super::CandidCallError::decode(context, 0, "channel closed");
307 assert!(!is_dead_pocket_ic_transport_error(&error));
308 }
309}