Skip to main content

ic_testkit/pic/
errors.rs

1use candid::Principal;
2use pocket_ic::{PocketIc, RejectResponse};
3
4use super::PocketIcOperationError;
5
6/// Structured failure from a [`super::CandidCallExt`] operation.
7#[non_exhaustive]
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct CandidCallError {
10    /// Human-readable error with available call context.
11    pub message: String,
12    /// Stable high-level failure classification.
13    pub kind: CandidCallErrorKind,
14    /// Target, caller, method, and raw PocketIC operation when available.
15    pub context: Option<Box<CandidCallContext>>,
16    /// Complete upstream rejection for [`CandidCallErrorKind::CanisterReject`].
17    pub reject_response: Option<Box<RejectResponse>>,
18}
19
20/// High-level Candid call failure classification.
21#[non_exhaustive]
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum CandidCallErrorKind {
24    /// Arguments could not be Candid-encoded.
25    Encode,
26    /// Successful response bytes could not be Candid-decoded.
27    Decode,
28    /// PocketIC returned a structured canister rejection.
29    CanisterReject,
30    /// The PocketIC instance became unreachable.
31    Transport,
32    /// A harness failure without a narrower stable classification.
33    Other,
34}
35
36/// Stable call metadata attached to a contextual call failure.
37#[non_exhaustive]
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct CandidCallContext {
40    /// Underlying PocketIC operation, such as `update_call` or `query_call`.
41    pub operation: &'static str,
42    /// Target canister.
43    pub canister_id: Principal,
44    /// Effective message caller.
45    pub caller: Principal,
46    /// Canister method name.
47    pub method: String,
48}
49
50/// Stage at which a canister creation and installation failed.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum CanisterInstallPhase {
53    /// PocketIC could not create the canister.
54    CreateCanister,
55    /// PocketIC could not add the requested cycles.
56    AddCycles,
57    /// PocketIC could not install the Wasm module.
58    InstallCode,
59}
60
61impl CanisterInstallPhase {
62    /// Upstream operation attempted at this stage.
63    #[must_use]
64    pub const fn operation(self) -> &'static str {
65        match self {
66            Self::CreateCanister => "create_canister",
67            Self::AddCycles => "add_cycles",
68            Self::InstallCode => "install_canister",
69        }
70    }
71}
72
73/// Failed creation, funding, or installation with its original operation error.
74#[derive(Debug, Eq, PartialEq)]
75pub struct CanisterInstallError {
76    phase: CanisterInstallPhase,
77    canister_id: Option<Principal>,
78    label: Option<String>,
79    source: PocketIcOperationError,
80}
81
82/// Failed standalone install retaining the caller-created PocketIC instance.
83pub struct StandaloneCanisterInstallError {
84    pocket_ic: Box<PocketIc>,
85    install_error: CanisterInstallError,
86}
87
88impl CandidCallContext {
89    /// Capture the stable call metadata attached to one call failure.
90    #[must_use]
91    pub fn new(
92        operation: &'static str,
93        canister_id: Principal,
94        caller: Principal,
95        method: impl Into<String>,
96    ) -> Self {
97        Self {
98            operation,
99            canister_id,
100            caller,
101            method: method.into(),
102        }
103    }
104
105    /// Read the PocketIC operation name, such as `update_call` or `query_call`.
106    #[must_use]
107    pub const fn operation(&self) -> &'static str {
108        self.operation
109    }
110
111    /// Read the target canister id.
112    #[must_use]
113    pub const fn canister_id(&self) -> Principal {
114        self.canister_id
115    }
116
117    /// Read the caller principal used for the call.
118    #[must_use]
119    pub const fn caller(&self) -> Principal {
120        self.caller
121    }
122
123    /// Read the called method name.
124    #[must_use]
125    pub fn method(&self) -> &str {
126        &self.method
127    }
128}
129
130impl CandidCallError {
131    /// Capture one PocketIC call/codec failure.
132    #[must_use]
133    pub fn new(message: impl Into<String>) -> Self {
134        Self {
135            message: message.into(),
136            kind: CandidCallErrorKind::Other,
137            context: None,
138            reject_response: None,
139        }
140    }
141
142    /// Capture one contextual Candid encode failure.
143    #[must_use]
144    pub fn encode(context: CandidCallContext, source: impl std::fmt::Display) -> Self {
145        let message = format!(
146            "candid encode_args failed (operation={}, canister={}, caller={}, method={}): {source}",
147            context.operation, context.canister_id, context.caller, context.method
148        );
149
150        Self {
151            message,
152            kind: CandidCallErrorKind::Encode,
153            context: Some(Box::new(context)),
154            reject_response: None,
155        }
156    }
157
158    /// Capture one contextual Candid decode failure.
159    #[must_use]
160    pub fn decode(
161        context: CandidCallContext,
162        bytes: usize,
163        source: impl std::fmt::Display,
164    ) -> Self {
165        let message = format!(
166            "candid decode_one failed (operation={}, canister={}, caller={}, method={}, bytes={}): {source}",
167            context.operation, context.canister_id, context.caller, context.method, bytes
168        );
169
170        Self {
171            message,
172            kind: CandidCallErrorKind::Decode,
173            context: Some(Box::new(context)),
174            reject_response: None,
175        }
176    }
177
178    /// Capture one structured rejection returned by PocketIC.
179    #[must_use]
180    pub fn canister_reject(context: CandidCallContext, response: RejectResponse) -> Self {
181        let message = format!(
182            "pocket_ic {} was rejected (canister={}, caller={}, method={}): {response}",
183            context.operation, context.canister_id, context.caller, context.method
184        );
185
186        Self {
187            message,
188            kind: CandidCallErrorKind::CanisterReject,
189            context: Some(Box::new(context)),
190            reject_response: Some(Box::new(response)),
191        }
192    }
193
194    /// Capture one contextual PocketIC transport failure.
195    #[must_use]
196    pub fn transport(context: CandidCallContext, source: impl std::fmt::Display) -> Self {
197        let message = format!(
198            "pocket_ic {} failed (canister={}, caller={}, method={}): {source}",
199            context.operation, context.canister_id, context.caller, context.method
200        );
201
202        Self {
203            message,
204            kind: CandidCallErrorKind::Transport,
205            context: Some(Box::new(context)),
206            reject_response: None,
207        }
208    }
209
210    /// Read the rendered error message.
211    #[must_use]
212    pub fn message(&self) -> &str {
213        &self.message
214    }
215
216    /// Read the structured failure kind.
217    #[must_use]
218    pub const fn kind(&self) -> CandidCallErrorKind {
219        self.kind
220    }
221
222    /// Read the structured call context, when available.
223    #[must_use]
224    pub fn context(&self) -> Option<&CandidCallContext> {
225        self.context.as_deref()
226    }
227
228    /// Read the structured PocketIC rejection, when the call reached the IC.
229    #[must_use]
230    pub fn reject_response(&self) -> Option<&RejectResponse> {
231        self.reject_response.as_deref()
232    }
233}
234
235impl std::fmt::Display for CandidCallError {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        f.write_str(&self.message)
238    }
239}
240
241impl std::error::Error for CandidCallError {}
242
243impl CanisterInstallError {
244    /// Capture an operation failure and all context available at that stage.
245    #[must_use]
246    pub const fn new(
247        phase: CanisterInstallPhase,
248        canister_id: Option<Principal>,
249        label: Option<String>,
250        source: PocketIcOperationError,
251    ) -> Self {
252        Self {
253            phase,
254            canister_id,
255            label,
256            source,
257        }
258    }
259
260    /// Read the failed stage.
261    #[must_use]
262    pub const fn phase(&self) -> CanisterInstallPhase {
263        self.phase
264    }
265
266    /// Read the created canister id, absent when creation failed.
267    #[must_use]
268    pub const fn canister_id(&self) -> Option<Principal> {
269        self.canister_id
270    }
271
272    /// Read the original upstream panic message.
273    #[must_use]
274    pub fn message(&self) -> &str {
275        self.source.message()
276    }
277
278    /// Inspect the original operation failure and its transport classification.
279    #[must_use]
280    pub const fn operation_error(&self) -> &PocketIcOperationError {
281        &self.source
282    }
283
284    /// Read the optional caller-provided install label.
285    #[must_use]
286    pub fn label(&self) -> Option<&str> {
287        self.label.as_deref()
288    }
289}
290
291impl std::fmt::Display for CanisterInstallError {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        write!(f, "{} failed", self.phase.operation())?;
294        if let Some(canister_id) = self.canister_id {
295            write!(f, " for canister {canister_id}")?;
296        }
297        if let Some(label) = &self.label {
298            write!(f, " ({label})")?;
299        }
300        write!(f, ": {}", self.source)
301    }
302}
303
304impl std::error::Error for CanisterInstallError {
305    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
306        Some(&self.source)
307    }
308}
309
310impl StandaloneCanisterInstallError {
311    pub(super) fn new(pocket_ic: PocketIc, install_error: CanisterInstallError) -> Self {
312        Self {
313            pocket_ic: Box::new(pocket_ic),
314            install_error,
315        }
316    }
317
318    /// Borrow the caller-created instance retained after the failed install.
319    #[must_use]
320    pub fn pocket_ic(&self) -> &PocketIc {
321        self.pocket_ic.as_ref()
322    }
323
324    /// Inspect the structured install failure.
325    #[must_use]
326    pub const fn install_error(&self) -> &CanisterInstallError {
327        &self.install_error
328    }
329
330    /// Recover ownership of the instance and install failure.
331    #[must_use]
332    pub fn into_parts(self) -> (PocketIc, CanisterInstallError) {
333        (*self.pocket_ic, self.install_error)
334    }
335}
336
337impl std::fmt::Debug for StandaloneCanisterInstallError {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        f.debug_struct("StandaloneCanisterInstallError")
340            .field("install_error", &self.install_error)
341            .finish_non_exhaustive()
342    }
343}
344
345impl std::fmt::Display for StandaloneCanisterInstallError {
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        self.install_error.fmt(f)
348    }
349}
350
351impl std::error::Error for StandaloneCanisterInstallError {
352    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
353        Some(&self.install_error)
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use candid::Principal;
360    use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
361
362    use super::{CandidCallContext, CandidCallError, CandidCallErrorKind, CanisterInstallError};
363
364    #[test]
365    fn labeled_install_error_display_includes_label() {
366        let err = CanisterInstallError::new(
367            super::CanisterInstallPhase::InstallCode,
368            Some(Principal::anonymous()),
369            Some("authority".into()),
370            super::PocketIcOperationError::new("trap"),
371        );
372
373        assert_eq!(err.label(), Some("authority"));
374        assert!(err.to_string().contains("(authority): trap"));
375    }
376
377    #[test]
378    fn canister_reject_preserves_the_upstream_response() {
379        let response = RejectResponse {
380            reject_code: RejectCode::DestinationInvalid,
381            reject_message: "missing canister".to_string(),
382            error_code: ErrorCode::CanisterNotFound,
383            certified: true,
384        };
385        let error = CandidCallError::canister_reject(
386            CandidCallContext::new(
387                "query_call",
388                Principal::anonymous(),
389                Principal::management_canister(),
390                "get",
391            ),
392            response.clone(),
393        );
394
395        assert_eq!(error.kind(), CandidCallErrorKind::CanisterReject);
396        assert_eq!(error.reject_response(), Some(&response));
397    }
398}