Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ air-gapped signing

# Unreleased

* feat: `icp cycles buy --amount <amount> [--currency USD]` buys cycles with a card for the current identity through a cycles gateway canister; `--cycles <cycles>` instead names the cycles to receive and pays the least amount that buys them, card fee included. It quotes the amount, asks for confirmation, creates the order as the identity, prints the hosted checkout URL with a QR code to scan (and opens the URL in a browser from a terminal) and waits for the cycles to land on the identity's cycles-ledger account. `--resume` and `--cancel` continue or cancel an existing order; `--gateway` or `ICP_CYCLES_GATEWAY_CANISTER_ID` picks another gateway canister.
* feat: `icp deploy` and `icp canister create` take `--engine <name>` to target a cloud engine by name. The name is resolved to the engine's subnet through the engine canister, among the engines visible to the identity in use, and then behaves as `--subnet` would with that subnet; a subnet id passed as the engine is used as-is. `name/slug` or the engine's id picks one of several engines sharing a name.
* fix: reject path-like network and environment names

Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ nix = { version = "0.31.2", features = ["process", "signal"] }
notify = "8.2.0"
num-bigint = "0.4.6"
open = "5"
qrcode = { version = "0.14.1", default-features = false }
num-integer = "0.1.46"
num-traits = "0.2.19"
p256 = { version = "0.13.2", features = ["pem", "pkcs8", "std"] }
Expand Down
46 changes: 44 additions & 2 deletions crates/icp-app/src/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ use async_trait::async_trait;
use candid::{Encode, Nat, Principal};
use ic_agent::{
Agent, AgentError,
agent::{CallResponse, EffectiveId, SubnetType},
agent::{CallResponse, EffectiveId, SubnetType, agent_error::HttpErrorPayload},
hash_tree::{Label, LookupResult},
};
use ic_management_canister_types::{CanisterMetadataArgs, CanisterMetadataResult};
use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult};
use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo};
use icp_project::calls::{Authority, CANISTER_NOT_FOUND, Call, CallError, CanisterCalls, RouteTo};

/// [`CanisterCalls`] over an `ic-agent`, optionally forwarding through a proxy
/// canister.
Expand Down Expand Up @@ -73,6 +73,18 @@ impl AgentCalls {
AgentError::TimeoutWaitingForResponse() | AgentError::TransportError(_) => {
CallError::unanswered(canister, method, err)
}
// An HTTP gateway that cannot route to the canister answers before
// any replica does, with no reject code to carry. It has still
// reached the same verdict a replica's IC0301 would, so it is
// reported as that rejection rather than as an opaque failure.
AgentError::HttpError(payload) if is_canister_not_found_http(payload) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mapping applies to every AgentCalls user, not just cycles buy. Deploy's readiness probe (is_serving_reject in operations/deploy.rs) treats any non-IC0508/IC0509 rejection as "serving". So a gateway 400 canister_not_found (e.g. stale routing right after create) now counts as ready instead of being retried. Could this be scoped to the cycles-gateway path?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that this leaked into the probe. Rather than scoping the mapping (an HTTP gateway's canister_not_found really is the same verdict as IC0301, and cycles buy needs it as one), is_serving_reject now treats is_canister_not_found() as not serving, which also covers a replica-issued IC0301 that the probe previously counted as ready. Test added; deploy_tests pass. 9ee62e4

CallError::Rejected {
canister,
method: method.to_owned(),
code: Some(CANISTER_NOT_FOUND.to_owned()),
message: format!("Canister {canister} not found"),
}
}
_ => CallError::failed(canister, method, err),
}
}
Expand Down Expand Up @@ -402,6 +414,13 @@ impl CanisterCalls for AgentCalls {
}
}

/// Whether an HTTP gateway's error says the canister it was asked to route to
/// does not exist: a 4xx whose body is the gateway's `canister_not_found`.
fn is_canister_not_found_http(payload: &HttpErrorPayload) -> bool {
(400..500).contains(&payload.status)
&& String::from_utf8_lossy(&payload.content).contains("canister_not_found")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -444,6 +463,29 @@ mod tests {
assert!(!err.is_transient());
}

/// A local HTTP gateway refuses to route to a canister it has never heard
/// of before any replica sees the call. Callers that branch on "does this
/// canister exist" must get the same answer they would from a replica.
#[test]
fn a_gateway_canister_not_found_is_an_ic0301_rejection() {
let err = wrapped(AgentError::HttpError(HttpErrorPayload {
status: 400,
content_type: Some("text/plain; charset=utf-8".to_owned()),
content: b"error: canister_not_found".to_vec(),
}));
assert!(err.is_canister_not_found(), "{err}");
assert!(err.is_rejection());

// Any other HTTP failure stays what it is: deterministic, not absence.
let err = wrapped(AgentError::HttpError(HttpErrorPayload {
status: 502,
content_type: None,
content: b"bad gateway".to_vec(),
}));
assert!(!err.is_canister_not_found());
assert!(!err.is_rejection());
}

/// The replica's own wording for the two ways a target reports it has no
/// section, copied from `CanisterManagerError` in the IC repo. Both are
/// absence, not failure, so both must reach the plugin as `none`.
Expand Down
Loading
Loading