-
Notifications
You must be signed in to change notification settings - Fork 13
refactor: Abstract out canister calling from icp-project #774
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
adamspofford-dfinity
wants to merge
13
commits into
spofford/abstract-filesystem
from
spofford/abstract-canister-calls
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
7a63f45
refactor: reach canisters through a seam shaped like the plugin inter…
adamspofford-dfinity 7bfec18
fix: pass a deferred value's cause through instead of restating it
adamspofford-dfinity 586401d
fix: stop appending a call's cause to its own message
adamspofford-dfinity 79ee20f
fix: stop appending a call's cause to the create error's message
adamspofford-dfinity 21d2f59
fix: report a canister with nothing installed as having no module
adamspofford-dfinity 7e0532f
fix: say that a canister does not exist when its controllers are absent
adamspofford-dfinity a81df6e
fix: let a call say it must come from the caller itself
adamspofford-dfinity e8b12bb
docs: stop describing a proxy parameter cycle recovery no longer takes
adamspofford-dfinity a2785f8
fix: route a subnet-scoped call to its subnet rather than to a proxy
adamspofford-dfinity 67df348
fix: read a not-found rejection that carries no error code
adamspofford-dfinity 3cdba93
fix: name the engine-operator check in the error it produces
adamspofford-dfinity 2533f2e
fix: stop a create target from naming a proxy nothing reads
adamspofford-dfinity b416283
fix: retry only a call the network never answered
adamspofford-dfinity File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,317 @@ | ||
| //! Reaching canisters over the IC's HTTP interface. | ||
| //! | ||
| //! The implementation of [`icp_project::calls::CanisterCalls`] for a machine | ||
| //! with an `ic-agent`: it owns the identity the calls are signed with, the | ||
| //! endpoint they go to, and the root key their answers are verified against. | ||
| //! | ||
| //! It also owns *proxy routing*. `--proxy` names a canister that forwards | ||
| //! management calls on the caller's behalf, and it applies to the whole | ||
| //! command, so it is a property of the caller rather than of any one call — | ||
| //! the proxy is the [`Authority::Mediated`] one, and a request that asks for | ||
| //! [`Authority::Direct`] skips it. A query made through a proxy necessarily | ||
| //! becomes an update, which is why the trait leaves how a query is answered to | ||
| //! the implementation. | ||
|
|
||
| use async_trait::async_trait; | ||
| use candid::{Encode, Nat, Principal}; | ||
| use ic_agent::{ | ||
| Agent, AgentError, | ||
| agent::{CallResponse, EffectiveId, SubnetType}, | ||
| }; | ||
| use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult}; | ||
| use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo}; | ||
|
|
||
| /// [`CanisterCalls`] over an `ic-agent`, optionally forwarding through a proxy | ||
| /// canister. | ||
| pub struct AgentCalls { | ||
| agent: Agent, | ||
| proxy: Option<Principal>, | ||
| caller: Principal, | ||
| } | ||
|
|
||
| impl AgentCalls { | ||
| /// Builds a caller from `agent`. `proxy`, when given, is the canister every | ||
| /// call is forwarded through. | ||
| /// | ||
| /// The caller's principal is read once here: it cannot change for the life | ||
| /// of the agent, and every call would otherwise ask for it again. | ||
| pub fn new(agent: Agent, proxy: Option<Principal>) -> Result<Self, AgentError> { | ||
| let caller = agent | ||
| .get_principal() | ||
| .map_err(|message| AgentError::MessageError(message.clone()))?; | ||
| Ok(Self { | ||
| agent, | ||
| proxy, | ||
| caller, | ||
| }) | ||
| } | ||
|
|
||
| /// The proxy `call` should go through, if any: none when no proxy was | ||
| /// configured, and none when the call asked to be made directly. | ||
| fn mediator(&self, call: &Call) -> Option<Principal> { | ||
| match call.authority { | ||
| Authority::Mediated => self.proxy, | ||
| Authority::Direct => None, | ||
| } | ||
| } | ||
|
|
||
| /// Turns an agent error into the shape the trait speaks in: a rejection is | ||
| /// a verdict and keeps its code, a call that ran out of time or never got | ||
| /// down the wire went unanswered, and anything else — a reply that would | ||
| /// not parse, a certificate that would not verify — failed for good. | ||
| fn wrap(canister: Principal, method: &str, err: AgentError) -> CallError { | ||
| match &err { | ||
| AgentError::CertifiedReject { reject, .. } | ||
| | AgentError::UncertifiedReject { reject, .. } => CallError::Rejected { | ||
| canister, | ||
| method: method.to_owned(), | ||
| code: reject.error_code.clone(), | ||
| message: reject.reject_message.clone(), | ||
| }, | ||
| AgentError::TimeoutWaitingForResponse() | AgentError::TransportError(_) => { | ||
| CallError::unanswered(canister, method, err) | ||
| } | ||
| _ => CallError::failed(canister, method, err), | ||
| } | ||
| } | ||
|
|
||
| /// Forwards `call` through the proxy canister, unwrapping its reply. | ||
| async fn through_proxy(&self, proxy: Principal, call: &Call) -> Result<Vec<u8>, CallError> { | ||
| let args = ProxyArgs { | ||
| canister_id: call.canister, | ||
| method: call.method.clone(), | ||
| args: call.arg.clone(), | ||
| cycles: Nat::from(call.cycles), | ||
| }; | ||
| let arg = Encode!(&args).map_err(|e| CallError::failed(proxy, "proxy", e))?; | ||
| let reply = self | ||
| .agent | ||
| .update(&proxy, "proxy") | ||
| .with_arg(arg) | ||
| .await | ||
| .map_err(|e| Self::wrap(proxy, "proxy", e))?; | ||
| let (result,): (ProxyResult,) = | ||
| candid::decode_args(&reply).map_err(|e| CallError::failed(proxy, "proxy", e))?; | ||
| match result { | ||
| ProxyResult::Ok(ok) => Ok(ok.result), | ||
| // The proxy reports the inner call's failure as text; it reached a | ||
| // verdict, so it is a rejection even though the code is lost. | ||
| ProxyResult::Err(err) => Err(CallError::Rejected { | ||
| canister: call.canister, | ||
| method: call.method.clone(), | ||
| code: None, | ||
| message: err.format_error(), | ||
| }), | ||
| } | ||
| } | ||
|
|
||
| /// Whether `canister` exists, which its controllers answer — see | ||
| /// [`CanisterCalls::controllers`]. | ||
| /// | ||
| /// A check that could not be made reads as "does not exist": every caller | ||
| /// asks this while deciding whether to report a read failure of its own, | ||
| /// and an existence check that failed too is no reason to suppress it. | ||
| async fn exists(&self, canister: Principal) -> bool { | ||
| self.controllers(canister) | ||
| .await | ||
| .is_ok_and(|controllers| controllers.is_some()) | ||
| } | ||
|
|
||
| /// A subnet-scoped update: routed to a subnet rather than to any canister | ||
| /// on it, which the agent only exposes through a signed submission. | ||
| async fn to_subnet(&self, subnet: Principal, call: &Call) -> Result<Vec<u8>, CallError> { | ||
| let effective = EffectiveId::Subnet(subnet); | ||
| let fail = |e: AgentError| Self::wrap(call.canister, &call.method, e); | ||
|
|
||
| let signed = self | ||
| .agent | ||
| .update(&call.canister, &call.method) | ||
| .with_arg(call.arg.clone()) | ||
| .sign() | ||
| .map_err(fail)?; | ||
| let response = self | ||
| .agent | ||
| .update_signed(effective, signed.signed_update) | ||
| .await | ||
| .map_err(fail)?; | ||
| match response { | ||
| CallResponse::Response(bytes) => Ok(bytes), | ||
| CallResponse::Poll(request_id) => { | ||
| let status = self | ||
| .agent | ||
| .sign_request_status(effective, request_id) | ||
| .map_err(fail)?; | ||
| Ok(self | ||
| .agent | ||
| .wait_signed(&request_id, effective, status.signed_request_status) | ||
| .await | ||
| .map_err(fail)? | ||
| .0) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Wraps a resolved agent as the caller this workspace's operations take, | ||
| /// forwarding through `proxy` when one was asked for. | ||
| pub fn calls( | ||
| agent: Agent, | ||
| proxy: Option<Principal>, | ||
| ) -> Result<std::sync::Arc<dyn CanisterCalls>, AgentError> { | ||
| Ok(std::sync::Arc::new(AgentCalls::new(agent, proxy)?)) | ||
| } | ||
|
|
||
| #[async_trait] | ||
| impl CanisterCalls for AgentCalls { | ||
| fn caller(&self) -> Principal { | ||
| self.caller | ||
| } | ||
|
|
||
| async fn update(&self, call: Call) -> Result<Vec<u8>, CallError> { | ||
| // A subnet-scoped call names the subnet it acts on, and a proxy could | ||
| // only act on the subnet it lives on itself — so this comes first, | ||
| // rather than dropping the one thing the call is about. The commands | ||
| // that can name a subnet refuse to name a proxy as well. | ||
| if let RouteTo::Subnet(subnet) = call.route { | ||
| return self.to_subnet(subnet, &call).await; | ||
| } | ||
| if let Some(proxy) = self.mediator(&call) { | ||
| return self.through_proxy(proxy, &call).await; | ||
| } | ||
| let mut builder = self | ||
| .agent | ||
| .update(&call.canister, &call.method) | ||
| .with_arg(call.arg.clone()); | ||
| if let RouteTo::Canister(effective) = call.route { | ||
| builder = builder.with_effective_canister_id(effective); | ||
| } | ||
| builder | ||
| .await | ||
| .map_err(|e| Self::wrap(call.canister, &call.method, e)) | ||
| } | ||
|
|
||
| async fn query(&self, call: Call) -> Result<Vec<u8>, CallError> { | ||
| // A proxy only accepts updates, so a query through one becomes an | ||
| // update. The reply is the same either way. | ||
| if let Some(proxy) = self.mediator(&call) { | ||
| return self.through_proxy(proxy, &call).await; | ||
| } | ||
| let mut builder = self | ||
| .agent | ||
| .query(&call.canister, &call.method) | ||
| .with_arg(call.arg.clone()); | ||
| if let RouteTo::Canister(effective) = call.route { | ||
| builder = builder.with_effective_canister_id(effective); | ||
| } | ||
| builder | ||
| .call() | ||
| .await | ||
| .map_err(|e| Self::wrap(call.canister, &call.method, e)) | ||
| } | ||
|
|
||
| async fn metadata_section( | ||
| &self, | ||
| canister: Principal, | ||
| path: &str, | ||
| ) -> Result<Option<Vec<u8>>, CallError> { | ||
| match self | ||
| .agent | ||
| .read_state_canister_metadata(canister, path) | ||
| .await | ||
| { | ||
| Ok(bytes) => Ok(Some(bytes)), | ||
| Err(err) => { | ||
| // A path the certificate will not certify looks the same as a | ||
| // canister that was never created, which is the error this | ||
| // reports rather than "no such section". | ||
| if self.exists(canister).await { | ||
| Ok(None) | ||
| } else { | ||
| Err(Self::wrap(canister, "read_state(metadata)", err)) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async fn controllers(&self, canister: Principal) -> Result<Option<Vec<Principal>>, CallError> { | ||
| match self.agent.read_state_canister_controllers(canister).await { | ||
| Ok(controllers) => Ok(Some(controllers)), | ||
| Err(AgentError::LookupPathAbsent(_)) => Ok(None), | ||
| Err(err) => Err(Self::wrap(canister, "read_state(controllers)", err)), | ||
| } | ||
| } | ||
|
|
||
| async fn module_hash(&self, canister: Principal) -> Result<Option<Vec<u8>>, CallError> { | ||
| let err = match self.agent.read_state_canister_module_hash(canister).await { | ||
| Ok(hash) => return Ok(Some(hash)), | ||
| Err(err) => err, | ||
| }; | ||
| // No module-hash path means nothing is installed — or that there is no | ||
| // canister to install into, which is not an answer this can give. | ||
| if matches!(err, AgentError::LookupPathAbsent(_)) && self.exists(canister).await { | ||
| return Ok(None); | ||
| } | ||
| Err(Self::wrap(canister, "read_state(module_hash)", err)) | ||
| } | ||
|
|
||
| async fn subnet_of(&self, canister: Principal) -> Result<Principal, CallError> { | ||
| Ok(self | ||
| .agent | ||
| .get_subnet_by_canister(&canister) | ||
| .await | ||
| .map_err(|e| Self::wrap(canister, "subnet_of", e))? | ||
| .id()) | ||
| } | ||
|
|
||
| async fn subnet_uses_engine_operator(&self, subnet: Principal) -> Result<bool, CallError> { | ||
| let info = self | ||
| .agent | ||
| .get_subnet_by_id(&subnet) | ||
| .await | ||
| .map_err(|e| Self::wrap(subnet, "subnet_type", e))?; | ||
| Ok(matches!(info.subnet_type(), Some(SubnetType::CloudEngine))) | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use ic_agent::agent::{RejectCode, RejectResponse}; | ||
|
|
||
| fn wrapped(err: AgentError) -> CallError { | ||
| AgentCalls::wrap(Principal::anonymous(), "read_canister_snapshot_data", err) | ||
| } | ||
|
|
||
| /// The retry loops in the snapshot transfers branch on this: only a call | ||
| /// the network never answered is worth repeating, so a deterministic | ||
| /// failure must not be classified as one and stall the command for the | ||
| /// whole retry window. | ||
| /// | ||
| /// `AgentError::TransportError` is transient for the same reason, but the | ||
| /// error it carries is private to `ic-agent` and cannot be built here. | ||
| #[test] | ||
| fn only_an_unanswered_call_is_transient() { | ||
| assert!(wrapped(AgentError::TimeoutWaitingForResponse()).is_transient()); | ||
|
|
||
| // Deterministic: the reply was received and could not be understood. | ||
| assert!(!wrapped(AgentError::CertificateVerificationFailed()).is_transient()); | ||
| assert!(!wrapped(AgentError::MessageError("malformed reply".to_owned())).is_transient()); | ||
| } | ||
|
|
||
| /// A rejection is the network's verdict: it keeps its code for callers to | ||
| /// branch on, and repeating the call would only earn it again. | ||
| #[test] | ||
| fn a_rejection_keeps_its_code_and_is_not_transient() { | ||
| let err = wrapped(AgentError::CertifiedReject { | ||
| reject: RejectResponse { | ||
| reject_code: RejectCode::CanisterError, | ||
| reject_message: "canister is stopped".to_owned(), | ||
| error_code: Some("IC0508".to_owned()), | ||
| }, | ||
| operation: None, | ||
| }); | ||
| assert_eq!(err.code(), Some("IC0508")); | ||
| assert!(err.is_rejection()); | ||
| assert!(!err.is_transient()); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.