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