diff --git a/CHANGELOG.md b/CHANGELOG.md index b73c52fc..09caf57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +* BREAKING: `AgentError` is now an opaque error type which reveals only the general category of error that occurred. `ic-utils` now has its own error types. +* `AgentError` now contains information about the ongoing operation the error occurred in the context of. This allows you to see if a call successfully went through even if there was for example an error decoding the response. * Add `read_state_canister_controllers` and `read_state_canister_module_hash` functions. ## [0.40.0] - 2025-03-17 diff --git a/Cargo.lock b/Cargo.lock index 75ac34de..84af06fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1138,12 +1138,15 @@ dependencies = [ "ic-certification 3.0.2", "ic-transport-types 0.40.0", "ic-verify-bls-signature", + "ic_principal", + "itertools 0.14.0", "js-sys", "k256", "leb128", "mockito", "p256", "pem", + "pin-project", "pkcs8", "rand", "rangemap", @@ -1526,6 +1529,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.12" @@ -1564,7 +1576,7 @@ dependencies = [ "ascii-canvas", "bit-set", "ena", - "itertools", + "itertools 0.11.0", "lalrpop-util", "petgraph", "pico-args", @@ -1600,9 +1612,9 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" [[package]] name = "libc" -version = "0.2.164" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libloading" @@ -1965,6 +1977,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -2208,6 +2240,7 @@ version = "0.0.0" dependencies = [ "candid", "ed25519-consensus", + "hex", "ic-agent", "ic-certification 3.0.2", "ic-identity-hsm", @@ -2991,9 +3024,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.41.1" +version = "1.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" dependencies = [ "backtrace", "bytes", @@ -3009,9 +3042,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 419eae8b..daadd7f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ ic-utils = { path = "ic-utils", version = "0.40.0" } ic-transport-types = { path = "ic-transport-types", version = "0.40.0" } ic-certification = "3" +ic_principal = "0.1" candid = "0.10.10" candid_parser = "0.1.4" clap = "4.5.21" diff --git a/ic-agent/Cargo.toml b/ic-agent/Cargo.toml index d675432a..78c42a35 100644 --- a/ic-agent/Cargo.toml +++ b/ic-agent/Cargo.toml @@ -25,7 +25,6 @@ async-trait = "0.1" async-watch = "0.3" backoff = "0.4.0" cached = { version = "0.52", features = ["ahash"], default-features = false } -candid = { workspace = true } der = "0.7" ecdsa = "0.16" ed25519-consensus = { workspace = true } @@ -34,17 +33,20 @@ futures-util = { workspace = true } hex = { workspace = true } http = "1.0.0" http-body = "1.0.0" +ic_principal = { workspace = true } ic-certification = { workspace = true } ic-transport-types = { workspace = true } ic-verify-bls-signature = "0.5" +itertools = "0.14.0" k256 = { workspace = true, features = ["pem"] } -p256 = { workspace = true, features = ["pem"] } leb128 = { workspace = true } +p256 = { workspace = true, features = ["pem"] } +pin-project = "1.1.10" pkcs8 = { version = "0.10.2", features = ["std"] } -sec1 = { version = "0.7.2", features = ["pem"] } rand = { workspace = true } rangemap = "1.4" ring = { version = "0.17", optional = true } +sec1 = { version = "0.7.2", features = ["pem"] } serde = { workspace = true, features = ["derive"] } serde_bytes = { workspace = true } serde_cbor = { workspace = true } @@ -78,6 +80,7 @@ wasm-bindgen-futures = { version = "0.4", optional = true } web-sys = { version = "0.3", features = ["Window"], optional = true } [dev-dependencies] +candid = { workspace = true } serde_json.workspace = true tracing-subscriber = "0.3" tracing = "0.1" diff --git a/ic-agent/src/agent/agent_error.rs b/ic-agent/src/agent/agent_error.rs index 006b0f23..0c8a00e9 100644 --- a/ic-agent/src/agent/agent_error.rs +++ b/ic-agent/src/agent/agent_error.rs @@ -1,63 +1,153 @@ -//! Errors that can occur when using the replica agent. +//! Errors that can occur when using the agent. -use crate::{agent::status::Status, RequestIdError}; -use candid::Principal; use ic_certification::Label; -use ic_transport_types::{InvalidRejectCodeError, RejectResponse}; -use leb128::read; -use std::time::Duration; +use ic_transport_types::RejectResponse; use std::{ + error::Error, fmt::{Debug, Display, Formatter}, - str::Utf8Error, + time::Duration, }; use thiserror::Error; -/// An error that occurred when using the agent. -#[derive(Error, Debug)] -pub enum AgentError { - /// The replica URL was invalid. - #[error(r#"Invalid Replica URL: "{0}""#)] - InvalidReplicaUrl(String), +use crate::util::try_from_context; - /// The request timed out. - #[error("The request timed out.")] - TimeoutWaitingForResponse(), +use super::{status::Status, OperationInfo, CURRENT_OPERATION}; - /// An error occurred when signing with the identity. - #[error("Identity had a signing error: {0}")] - SigningError(String), +/// An error that can occur when using an `Agent`. Includes partial operation info. +/// +/// If (say) a deserialization hiccup occurred after a call returned, you can call the +/// [`operation_info()`](Self::operation_info) method to learn whether the call failed or succeeded, +/// and (if possible) what the response was. +pub struct AgentError { + inner: Box, +} + +#[derive(Debug)] +struct AgentErrorInner { + source: Option>, + kind: ErrorKind, + operation_info: Option, +} - /// The data fetched was invalid CBOR. - #[error("Invalid CBOR data, could not deserialize: {0}")] - InvalidCborData(#[from] serde_cbor::Error), +/// What category of error occurred. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum ErrorKind { + /// Errors relating to certificate and signature verification. Plausibly due to malicious nodes, + /// but far more likely due to running mainnet-targeting code against a dev instance. + Trust, + /// Errors relating to the IC protocol as defined by the specification (e.g. CBOR decoding). + Protocol, + /// Reject messages provided by the IC. Note that unless the [`OperationStatus`](super::OperationStatus) + /// is `Received`, a reject cannot necessarily be trusted. + Reject, + /// Errors relating to the HTTP transport (e.g. TCP errors). + Transport, + /// Errors from a pluggable interface (e.g. [`Identity`](super::Identity)). + External, + /// Errors caused by hitting a user-provided limit (e.g. response body size). + Limit, + /// Errors caused by invalid input to a function. + Input, + /// Uncategorizable errors. + Unknown, +} - /// There was an error calculating a request ID. - #[error("Cannot calculate a RequestID: {0}")] - CannotCalculateRequestId(#[from] RequestIdError), +impl AgentError { + /// Returns what kind of error occurred. + pub fn kind(&self) -> ErrorKind { + self.inner.kind + } + /// Returns details on whatever operation was ongoing, or `None` if there wasn't one. + pub fn operation_info(&self) -> Option<&OperationInfo> { + self.inner.operation_info.as_ref() + } + /// Creates a new error for use in the [`RouteProvider`](super::RouteProvider) interface. + /// `operation_info` will return `None` initially, this will be inserted by the agent. + pub fn new_route_provider_error_without_context(message: String) -> Self { + Self { + inner: Box::new(AgentErrorInner { + source: Some(Box::new(ErrorCode::RouteProviderError(message))), + kind: ErrorKind::External, + operation_info: None, + }), + } + } + pub(crate) fn from_boxed_in_context( + inner: Box, + kind: ErrorKind, + ) -> Self { + match inner.downcast::() { + Ok(agent_err) => *agent_err, + Err(source) => AgentError { + inner: Box::new(AgentErrorInner { + kind, + operation_info: try_from_context(&CURRENT_OPERATION, |op| op.clone()).ok(), + source: Some(source), + }), + }, + } + } + pub(crate) fn add_context(&mut self) { + self.inner.operation_info = try_from_context(&CURRENT_OPERATION, |op| op.clone()).ok(); + } + /// If this error is an HTTP error, retrieve the the payload. Equivalent to downcasting [`source()`](Error::source). + pub fn as_http_error(&self) -> Option<&HttpErrorPayload> { + self.inner + .source + .as_ref() + .and_then(|source| source.downcast_ref()) + } - /// There was an error when de/serializing with Candid. - #[error("Candid returned an error: {0}")] - CandidError(Box), + /// If this error is/contains a reject (whether certified or not), returns it. + pub fn as_reject(&self) -> Option<&RejectResponse> { + if let Some(Err(reject)) = self + .inner + .operation_info + .as_ref() + .and_then(|op| op.response()) + { + Some(reject) + } else if let Some( + ErrorCode::CertifiedReject { reject } | ErrorCode::UncertifiedReject { reject }, + ) = self + .inner + .source + .as_ref() + .and_then(|source| source.downcast_ref::()) + { + Some(reject) + } else { + None + } + } +} - /// There was an error parsing a URL. - #[error(r#"Cannot parse url: "{0}""#)] - UrlParseError(#[from] url::ParseError), +impl Debug for AgentError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AgentError") + .field("source", &self.inner.source) + .field("kind", &self.inner.kind) + .field("operation_info", &self.inner.operation_info) + .finish() + } +} - /// The HTTP method was invalid. - #[error(r#"Invalid method: "{0}""#)] - InvalidMethodError(#[from] http::method::InvalidMethod), +/// An error that occurred when using the agent. +#[derive(Error, Debug)] +pub(crate) enum ErrorCode { + /// The request timed out. + #[error("The request timed out.")] + TimeoutWaitingForResponse, - /// The principal string was not a valid principal. - #[error("Cannot parse Principal: {0}")] - PrincipalError(#[from] crate::export::PrincipalError), + /// An error occurred when signing with the identity. + #[error("Identity had a signing error: {0}")] + SigningError(String), /// The subnet rejected the message. #[error("The replica returned a rejection error: reject code {:?}, reject message {}, error code {:?}", .reject.reject_code, .reject.reject_message, .reject.error_code)] CertifiedReject { /// The rejection returned by the replica. reject: RejectResponse, - /// The operation that was rejected. Not always available. - operation: Option, }, /// The subnet may have rejected the message. This rejection cannot be verified as authentic. @@ -65,14 +155,8 @@ pub enum AgentError { UncertifiedReject { /// The rejection returned by the boundary node. reject: RejectResponse, - /// The operation that was rejected. Not always available. - operation: Option, }, - /// The replica returned an HTTP error. - #[error("The replica returned an HTTP Error: {0}")] - HttpError(HttpErrorPayload), - /// The status endpoint returned an invalid status. #[error("Status endpoint returned an invalid status.")] InvalidReplicaStatus, @@ -81,37 +165,13 @@ pub enum AgentError { #[error("Call was marked as done but we never saw the reply. Request ID: {0}")] RequestStatusDoneNoReply(String), - /// A string error occurred in an external tool. - #[error("A tool returned a string message error: {0}")] - MessageError(String), - - /// There was an error reading a LEB128 value. - #[error("Error reading LEB128 value: {0}")] - Leb128ReadError(#[from] read::Error), - - /// A string was invalid UTF-8. - #[error("Error in UTF-8 string: {0}")] - Utf8ReadError(#[from] Utf8Error), - - /// The lookup path was absent in the certificate. - #[error("The lookup path ({0:?}) is absent in the certificate.")] - LookupPathAbsent(Vec