From 7ed0d9082fb7f8a8e4d177f4aad799f49de3949c Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 10 Sep 2026 10:52:08 +0000 Subject: [PATCH 1/5] impl --- Cargo.toml | 4 +- e2e-tests/src/bin/api.rs | 26 + e2e-tests/src/bin/canister_info.rs | 3 + e2e-tests/src/bin/http_request.rs | 271 +++++--- e2e-tests/src/bin/management_canister.rs | 1 + e2e-tests/src/bin/timers.rs | 22 +- e2e-tests/tests/api.rs | 8 + e2e-tests/tests/http_request.rs | 110 ++- ic-cdk-management-canister/CHANGELOG.md | 19 + ic-cdk-management-canister/README.md | 7 +- ic-cdk-management-canister/src/lib.rs | 816 ++++++++++++++++++++--- ic-cdk/CHANGELOG.md | 5 + ic-cdk/src/api.rs | 26 + ic0/CHANGELOG.md | 4 + ic0/ic0.txt | 2 + ic0/manual_safety_comments.txt | 5 + ic0/src/lib.rs | 22 + ic0/src/sys.rs | 12 + 18 files changed, 1157 insertions(+), 206 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 845b283f5..f48526a07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ ic-cdk-executor = { path = "ic-cdk-executor", version = "2.0.0" } ic-cdk-macros = { path = "ic-cdk-macros", version = "=0.20.2" } ic-cdk-management-canister = { path = "ic-cdk-management-canister", version = "0.1.1" } ic-cdk-timers = { path = "ic-cdk-timers", version = "1.0.0" } -ic-management-canister-types = "0.7.1" +ic-management-canister-types = "0.10" ic0 = { path = "ic0", version = "1.1.0" } # Regular dependencies @@ -68,7 +68,7 @@ escargot = "0.5.15" futures = "0.3" ic-vetkd-utils = { git = "https://github.com/dfinity/ic", rev = "95231520" } lazy_static = "1.5.0" -pocket-ic = { git = "https://github.com/dfinity/ic", tag = "release-2026-04-16_04-20-base" } +pocket-ic = { git = "https://github.com/dfinity/ic", tag = "release-2026-09-03_04-41-base" } prost = "0.14.3" prost-build = "0.14.3" reqwest = "0.13.2" diff --git a/e2e-tests/src/bin/api.rs b/e2e-tests/src/bin/api.rs index 157c71412..c209981eb 100644 --- a/e2e-tests/src/bin/api.rs +++ b/e2e-tests/src/bin/api.rs @@ -137,6 +137,15 @@ fn call_subnet_self() { msg_reply(vec![]); } +#[unsafe(export_name = "canister_update call_subnet_self_node_count")] +fn call_subnet_self_node_count() { + let count = subnet_self_node_count(); + debug_print(format!("Subnet node count: {count}")); + // Every subnet has at least one node. + assert!(count > 0); + msg_reply(vec![]); +} + #[unsafe(export_name = "canister_inspect_message")] fn inspect_message() { assert!(msg_method_name().starts_with("call_")); @@ -239,6 +248,23 @@ fn call_cost_http_request() { msg_reply(vec![]); } +#[unsafe(export_name = "canister_query call_cost_http_request_v2")] +fn call_cost_http_request_v2() { + // The Candid encoding of the parameter record, with `outcall_type` left unset so a fully + // replicated outcall is priced. + let args = ic_cdk_management_canister::CostHttpRequestV2Args { + request_bytes: 100, + http_roundtrip_time_ms: 1_000, + raw_response_bytes: 1_000, + transformed_response_bytes: 1_000, + transform_instructions: 1_000_000, + outcall_type: None, + }; + let res = ic_cdk_management_canister::cost_http_request_v2(&args); + assert!(res > 0); + msg_reply(vec![]); +} + const INVALID_KEY_NAME: &str = "invalid_key_name"; const INVALID_CURVE_OR_ALGORITHM: u32 = 42; // Just a big number which is impossible to be valid. const VALID_KEY_NAME: &str = "test_key_1"; diff --git a/e2e-tests/src/bin/canister_info.rs b/e2e-tests/src/bin/canister_info.rs index 96786735f..7d0f33b9f 100644 --- a/e2e-tests/src/bin/canister_info.rs +++ b/e2e-tests/src/bin/canister_info.rs @@ -74,6 +74,9 @@ async fn canister_lifecycle() -> Principal { wasm_memory_limit: None, wasm_memory_threshold: None, environment_variables: None, + minimum_incoming_canister_call_cycles: None, + snapshot_visibility: None, + status_visibility: None, }, canister_id, }) diff --git a/e2e-tests/src/bin/http_request.rs b/e2e-tests/src/bin/http_request.rs index 613f0d140..4694378cc 100644 --- a/e2e-tests/src/bin/http_request.rs +++ b/e2e-tests/src/bin/http_request.rs @@ -1,59 +1,52 @@ +use candid::Reserved; use ic_cdk::{query, update}; use ic_cdk_management_canister::{ - HttpHeader, HttpMethod, HttpRequestArgs, HttpRequestResult, TransformArgs, http_request, - http_request_with_closure, transform_context_from_query, + FlexibleHttpGlobalError, FlexibleHttpRequest, FlexibleHttpRequestResult, HttpHeader, + HttpMethod, HttpRequest, HttpRequestResult, ReplicationCounts, TransformArgs, + transform_context_from_query, }; -/// All fields are Some except transform. +fn expected_response_headers() -> Vec { + vec![HttpHeader { + name: "response_header_name".to_string(), + value: "response_header_value".to_string(), + }] +} + +/// All fields are set except transform. #[update] async fn get_without_transform() { - let args = HttpRequestArgs { - url: "https://example.com".to_string(), - method: HttpMethod::GET, - headers: vec![HttpHeader { - name: "request_header_name".to_string(), - value: "request_header_value".to_string(), - }], - body: Some(vec![1]), - max_response_bytes: Some(100_000), - transform: None, - is_replicated: Some(true), - }; - - let res = http_request(&args).await.unwrap(); + let res = HttpRequest::new("https://example.com") + .with_method(HttpMethod::GET) + .with_header("request_header_name", "request_header_value") + .with_body(vec![1]) + .with_max_response_bytes(100_000) + .send() + .await + .unwrap(); assert_eq!(res.status, 200u32); - assert_eq!( - res.headers, - vec![HttpHeader { - name: "response_header_name".to_string(), - value: "response_header_value".to_string(), - }] - ); + assert_eq!(res.headers, expected_response_headers()); assert_eq!(res.body, vec![42]); } /// Method is POST. #[update] async fn post() { - let args = HttpRequestArgs { - url: "https://example.com".to_string(), - method: HttpMethod::POST, - ..Default::default() - }; - - http_request(&args).await.unwrap(); + HttpRequest::new("https://example.com") + .with_method(HttpMethod::POST) + .send() + .await + .unwrap(); } /// Method is HEAD. #[update] async fn head() { - let args = HttpRequestArgs { - url: "https://example.com".to_string(), - method: HttpMethod::HEAD, - ..Default::default() - }; - - http_request(&args).await.unwrap(); + HttpRequest::new("https://example.com") + .with_method(HttpMethod::HEAD) + .send() + .await + .unwrap(); } /// The standard way to define a transform function. @@ -70,59 +63,41 @@ fn transform(args: TransformArgs) -> HttpRequestResult { } } -/// Set the transform field with the name of the transform query method. +/// Set the transform with the name of the transform query method. #[update] async fn get_with_transform() { - let args = HttpRequestArgs { - url: "https://example.com".to_string(), - method: HttpMethod::GET, - transform: Some(transform_context_from_query( + let res = HttpRequest::new("https://example.com") + .with_transform(transform_context_from_query( "transform".to_string(), vec![42], - )), - ..Default::default() - }; - - let res = http_request(&args).await.unwrap(); + )) + .send() + .await + .unwrap(); assert_eq!(res.status, 200u32); - assert_eq!( - res.headers, - vec![HttpHeader { - name: "response_header_name".to_string(), - value: "response_header_value".to_string(), - }] - ); + assert_eq!(res.headers, expected_response_headers()); // The first 42 is from the response body, the second 42 is from the transform context. assert_eq!(res.body, vec![42, 42]); } -/// Set the transform field with a closure. +/// Set the transform with a closure. #[update] async fn get_with_transform_closure() { - let transform = |args: HttpRequestResult| { - let mut body = args.body; - body.push(42); - HttpRequestResult { - status: args.status, - headers: args.headers, - body, - } - }; - let args = HttpRequestArgs { - url: "https://example.com".to_string(), - method: HttpMethod::GET, - transform: None, - ..Default::default() - }; - let res = http_request_with_closure(&args, transform).await.unwrap(); + let res = HttpRequest::new("https://example.com") + .with_transform_closure(|args: HttpRequestResult| { + let mut body = args.body; + body.push(42); + HttpRequestResult { + status: args.status, + headers: args.headers, + body, + } + }) + .send() + .await + .unwrap(); assert_eq!(res.status, 200u32); - assert_eq!( - res.headers, - vec![HttpHeader { - name: "response_header_name".to_string(), - value: "response_header_value".to_string(), - }] - ); + assert_eq!(res.headers, expected_response_headers()); // The first 42 is from the response body, the second 42 is from the transform closure. assert_eq!(res.body, vec![42, 42]); } @@ -130,14 +105,140 @@ async fn get_with_transform_closure() { /// Non replicated HTTP request. #[update] async fn non_replicated() { - let args = HttpRequestArgs { - url: "https://example.com".to_string(), - method: HttpMethod::GET, - is_replicated: Some(false), - ..Default::default() + HttpRequest::new("https://example.com") + .with_method(HttpMethod::GET) + .non_replicated() + .send() + .await + .unwrap(); +} + +/// Narrowing the expected resource usage must lower the cycles reservation. +#[update] +async fn expected_usage_lowers_cost() { + let worst_case = HttpRequest::new("https://example.com") + .with_max_response_bytes(4_000) + .get_cost(); + let expected = HttpRequest::new("https://example.com") + .with_max_response_bytes(4_000) + .with_expected_roundtrip_time_ms(300) + .with_expected_transform_instructions(1_000_000) + .get_cost(); + assert!( + expected < worst_case, + "expected {expected} should be below worst case {worst_case}" + ); +} + +/// Flexible outcall with the default replication counts. +/// +/// Returns how many responses were delivered, so that the test can check it against the +/// committee it answered. +#[update] +async fn flexible_default() -> u32 { + let res = FlexibleHttpRequest::new("https://example.com") + .with_method(HttpMethod::GET) + .with_header("request_header_name", "request_header_value") + .with_max_response_bytes(100_000) + .send() + .await + .unwrap(); + let FlexibleHttpRequestResult::Ok(responses) = res else { + panic!("expected responses, got {res:?}"); + }; + assert!(!responses.is_empty()); + for response in &responses { + assert_eq!(response.status, 200u32); + assert_eq!(response.headers, expected_response_headers()); + assert_eq!(response.body, vec![42]); + } + responses.len() as u32 +} + +/// Flexible outcall from a committee of one, whose single response must be delivered. +#[update] +async fn flexible_single() { + let res = FlexibleHttpRequest::new("https://example.com") + .with_replication(ReplicationCounts { + min_responses: 1, + max_responses: 1, + total_requests: 1, + }) + .send() + .await + .unwrap(); + let FlexibleHttpRequestResult::Ok(responses) = res else { + panic!("expected responses, got {res:?}"); + }; + assert_eq!(responses.len(), 1); + assert_eq!(responses[0].body, vec![42]); +} + +/// Each node runs the transform on its own response. +#[update] +async fn flexible_with_transform_closure() { + let res = FlexibleHttpRequest::new("https://example.com") + .with_replication(ReplicationCounts { + min_responses: 1, + max_responses: 1, + total_requests: 1, + }) + .with_transform_closure(|args: HttpRequestResult| { + let mut body = args.body; + body.push(42); + HttpRequestResult { + status: args.status, + headers: args.headers, + body, + } + }) + .send() + .await + .unwrap(); + let FlexibleHttpRequestResult::Ok(responses) = res else { + panic!("expected responses, got {res:?}"); }; + assert_eq!(responses.len(), 1); + // The first 42 is from the response body, the second 42 is from the transform closure. + assert_eq!(responses[0].body, vec![42, 42]); +} - http_request(&args).await.unwrap(); +/// A committee that only rejects reports `too_many_rejects`, and does so as a reply. +#[update] +async fn flexible_too_many_rejects() { + let res = FlexibleHttpRequest::new("https://example.com") + .with_replication(ReplicationCounts { + min_responses: 1, + max_responses: 1, + total_requests: 1, + }) + .send() + .await + .expect("the outcall itself must not be rejected"); + let FlexibleHttpRequestResult::Err(err) = res else { + panic!("expected an error result, got {res:?}"); + }; + assert_eq!( + err.global_error, + Some(FlexibleHttpGlobalError::TooManyRejects(Reserved)) + ); +} + +/// Narrowing the expected resource usage must lower the cycles reservation here too. +#[update] +async fn flexible_expected_usage_lowers_cost() { + let worst_case = FlexibleHttpRequest::new("https://example.com") + .with_max_response_bytes(4_000) + .get_cost(); + let expected = FlexibleHttpRequest::new("https://example.com") + .with_max_response_bytes(4_000) + .with_expected_roundtrip_time_ms(300) + .with_expected_transform_instructions(1_000_000) + .get_cost(); + assert!( + expected < worst_case, + "expected {expected} should be below worst case {worst_case}" + ); } fn main() {} diff --git a/e2e-tests/src/bin/management_canister.rs b/e2e-tests/src/bin/management_canister.rs index 8ef6be4a7..368f5e473 100644 --- a/e2e-tests/src/bin/management_canister.rs +++ b/e2e-tests/src/bin/management_canister.rs @@ -21,6 +21,7 @@ async fn basic() { wasm_memory_limit: Some(0u8.into()), wasm_memory_threshold: Some(0u8.into()), environment_variables: Some(vec![]), + ..Default::default() }), }; // 500 B is the minimum cycles required to create a canister. diff --git a/e2e-tests/src/bin/timers.rs b/e2e-tests/src/bin/timers.rs index 60dd3da9d..10f54afab 100644 --- a/e2e-tests/src/bin/timers.rs +++ b/e2e-tests/src/bin/timers.rs @@ -1,6 +1,6 @@ use futures::{StreamExt, stream::FuturesUnordered}; use ic_cdk::{api::canister_self, call::Call, futures::spawn, query, update}; -use ic_cdk_management_canister::{HttpMethod, HttpRequestArgs}; +use ic_cdk_management_canister::HttpMethod; use ic_cdk_timers::{TimerId, clear_timer, set_timer, set_timer_interval}; use std::{ cell::{Cell, RefCell}, @@ -101,20 +101,12 @@ fn start_repeating_serial() { .await .unwrap(); // best way of sleeping is a mocked http outcall - ic_cdk_management_canister::http_request_with_closure( - &HttpRequestArgs { - url: "http://mock".to_string(), - method: HttpMethod::GET, - headers: vec![], - body: None, - max_response_bytes: None, - transform: None, - is_replicated: None, - }, - |resp| resp, - ) - .await - .unwrap(); + ic_cdk_management_canister::HttpRequest::new("http://mock") + .with_method(HttpMethod::GET) + .with_transform_closure(|resp| resp) + .send() + .await + .unwrap(); }); REPEATING.with(|repeating| repeating.set(id)); } diff --git a/e2e-tests/tests/api.rs b/e2e-tests/tests/api.rs index 36fe7d922..c72663d36 100644 --- a/e2e-tests/tests/api.rs +++ b/e2e-tests/tests/api.rs @@ -122,6 +122,10 @@ fn call_api() { .update_call(canister_id, sender, "call_subnet_self", vec![]) .unwrap(); assert!(res.is_empty()); + let res = pic + .update_call(canister_id, sender, "call_subnet_self_node_count", vec![]) + .unwrap(); + assert!(res.is_empty()); // `msg_method_name` and `accept_message` are invoked in the inspect_message entry point. // Every calls above/below execute the inspect_message entry point. // So these two API bindings are tested implicitly. @@ -174,6 +178,10 @@ fn call_api() { .update_call(canister_id, sender, "call_cost_http_request", vec![]) .unwrap(); assert!(res.is_empty()); + let res = pic + .update_call(canister_id, sender, "call_cost_http_request_v2", vec![]) + .unwrap(); + assert!(res.is_empty()); let res = pic .update_call(canister_id, sender, "call_cost_sign_with_ecdsa", vec![]) .unwrap(); diff --git a/e2e-tests/tests/http_request.rs b/e2e-tests/tests/http_request.rs index 8fb366d3c..de96795ab 100644 --- a/e2e-tests/tests/http_request.rs +++ b/e2e-tests/tests/http_request.rs @@ -1,8 +1,9 @@ -use candid::{Encode, Principal}; +use candid::{Decode, Encode, Principal}; use pocket_ic::PocketIc; use pocket_ic::common::rest::{ - CanisterHttpHeader, CanisterHttpReply, CanisterHttpRequest, CanisterHttpResponse, - MockCanisterHttpResponse, + CanisterHttpHeader, CanisterHttpPricingVersion, CanisterHttpReject, CanisterHttpReplication, + CanisterHttpReply, CanisterHttpRequest, CanisterHttpResponse, MockCanisterHttpResponse, + MockFlexibleCanisterHttpResponse, }; mod test_utilities; @@ -23,6 +24,104 @@ fn test_http_request() { test_one_http_request(&pic, canister_id, "get_with_transform"); test_one_http_request(&pic, canister_id, "get_with_transform_closure"); test_one_http_request(&pic, canister_id, "non_replicated"); + // `get_cost` is pure, so this needs no mocked response. + pic.update_call( + canister_id, + Principal::anonymous(), + "expected_usage_lowers_cost", + vec![], + ) + .expect("expected_usage_lowers_cost failed"); +} + +#[test] +fn test_flexible_http_request() { + let wasm = cargo_build_canister("http_request"); + let pic = pic_base().build(); + + let canister_id = pic.create_canister(); + pic.add_cycles(canister_id, 3_000_000_000_000u128); + pic.install_canister(canister_id, wasm, vec![], None); + + // Answering exactly `min_responses` of the committee is what the outcall waits for, so + // that is how many responses come back. + let mut expected_responses = 0; + let res = test_one_flexible_http_request(&pic, canister_id, "flexible_default", |request| { + let CanisterHttpReplication::Flexible { min_responses, .. } = request.replication else { + panic!("expected a flexible outcall, got {:?}", request.replication); + }; + expected_responses = min_responses; + vec![reply(); min_responses as usize] + }); + assert_eq!(Decode!(&res, u32).unwrap(), expected_responses); + + test_one_flexible_http_request(&pic, canister_id, "flexible_single", |_| vec![reply()]); + test_one_flexible_http_request(&pic, canister_id, "flexible_with_transform_closure", |_| { + vec![reply()] + }); + test_one_flexible_http_request(&pic, canister_id, "flexible_too_many_rejects", |_| { + vec![CanisterHttpResponse::CanisterHttpReject( + CanisterHttpReject { + reject_code: 1, + message: "rejected".to_string(), + }, + )] + }); + // `get_cost` is pure, so this needs no mocked response. + pic.update_call( + canister_id, + Principal::anonymous(), + "flexible_expected_usage_lowers_cost", + vec![], + ) + .expect("flexible_expected_usage_lowers_cost failed"); +} + +fn reply() -> CanisterHttpResponse { + CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply { + status: 200, + headers: vec![CanisterHttpHeader { + name: "response_header_name".to_string(), + value: "response_header_value".to_string(), + }], + body: vec![42], + }) +} + +/// Answers the committee of one pending flexible outcall with what `responses` builds from +/// the replication counts the outcall was issued with. +fn test_one_flexible_http_request( + pic: &PocketIc, + canister_id: Principal, + method: &str, + responses: impl FnOnce(&CanisterHttpRequest) -> Vec, +) -> Vec { + let call_id = pic + .submit_call( + canister_id, + Principal::anonymous(), + method, + Encode!(&()).unwrap(), + ) + .unwrap(); + let canister_http_requests = tick_until_next_request(pic); + assert_eq!(canister_http_requests.len(), 1); + let request = &canister_http_requests[0]; + assert!(matches!( + request.replication, + CanisterHttpReplication::Flexible { .. } + )); + // Flexible outcalls are priced by version 2 only. + assert_eq!( + request.pricing_version, + CanisterHttpPricingVersion::PayAsYouGo + ); + pic.mock_flexible_canister_http_response(MockFlexibleCanisterHttpResponse { + subnet_id: request.subnet_id, + request_id: request.request_id, + responses: responses(request), + }); + pic.await_call(call_id).unwrap() } fn test_one_http_request(pic: &PocketIc, canister_id: Principal, method: &str) { @@ -37,6 +136,11 @@ fn test_one_http_request(pic: &PocketIc, canister_id: Principal, method: &str) { let canister_http_requests = tick_until_next_request(pic); assert_eq!(canister_http_requests.len(), 1); let request = &canister_http_requests[0]; + // The builder switched to version 2 pricing exclusively. + assert_eq!( + request.pricing_version, + CanisterHttpPricingVersion::PayAsYouGo + ); pic.mock_canister_http_response(MockCanisterHttpResponse { subnet_id: request.subnet_id, request_id: request.request_id, diff --git a/ic-cdk-management-canister/CHANGELOG.md b/ic-cdk-management-canister/CHANGELOG.md index 5f1526944..964535012 100644 --- a/ic-cdk-management-canister/CHANGELOG.md +++ b/ic-cdk-management-canister/CHANGELOG.md @@ -6,6 +6,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added + +- `HttpRequest`, a builder for `http_request`. It always selects pricing version `2` ("pay-as-you-go"), which charges for the resources the outcall consumes rather than for `max_response_bytes`. +- `FlexibleHttpRequest`, a builder for the new `flexible_http_request` method, in which a committee of nodes return their individual HTTP responses instead of the subnet reaching consensus on one. +- `with_expected_roundtrip_time_ms`, `with_expected_raw_response_bytes`, `with_expected_transformed_response_bytes` and `with_expected_transform_instructions` on both builders. Under pricing version `2` the attached cycles are also the budget each node may spend, so these narrow the reservation from "the most the outcall could consume" to what the caller expects. Anything left unset falls back to the maximum, which yields a reservation the outcall cannot exhaust but which holds far more cycles for the duration of the call. +- `with_transform_closure` on both builders, replacing the free function `http_request_with_closure` and extending closure transforms to flexible outcalls. +- `cost_http_request_v2` and its argument types `CostHttpRequestV2Args` and `HttpOutcallType`. +- Re-exports of the new `ic-management-canister-types` items: `FlexibleHttpRequestArgs`, `FlexibleHttpRequestResult`, `FlexibleHttpRequestErr`, `FlexibleHttpGlobalError`, `FlexibleHttpNodeDetail`, `FlexibleHttpNodeError`, `HttpRequestResourceReport`, `ReplicationCounts` and `ResourceUsage`. + +### Removed + +- The free functions `http_request`, `cost_http_request` and `http_request_with_closure`. Use `HttpRequest` instead, which prices the outcall with version `2`. Migrating deliberately rather than switching the pricing version underneath an unchanged call is the reason this is a breaking change rather than a silent one. + - `HttpRequest::from_args` accepts an existing `HttpRequestArgs`, so an existing call site can be migrated without rewriting how it builds its arguments. + - `ic_cdk::api::cost_http_request` still exposes version `1` pricing for callers that need it. + +### Changed + +- `ic-management-canister-types` bumped from `0.7.1` to `0.10`, which adds the `pricing_version` field to `HttpRequestArgs`, the `flexible_http_request` types, and three fields to `CanisterSettings`. + ## [0.1.1] - 2026-03-10 ### Fixed diff --git a/ic-cdk-management-canister/README.md b/ic-cdk-management-canister/README.md index ca4695260..d6d10ee13 100644 --- a/ic-cdk-management-canister/README.md +++ b/ic-cdk-management-canister/README.md @@ -57,9 +57,10 @@ async fn example() -> Result { Some management canister entry points require cycles to be attached to the call. The functions for calling management canister automatically calculate the required cycles and attach them to the call. +HTTPS outcalls go through the [`HttpRequest`] and [`FlexibleHttpRequest`] builders, which do the same and additionally let a caller narrow how many cycles are reserved. For completeness, this module also provides functions to calculate the cycle cost: -- [`cost_http_request`] +- [`cost_http_request_v2`] - [`cost_sign_with_ecdsa`] - [`cost_sign_with_schnorr`] - [`cost_vetkd_derive_key`] @@ -68,7 +69,9 @@ For completeness, this module also provides functions to calculate the cycle cos [unbounded-wait]: https://docs.rs/ic-cdk/latest/ic_cdk/call/struct.Call.html#method.unbounded_wait [`Call`]: https://docs.rs/ic-cdk/latest/ic_cdk/call/struct.Call.html [`sign_with_ecdsa`]: https://docs.rs/ic-cdk-management-canister/latest/ic_cdk_management_canister/fn.sign_with_ecdsa.html -[`cost_http_request`]: https://docs.rs/ic-cdk-management-canister/latest/ic_cdk_management_canister/fn.cost_http_request.html +[`cost_http_request_v2`]: https://docs.rs/ic-cdk-management-canister/latest/ic_cdk_management_canister/fn.cost_http_request_v2.html [`cost_sign_with_ecdsa`]: https://docs.rs/ic-cdk-management-canister/latest/ic_cdk_management_canister/fn.cost_sign_with_ecdsa.html [`cost_sign_with_schnorr`]: https://docs.rs/ic-cdk-management-canister/latest/ic_cdk_management_canister/fn.cost_sign_with_schnorr.html [`cost_vetkd_derive_key`]: https://docs.rs/ic-cdk-management-canister/latest/ic_cdk_management_canister/fn.cost_vetkd_derive_key.html +[`HttpRequest`]: https://docs.rs/ic-cdk-management-canister/latest/ic_cdk_management_canister/struct.HttpRequest.html +[`FlexibleHttpRequest`]: https://docs.rs/ic-cdk-management-canister/latest/ic_cdk_management_canister/struct.FlexibleHttpRequest.html diff --git a/ic-cdk-management-canister/src/lib.rs b/ic-cdk-management-canister/src/lib.rs index a5ee96ca5..17a978d81 100644 --- a/ic-cdk-management-canister/src/lib.rs +++ b/ic-cdk-management-canister/src/lib.rs @@ -1,10 +1,10 @@ #![doc = include_str!("../README.md")] #![cfg_attr(docsrs, feature(doc_cfg))] -use candid::{CandidType, Nat, Principal}; +use candid::{CandidType, Nat, Principal, Reserved}; use ic_cdk::api::{ SignCostError, canister_version, cost_create_canister, - cost_http_request as ic0_cost_http_request, cost_sign_with_ecdsa as ic0_cost_sign_with_ecdsa, + cost_sign_with_ecdsa as ic0_cost_sign_with_ecdsa, cost_sign_with_schnorr as ic0_cost_sign_with_schnorr, cost_vetkd_derive_key as ic0_cost_vetkd_derive_key, }; @@ -20,17 +20,19 @@ pub use ic_management_canister_types::{ CodeDeploymentRecord, ControllersChangeRecord, CreateCanisterResult, CreationRecord, DefiniteCanisterSettings, DeleteCanisterArgs, DeleteCanisterSnapshotArgs, DepositCyclesArgs, EcdsaCurve, EcdsaKeyId, EcdsaPublicKeyArgs, EcdsaPublicKeyResult, EnvironmentVariable, - FromCanisterRecord, FromUserRecord, HttpHeader, HttpMethod, HttpRequestArgs, HttpRequestResult, - ListCanisterSnapshotsArgs, ListCanisterSnapshotsResult, LoadSnapshotRecord, LogVisibility, - MemoryMetrics, NodeMetrics, NodeMetricsHistoryArgs, NodeMetricsHistoryRecord, + FlexibleHttpGlobalError, FlexibleHttpNodeDetail, FlexibleHttpNodeError, + FlexibleHttpRequestArgs, FlexibleHttpRequestErr, FlexibleHttpRequestResult, FromCanisterRecord, + FromUserRecord, HttpHeader, HttpMethod, HttpRequestArgs, HttpRequestResourceReport, + HttpRequestResult, ListCanisterSnapshotsArgs, ListCanisterSnapshotsResult, LoadSnapshotRecord, + LogVisibility, MemoryMetrics, NodeMetrics, NodeMetricsHistoryArgs, NodeMetricsHistoryRecord, NodeMetricsHistoryResult, OnLowWasmMemoryHookStatus, ProvisionalCreateCanisterWithCyclesResult, ProvisionalTopUpCanisterArgs, QueryStats, RawRandResult, ReadCanisterSnapshotDataArgs, ReadCanisterSnapshotDataResult, ReadCanisterSnapshotMetadataArgs, - ReadCanisterSnapshotMetadataResult, SchnorrAlgorithm, SchnorrAux, SchnorrKeyId, - SchnorrPublicKeyArgs, SchnorrPublicKeyResult, SignWithEcdsaArgs, SignWithEcdsaResult, - SignWithSchnorrArgs, SignWithSchnorrResult, Snapshot, SnapshotDataKind, SnapshotDataOffset, - SnapshotId, SnapshotMetadataGlobal, SnapshotSource, StartCanisterArgs, StopCanisterArgs, - StoredChunksArgs, StoredChunksResult, SubnetInfoArgs, SubnetInfoResult, + ReadCanisterSnapshotMetadataResult, ReplicationCounts, ResourceUsage, SchnorrAlgorithm, + SchnorrAux, SchnorrKeyId, SchnorrPublicKeyArgs, SchnorrPublicKeyResult, SignWithEcdsaArgs, + SignWithEcdsaResult, SignWithSchnorrArgs, SignWithSchnorrResult, Snapshot, SnapshotDataKind, + SnapshotDataOffset, SnapshotId, SnapshotMetadataGlobal, SnapshotSource, StartCanisterArgs, + StopCanisterArgs, StoredChunksArgs, StoredChunksResult, SubnetInfoArgs, SubnetInfoResult, TakeCanisterSnapshotArgs, TakeCanisterSnapshotResult, TransformArgs, TransformContext, TransformFunc, UpgradeFlags, UploadCanisterSnapshotDataArgs, UploadCanisterSnapshotMetadataArgs, UploadCanisterSnapshotMetadataResult, UploadChunkArgs, @@ -459,54 +461,565 @@ pub async fn raw_rand() -> CallResult { ) } -/// Calculates the cost of making an HTTP outcall with the given [`HttpRequestArgs`]. +/// The maximum `max_response_bytes` an HTTP outcall may declare, and the value used when the +/// field is omitted. +const MAX_RESPONSE_BYTES_LIMIT: u64 = 2_000_000; +/// The longest the system waits for an HTTP response. +const MAX_ROUNDTRIP_TIME_MS: u64 = 60_000; +/// The instruction limit of a query call, which bounds a `transform` function. +const MAX_TRANSFORM_INSTRUCTIONS: u64 = 5_000_000_000; +/// Bytes reserved on top of `max_response_bytes` for the Candid encoding of a response. +const CANDID_OVERHEAD_RESERVE_BYTES: u64 = 1_024; + +/// # HTTP Outcall Type. +/// +/// Which kind of HTTP outcall to price. See [`CostHttpRequestV2Args::outcall_type`]. +#[derive(CandidType, Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub enum HttpOutcallType { + /// An `http_request` with `is_replicated` set to `None` or `Some(true)`. + #[serde(rename = "fully_replicated")] + FullyReplicated(Reserved), + /// An `http_request` with `is_replicated` set to `Some(false)`. + #[serde(rename = "non_replicated")] + NonReplicated(Reserved), + /// A `flexible_http_request`, optionally with the replication counts it will use. + /// + /// If the counts are `None`, the endpoint's own defaults are priced. + #[serde(rename = "flexible")] + Flexible(Option), +} + +/// # Cost HTTP Request V2 Args. +/// +/// The resource usage to price. Argument type of [`cost_http_request_v2`]. +#[derive(CandidType, Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub struct CostHttpRequestV2Args { + /// The byte length of the URL, the header names and values, the body, and the transform + /// method name and context. + pub request_bytes: u64, + /// Milliseconds between sending the request and fully receiving the response. + pub http_roundtrip_time_ms: u64, + /// The byte length of the HTTP response, before transformation. + pub raw_response_bytes: u64, + /// The byte length of the response after transformation. + pub transformed_response_bytes: u64, + /// Instructions the transform function uses. + pub transform_instructions: u64, + /// The kind of outcall. If `None`, a fully replicated outcall is priced. + pub outcall_type: Option, +} + +/// Calculates the cost of an HTTP outcall priced with pricing version `2`. /// -/// [`http_request`] and [`http_request_with_closure`] invoke this method internally and attach the required cycles to the call. +/// This returns the amount to **attach** for an outcall that consumes exactly the resources in +/// `arg`, not a prediction of the charge. The surplus is refunded, so the eventual charge is at +/// most the amount attached. /// -/// # Note +/// [`HttpRequest`] and [`FlexibleHttpRequest`] invoke this internally and attach the result. +/// +/// # Panics /// -/// Alternatively, [`api::cost_http_request`][ic0_cost_http_request] requires manually calculating the request size and the maximum response size. -/// This method handles the calculation internally. -pub fn cost_http_request(arg: &HttpRequestArgs) -> u128 { - let request_size = (arg.url.len() - + arg - .headers +/// Panics if `arg` cannot be Candid-encoded, which cannot happen for a well-formed value. +pub fn cost_http_request_v2(arg: &CostHttpRequestV2Args) -> u128 { + let bytes = candid::encode_one(arg).expect("failed to Candid-encode the cost parameters"); + ic_cdk::api::cost_http_request_v2(&bytes) +} + +/// The resource usage a caller expects, used to size the cycles reservation. +/// +/// Any field left unset falls back to the maximum the outcall could consume, which yields a +/// reservation the outcall cannot exhaust. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct Reservation { + roundtrip_time_ms: Option, + raw_response_bytes: Option, + transformed_response_bytes: Option, + transform_instructions: Option, +} + +impl Reservation { + /// Resolves the expected usage against `max_response_bytes`, filling unset fields with the + /// maximum the outcall could consume. + fn resolve(&self, max_response_bytes: Option) -> (u64, u64, u64, u64) { + let cap = max_response_bytes.unwrap_or(MAX_RESPONSE_BYTES_LIMIT); + ( + self.roundtrip_time_ms.unwrap_or(MAX_ROUNDTRIP_TIME_MS), + self.raw_response_bytes.unwrap_or(cap), + self.transformed_response_bytes + .unwrap_or(cap.saturating_add(CANDID_OVERHEAD_RESERVE_BYTES)), + self.transform_instructions + .unwrap_or(MAX_TRANSFORM_INSTRUCTIONS), + ) + } +} + +/// Computes the `request_bytes` an outcall is charged for. +fn request_bytes( + url: &str, + headers: &[HttpHeader], + body: Option<&Vec>, + transform: Option<&TransformContext>, +) -> u64 { + (url.len() + + headers .iter() .map(|h| h.name.len() + h.value.len()) .sum::() - + arg.body.as_ref().map_or(0, |b| b.len()) - + arg - .transform - .as_ref() - .map_or(0, |t| t.context.len() + t.function.0.method.len())) - as u64; - // As stated here: https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request: - // "The upper limit on the maximal size for the response is 2MB (2,000,000B) and this value also applies if no maximal size value is specified." - let max_res_bytes = arg.max_response_bytes.unwrap_or(2_000_000); - ic0_cost_http_request(request_size, max_res_bytes) + + body.map_or(0, |b| b.len()) + + transform.map_or(0, |t| t.context.len() + t.function.0.method.len())) as u64 } -/// Makes an HTTP outcall. -/// -/// **Unbounded-wait call** -/// -/// See [IC method `http_request`](https://internetcomputer.org/docs/current/references/ic-interface-spec/#ic-http_request). -/// -/// # Note -/// -/// HTTP outcall costs cycles which varies with the request size and the maximum response size. -/// This method attaches the required cycles (detemined by [`cost_http_request`]) to the call. -/// -/// Check [HTTPS outcalls cycles cost](https://internetcomputer.org/docs/current/developer-docs/gas-cost#https-outcalls) for more details. -pub async fn http_request(arg: &HttpRequestArgs) -> CallResult { - let cycles = cost_http_request(arg); - Ok( - Call::unbounded_wait(Principal::management_canister(), "http_request") - .with_arg(arg) +/// A builder for an HTTP outcall via the Management canister method +/// [`http_request`](https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request). +/// +/// The outcall is always made with pricing version `2` ("pay-as-you-go"), which charges for the +/// resources the call actually consumes rather than for `max_response_bytes`. +/// +/// Because the cycles attached to a version `2` outcall are also the budget each node may spend +/// on it, the amount to attach depends on how much the call is expected to consume. Every +/// `with_expected_*` method narrows that estimate; whatever is left unset falls back to the most +/// the outcall could consume, which yields a reservation the outcall cannot exhaust but which +/// holds far more cycles for the duration of the call. +/// +/// Use [`FlexibleHttpRequest`] for an outcall whose nodes return their individual responses. +/// +/// # Examples +/// +/// ```no_run +/// # use ic_cdk_management_canister::{HttpRequest, HttpMethod}; +/// # async fn f() -> Result<(), Box> { +/// let response = HttpRequest::new("https://example.com/api") +/// .with_method(HttpMethod::POST) +/// .with_body(b"{}".to_vec()) +/// .with_max_response_bytes(4_000) +/// // a 200 ms call with a cheap transform reserves far less than the worst case +/// .with_expected_roundtrip_time_ms(200) +/// .with_expected_transform_instructions(1_000_000) +/// .send() +/// .await?; +/// # Ok(()) } +/// ``` +#[must_use = "an HttpRequest does nothing unless you call `send`"] +#[derive(Debug, Clone)] +pub struct HttpRequest { + args: HttpRequestArgs, + reservation: Reservation, + #[cfg(feature = "transform-closure")] + transform_guard: Option>, +} + +impl HttpRequest { + /// Starts building an outcall to `url`. + pub fn new(url: impl Into) -> Self { + Self { + args: HttpRequestArgs { + url: url.into(), + pricing_version: Some(2), + ..Default::default() + }, + reservation: Reservation::default(), + #[cfg(feature = "transform-closure")] + transform_guard: None, + } + } + + /// Starts building an outcall from an existing [`HttpRequestArgs`]. + /// + /// The `pricing_version` field is overwritten with `2`. + pub fn from_args(args: HttpRequestArgs) -> Self { + Self { + args: HttpRequestArgs { + pricing_version: Some(2), + ..args + }, + reservation: Reservation::default(), + #[cfg(feature = "transform-closure")] + transform_guard: None, + } + } + + /// Sets the HTTP method. Defaults to `GET`. + /// + /// `PUT`, `DELETE` and `PATCH` are accepted only in non-replicated mode, see + /// [`Self::non_replicated`]. + pub fn with_method(mut self, method: HttpMethod) -> Self { + self.args.method = method; + self + } + + /// Sets the request headers. + pub fn with_headers(mut self, headers: Vec) -> Self { + self.args.headers = headers; + self + } + + /// Appends one request header. + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.args.headers.push(HttpHeader { + name: name.into(), + value: value.into(), + }); + self + } + + /// Sets the request body. + pub fn with_body(mut self, body: Vec) -> Self { + self.args.body = Some(body); + self + } + + /// Sets the maximum size of the response in bytes, up to 2MB. + /// + /// Under pricing version `2` this no longer sets the price, but it still bounds the response + /// and it bounds how many cycles are held while the call runs. Setting it as low as the + /// response allows keeps the reservation small. + pub fn with_max_response_bytes(mut self, max_response_bytes: u64) -> Self { + self.args.max_response_bytes = Some(max_response_bytes); + self + } + + /// Sets the transform function, which each node runs on its own response. + pub fn with_transform(mut self, transform: TransformContext) -> Self { + self.args.transform = Some(transform); + self + } + + /// Makes the request from a single node chosen by the system, rather than from every node. + /// + /// This gives weaker integrity guarantees: the single node could observe or modify the + /// response. It avoids the rate-limit pressure of one request per node, and it is the only + /// mode in which `PUT`, `DELETE` and `PATCH` are accepted. + pub fn non_replicated(mut self) -> Self { + self.args.is_replicated = Some(false); + self + } + + /// Sets the round-trip time the outcall is expected to take, in milliseconds. + /// + /// Defaults to the 60 second maximum the system allows. + pub fn with_expected_roundtrip_time_ms(mut self, ms: u64) -> Self { + self.reservation.roundtrip_time_ms = Some(ms); + self + } + + /// Sets the size the response is expected to have as it arrives from the server. + /// + /// Defaults to `max_response_bytes`, or 2MB if that is unset. + pub fn with_expected_raw_response_bytes(mut self, bytes: u64) -> Self { + self.reservation.raw_response_bytes = Some(bytes); + self + } + + /// Sets the size the response is expected to have after the transform function. + /// + /// Defaults to `max_response_bytes` plus the bytes reserved for the Candid encoding, or 2MB + /// plus that reserve if `max_response_bytes` is unset. + pub fn with_expected_transformed_response_bytes(mut self, bytes: u64) -> Self { + self.reservation.transformed_response_bytes = Some(bytes); + self + } + + /// Sets the instructions the transform function is expected to use. + /// + /// Defaults to the query call instruction limit, which almost no transform approaches, so + /// setting this is usually the single largest reduction in the reservation. + pub fn with_expected_transform_instructions(mut self, instructions: u64) -> Self { + self.reservation.transform_instructions = Some(instructions); + self + } + + /// Sets a transform implemented as a closure, instead of an exported query method. + /// + /// Each node runs it on its own response. The closure is deregistered when this builder is + /// dropped, so a builder that is never sent does not leak it. + /// + /// # Panics + /// + /// Panics if a transform has already been set, as the two would conflict. + #[cfg(feature = "transform-closure")] + #[cfg_attr(docsrs, doc(cfg(feature = "transform-closure")))] + pub fn with_transform_closure( + mut self, + transform_func: impl FnOnce(HttpRequestResult) -> HttpRequestResult + 'static, + ) -> Self { + assert!( + self.args.transform.is_none(), + "a transform is already set on this outcall" + ); + let (transform, guard) = transform_closure::register(transform_func); + self.args.transform = Some(transform); + self.transform_guard = Some(std::sync::Arc::new(guard)); + self + } + + /// Returns the arguments the outcall will be made with. + pub fn args(&self) -> &HttpRequestArgs { + &self.args + } + + /// Returns the cycles that [`Self::send`] will attach. + pub fn get_cost(&self) -> u128 { + let (roundtrip, raw, transformed, instructions) = + self.reservation.resolve(self.args.max_response_bytes); + cost_http_request_v2(&CostHttpRequestV2Args { + request_bytes: request_bytes( + &self.args.url, + &self.args.headers, + self.args.body.as_ref(), + self.args.transform.as_ref(), + ), + http_roundtrip_time_ms: roundtrip, + raw_response_bytes: raw, + transformed_response_bytes: transformed, + transform_instructions: instructions, + // Absent means fully replicated, which is the encoding the system documents as the + // default. Note this still emits the field as `null`: Candid keeps an `opt` field in + // the type table, so it is not the same as omitting it. + outcall_type: if self.args.is_replicated == Some(false) { + Some(HttpOutcallType::NonReplicated(Reserved)) + } else { + None + }, + }) + } + + /// Makes the outcall, attaching [`Self::get_cost`] cycles. + /// + /// **Unbounded-wait call** + pub async fn send(self) -> CallResult { + let cycles = self.get_cost(); + let result = Call::unbounded_wait(Principal::management_canister(), "http_request") + .with_arg(&self.args) .with_cycles(cycles) .await? - .candid()?, - ) + .candid(); + // The transform guard, if any, must outlive the call. + #[cfg(feature = "transform-closure")] + drop(self.transform_guard); + Ok(result?) + } +} + +/// A builder for a flexible HTTP outcall via the Management canister method +/// [`flexible_http_request`](https://internetcomputer.org/docs/references/ic-interface-spec#ic-flexible_http_request). +/// +/// A committee of nodes make the request and the canister receives their individual responses +/// rather than one the subnet agreed on, so reconciling them is the canister's job. Flexible +/// outcalls are always priced with pricing version `2`. +/// +/// # Examples +/// +/// ```no_run +/// # use ic_cdk_management_canister::{FlexibleHttpRequest, FlexibleHttpRequestResult, ReplicationCounts}; +/// # async fn f() -> Result<(), Box> { +/// // ask 3 nodes, accept any 2 or 3 answers +/// let result = FlexibleHttpRequest::new("https://example.com/price") +/// .with_replication(ReplicationCounts { +/// min_responses: 2, +/// max_responses: 3, +/// total_requests: 3, +/// }) +/// .with_max_response_bytes(4_000) +/// .send() +/// .await?; +/// match result { +/// // fewer than `max_responses` is a normal success +/// FlexibleHttpRequestResult::Ok(responses) => { let _ = responses; } +/// FlexibleHttpRequestResult::Err(err) => { let _ = err.global_error; } +/// } +/// # Ok(()) } +/// ``` +#[must_use = "a FlexibleHttpRequest does nothing unless you call `send`"] +#[derive(Debug, Clone)] +pub struct FlexibleHttpRequest { + args: FlexibleHttpRequestArgs, + reservation: Reservation, + #[cfg(feature = "transform-closure")] + transform_guard: Option>, +} + +impl FlexibleHttpRequest { + /// Starts building a flexible outcall to `url`. + pub fn new(url: impl Into) -> Self { + Self { + args: FlexibleHttpRequestArgs { + url: url.into(), + ..Default::default() + }, + reservation: Reservation::default(), + #[cfg(feature = "transform-closure")] + transform_guard: None, + } + } + + /// Starts building a flexible outcall from an existing [`FlexibleHttpRequestArgs`]. + pub fn from_args(args: FlexibleHttpRequestArgs) -> Self { + Self { + args, + reservation: Reservation::default(), + #[cfg(feature = "transform-closure")] + transform_guard: None, + } + } + + /// Sets the HTTP method. Defaults to `GET`. + /// + /// `PUT`, `DELETE` and `PATCH` are accepted only when the replication counts are + /// deterministic, that is when `min_responses`, `max_responses` and `total_requests` are all + /// equal. + pub fn with_method(mut self, method: HttpMethod) -> Self { + self.args.method = method; + self + } + + /// Sets the request headers. + pub fn with_headers(mut self, headers: Vec) -> Self { + self.args.headers = headers; + self + } + + /// Appends one request header. + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.args.headers.push(HttpHeader { + name: name.into(), + value: value.into(), + }); + self + } + + /// Sets the request body. + pub fn with_body(mut self, body: Vec) -> Self { + self.args.body = Some(body); + self + } + + /// Sets the maximum size of any single node's response in bytes, up to 2MB. + /// + /// The responses delivered together must also fit a 2MiB total, so this should be no larger + /// than `2MiB / min_responses` unless a transform brings the delivered size below that. + pub fn with_max_response_bytes(mut self, max_response_bytes: u64) -> Self { + self.args.max_response_bytes = Some(max_response_bytes); + self + } + + /// Sets the transform function, which each node runs on its own response. + pub fn with_transform(mut self, transform: TransformContext) -> Self { + self.args.transform = Some(transform); + self + } + + /// Sets how many nodes issue the request and how many responses to require and accept. + /// + /// Must satisfy `0 <= min_responses <= max_responses <= total_requests` and + /// `1 <= total_requests <= N`, where `N` is + /// [`subnet_self_node_count`](ic_cdk::api::subnet_self_node_count). Defaults to + /// `floor(2 / 3 * N) + 1`, `N` and `N`. + pub fn with_replication(mut self, replication: ReplicationCounts) -> Self { + self.args.replication = Some(replication); + self + } + + /// Sets the round-trip time the outcall is expected to take, in milliseconds. + /// + /// Defaults to the 60 second maximum the system allows. + pub fn with_expected_roundtrip_time_ms(mut self, ms: u64) -> Self { + self.reservation.roundtrip_time_ms = Some(ms); + self + } + + /// Sets the size a response is expected to have as it arrives from the server. + /// + /// Defaults to `max_response_bytes`, or 2MB if that is unset. + pub fn with_expected_raw_response_bytes(mut self, bytes: u64) -> Self { + self.reservation.raw_response_bytes = Some(bytes); + self + } + + /// Sets the size a response is expected to have after the transform function. + /// + /// Defaults to `max_response_bytes` plus the bytes reserved for the Candid encoding, or 2MB + /// plus that reserve if `max_response_bytes` is unset. + pub fn with_expected_transformed_response_bytes(mut self, bytes: u64) -> Self { + self.reservation.transformed_response_bytes = Some(bytes); + self + } + + /// Sets the instructions the transform function is expected to use. + /// + /// Defaults to the query call instruction limit. + pub fn with_expected_transform_instructions(mut self, instructions: u64) -> Self { + self.reservation.transform_instructions = Some(instructions); + self + } + + /// Sets a transform implemented as a closure, instead of an exported query method. + /// + /// Each node runs it on its own response. The closure is deregistered when this builder is + /// dropped, so a builder that is never sent does not leak it. + /// + /// # Panics + /// + /// Panics if a transform has already been set, as the two would conflict. + #[cfg(feature = "transform-closure")] + #[cfg_attr(docsrs, doc(cfg(feature = "transform-closure")))] + pub fn with_transform_closure( + mut self, + transform_func: impl FnOnce(HttpRequestResult) -> HttpRequestResult + 'static, + ) -> Self { + assert!( + self.args.transform.is_none(), + "a transform is already set on this flexible outcall" + ); + let (transform, guard) = transform_closure::register(transform_func); + self.args.transform = Some(transform); + self.transform_guard = Some(std::sync::Arc::new(guard)); + self + } + + /// Returns the arguments the outcall will be made with. + pub fn args(&self) -> &FlexibleHttpRequestArgs { + &self.args + } + + /// Returns the cycles that [`Self::send`] will attach. + pub fn get_cost(&self) -> u128 { + let (roundtrip, raw, transformed, instructions) = + self.reservation.resolve(self.args.max_response_bytes); + cost_http_request_v2(&CostHttpRequestV2Args { + request_bytes: request_bytes( + &self.args.url, + &self.args.headers, + self.args.body.as_ref(), + self.args.transform.as_ref(), + ), + http_roundtrip_time_ms: roundtrip, + raw_response_bytes: raw, + transformed_response_bytes: transformed, + transform_instructions: instructions, + outcall_type: Some(HttpOutcallType::Flexible(self.args.replication.clone())), + }) + } + + /// Makes the outcall, attaching [`Self::get_cost`] cycles. + /// + /// **Unbounded-wait call** + /// + /// Both arms of [`FlexibleHttpRequestResult`] arrive as a reply. This returns `Err` only for + /// failures detected before the requests are issued, such as invalid arguments, invalid + /// replication counts, or too few attached cycles. + pub async fn send(self) -> CallResult { + let cycles = self.get_cost(); + let result = + Call::unbounded_wait(Principal::management_canister(), "flexible_http_request") + .with_arg(&self.args) + .with_cycles(cycles) + .await? + .candid(); + // The transform guard, if any, must outlive the call. + #[cfg(feature = "transform-closure")] + drop(self.transform_guard); + Ok(result?) + } } /// Constructs a [`TransformContext`] from a query method name and context. @@ -525,10 +1038,7 @@ pub fn transform_context_from_query( #[cfg(feature = "transform-closure")] mod transform_closure { - use super::{ - CallResult, HttpRequestArgs, HttpRequestResult, Principal, TransformArgs, http_request, - transform_context_from_query, - }; + use super::{HttpRequestResult, Principal, TransformArgs, TransformContext}; use candid::{decode_one, encode_one}; use slotmap::{DefaultKey, Key, KeyData, SlotMap}; use std::cell::RefCell; @@ -568,60 +1078,36 @@ mod transform_closure { }); } - /// Makes an HTTP outcall and transforms the response using a closure. - /// - /// **Unbounded-wait call** - /// - /// See [IC method `http_request`](https://internetcomputer.org/docs/current/references/ic-interface-spec/#ic-http_request). + /// Deregisters a transform closure when dropped. /// - /// # Panics - /// - /// This method will panic if the `transform` field in `arg` is not `None`, - /// as it would conflict with the transform function provided by the closure. - /// - /// # Note - /// - /// This method provides a straightforward way to transform the HTTP outcall result. - /// If you need to specify a custom transform [`context`](`ic_management_canister_types::TransformContext::context`), - /// please use [`http_request`] instead. - /// - /// HTTP outcall costs cycles which varies with the request size and the maximum response size. - /// This method attaches the required cycles (detemined by [`cost_http_request`](ic_cdk::api::cost_http_request)) to the call. - /// - /// Check [Gas and cycles cost](https://internetcomputer.org/docs/current/developer-docs/gas-cost) for more details. - #[cfg_attr(docsrs, doc(cfg(feature = "transform-closure")))] - pub async fn http_request_with_closure( - arg: &HttpRequestArgs, + /// A request builder holds this until its call completes, so that a builder which is dropped + /// without being sent does not leak the closure. + #[derive(Debug)] + pub struct TransformGuard(DefaultKey); + + impl Drop for TransformGuard { + fn drop(&mut self) { + TRANSFORMS.with(|transforms| transforms.borrow_mut().remove(self.0)); + } + } + + /// Registers `transform_func` and returns the [`TransformContext`] that routes to it, plus a + /// guard that deregisters it when dropped. + pub fn register( transform_func: impl FnOnce(HttpRequestResult) -> HttpRequestResult + 'static, - ) -> CallResult { - assert!( - arg.transform.is_none(), - "The `transform` field in `HttpRequestArgs` must be `None` when using a closure" - ); + ) -> (TransformContext, TransformGuard) { let transform_func = Box::new(transform_func) as _; let key = TRANSFORMS.with(|transforms| transforms.borrow_mut().insert(transform_func)); - struct DropGuard(DefaultKey); - impl Drop for DropGuard { - fn drop(&mut self) { - TRANSFORMS.with(|transforms| transforms.borrow_mut().remove(self.0)); - } - } - let key = DropGuard(key); - let context = key.0.data().as_ffi().to_be_bytes().to_vec(); - let arg = HttpRequestArgs { - transform: Some(transform_context_from_query( - " http_transform".to_string(), - context, - )), - ..arg.clone() - }; - http_request(&arg).await + let guard = TransformGuard(key); + let context = guard.0.data().as_ffi().to_be_bytes().to_vec(); + let transform = super::transform_context_from_query( + " http_transform".to_string(), + context, + ); + (transform, guard) } } -#[cfg(feature = "transform-closure")] -pub use transform_closure::http_request_with_closure; - /// Gets a SEC1 encoded ECDSA public key for the given canister using the given derivation path. /// /// **Bounded-wait call** @@ -1063,3 +1549,135 @@ pub async fn delete_canister_snapshot(arg: &DeleteCanisterSnapshotArgs) -> CallR .candid()?, ) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The cost parameter record must round-trip through Candid, since it is handed to the + /// system API as an encoded blob. + #[test] + fn cost_args_candid_round_trip() { + for outcall_type in [ + None, + Some(HttpOutcallType::FullyReplicated(Reserved)), + Some(HttpOutcallType::NonReplicated(Reserved)), + Some(HttpOutcallType::Flexible(None)), + Some(HttpOutcallType::Flexible(Some(ReplicationCounts { + min_responses: 2, + max_responses: 3, + total_requests: 3, + }))), + ] { + let args = CostHttpRequestV2Args { + request_bytes: 1, + http_roundtrip_time_ms: 2, + raw_response_bytes: 3, + transformed_response_bytes: 4, + transform_instructions: 5, + outcall_type, + }; + let bytes = candid::encode_one(&args).unwrap(); + let decoded: CostHttpRequestV2Args = candid::decode_one(&bytes).unwrap(); + assert_eq!(args, decoded); + } + } + + /// An unset expectation must fall back to the maximum the outcall could consume. + #[test] + fn reservation_defaults_to_the_maxima() { + let (roundtrip, raw, transformed, instructions) = Reservation::default().resolve(None); + assert_eq!(roundtrip, MAX_ROUNDTRIP_TIME_MS); + assert_eq!(raw, MAX_RESPONSE_BYTES_LIMIT); + assert_eq!( + transformed, + MAX_RESPONSE_BYTES_LIMIT + CANDID_OVERHEAD_RESERVE_BYTES + ); + assert_eq!(instructions, MAX_TRANSFORM_INSTRUCTIONS); + } + + /// `max_response_bytes` bounds both response sizes when they are not given explicitly. + #[test] + fn reservation_defaults_follow_max_response_bytes() { + let (_, raw, transformed, _) = Reservation::default().resolve(Some(4_000)); + assert_eq!(raw, 4_000); + assert_eq!(transformed, 4_000 + CANDID_OVERHEAD_RESERVE_BYTES); + } + + /// An explicit expectation must win over the default. + #[test] + fn reservation_uses_supplied_values() { + let reservation = Reservation { + roundtrip_time_ms: Some(300), + raw_response_bytes: Some(1_000), + transformed_response_bytes: Some(900), + transform_instructions: Some(1_000_000), + }; + assert_eq!( + reservation.resolve(Some(4_000)), + (300, 1_000, 900, 1_000_000) + ); + } + + /// The builder must always ask for pricing version 2, including via `from_args`. + #[test] + fn builder_always_selects_pricing_version_2() { + assert_eq!( + HttpRequest::new("https://example.com") + .args() + .pricing_version, + Some(2) + ); + let args = HttpRequestArgs { + url: "https://example.com".to_string(), + pricing_version: Some(1), + ..Default::default() + }; + assert_eq!(HttpRequest::from_args(args).args().pricing_version, Some(2)); + } + + /// `non_replicated` must be reflected in both the args and the priced outcall type. + #[test] + fn non_replicated_is_recorded() { + let req = HttpRequest::new("https://example.com").non_replicated(); + assert_eq!(req.args().is_replicated, Some(false)); + } + + /// The flexible builder records the replication counts, and leaves them unset so the + /// system picks its defaults when the caller does not. + #[test] + fn flexible_replication_is_recorded() { + assert_eq!( + FlexibleHttpRequest::new("https://example.com") + .args() + .replication, + None + ); + let counts = ReplicationCounts { + min_responses: 2, + max_responses: 3, + total_requests: 3, + }; + assert_eq!( + FlexibleHttpRequest::new("https://example.com") + .with_replication(counts.clone()) + .args() + .replication, + Some(counts) + ); + } + + /// `request_bytes` counts the URL, the headers, the body and the transform. + #[test] + fn request_bytes_counts_every_variable_part() { + let headers = vec![HttpHeader { + name: "ab".to_string(), + value: "cde".to_string(), + }]; + let body = vec![0u8; 7]; + assert_eq!( + request_bytes("https://x", &headers, Some(&body), None), + (9 + 2 + 3 + 7) as u64 + ); + } +} diff --git a/ic-cdk/CHANGELOG.md b/ic-cdk/CHANGELOG.md index 054955739..3044c09ff 100644 --- a/ic-cdk/CHANGELOG.md +++ b/ic-cdk/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added + +- `api::subnet_self_node_count`, returning the number of nodes on the subnet. Useful for computing valid replication bounds for `flexible_http_request`. +- `api::cost_http_request_v2`, pricing a canister HTTPS outcall under pricing version `2`. It takes the Candid-encoded parameter record; prefer the typed wrappers in `ic-cdk-management-canister`. + ## [0.20.2] - 2026-06-08 ### Changed diff --git a/ic-cdk/src/api.rs b/ic-cdk/src/api.rs index 8ee108b5a..0266403b6 100644 --- a/ic-cdk/src/api.rs +++ b/ic-cdk/src/api.rs @@ -267,6 +267,15 @@ pub fn subnet_self() -> Principal { Principal::try_from(&buf).unwrap() } +/// Gets the number of nodes on the subnet on which the canister is running. +/// +/// This is useful for computing valid replication bounds for the Management canister method +/// [`flexible_http_request`](https://internetcomputer.org/docs/references/ic-interface-spec#ic-flexible_http_request), +/// whose `total_requests` must not exceed the number of nodes on the subnet. +pub fn subnet_self_node_count() -> u32 { + ic0::subnet_self_node_count() +} + /// Gets the name of the method to be inspected. /// /// This function is only available in the `canister_inspect_message` context. @@ -502,6 +511,23 @@ pub fn cost_http_request(request_size: u64, max_res_bytes: u64) -> u128 { ic0::cost_http_request(request_size, max_res_bytes) } +/// Gets the cycle cost of a canister HTTPS outcall priced with pricing version `2`. +/// +/// This prices both [`http_request`](https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request) +/// with `pricing_version` set to `2` and +/// [`flexible_http_request`](https://internetcomputer.org/docs/references/ic-interface-spec#ic-flexible_http_request), +/// which is always priced this way. +/// +/// `params` must be the Candid encoding of the parameter record documented for +/// [`ic0.cost_http_request_v2`](https://internetcomputer.org/docs/references/ic-interface-spec#system-api-cycle-cost). +/// This function traps if it is not. +/// +/// Prefer the typed wrappers in the `ic-cdk-management-canister` crate, which build and encode +/// the record for you. +pub fn cost_http_request_v2(params: &[u8]) -> u128 { + ic0::cost_http_request_v2(params) +} + /// The error type for [`cost_sign_with_ecdsa`] and [`cost_sign_with_schnorr`]. #[derive(thiserror::Error, Debug, Clone)] pub enum SignCostError { diff --git a/ic0/CHANGELOG.md b/ic0/CHANGELOG.md index 9de489250..68040e3b6 100644 --- a/ic0/CHANGELOG.md +++ b/ic0/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added + +- Added the `subnet_self_node_count` and `cost_http_request_v2` API bindings. + ## [1.1.0] - 2026-04-20 ### Added diff --git a/ic0/ic0.txt b/ic0/ic0.txt index bf3b3c729..0ae4b2633 100644 --- a/ic0/ic0.txt +++ b/ic0/ic0.txt @@ -33,6 +33,7 @@ ic0.subnet_self_size : () -> I; // * ic0.subnet_self_copy : (dst : I, offset : I, size : I) -> (); // * + ic0.subnet_self_node_count : () -> i32; // * ic0.msg_method_name_size : () -> I; // F ic0.msg_method_name_copy : (dst : I, offset : I, size : I) -> (); // F @@ -75,6 +76,7 @@ ic0.cost_call : (method_name_size: i64, payload_size : i64, dst : I) -> (); // * s ic0.cost_create_canister : (dst : I) -> (); // * s ic0.cost_http_request : (request_size : i64, max_res_bytes : i64, dst : I) -> (); // * s + ic0.cost_http_request_v2 : (params_src : I, params_size : I, dst : I) -> (); // * s ic0.cost_sign_with_ecdsa : (src : I, size : I, ecdsa_curve: i32, dst : I) -> i32; // * s ic0.cost_sign_with_schnorr : (src : I, size : I, algorithm: i32, dst : I) -> i32; // * s ic0.cost_vetkd_derive_key : (src : I, size : I, vetkd_curve: i32, dst : I) -> i32; // * s diff --git a/ic0/manual_safety_comments.txt b/ic0/manual_safety_comments.txt index f7de05714..3343b8484 100644 --- a/ic0/manual_safety_comments.txt +++ b/ic0/manual_safety_comments.txt @@ -57,6 +57,8 @@ ic0.subnet_self_size : () -> I; Always safe to call ic0.subnet_self_copy : (dst : I, offset : I, size : I) -> (); // * `dst` must be a pointer to a writable sequence of bytes with size `size`. The `offset` parameter does not affect safety. +ic0.subnet_self_node_count : () -> i32; // * + Always safe to call ic0.msg_method_name_size : () -> I; // F Always safe to call ic0.msg_method_name_copy : (dst : I, offset : I, size : I) -> (); // F @@ -130,6 +132,9 @@ ic0.cost_create_canister : (dst : I) -> (); `dst` must be a pointer to a writable sequence of 16 bytes (LE u128) ic0.cost_http_request : (request_size : i64, max_res_bytes : i64, dst : I) -> (); // * s `dst` must be a pointer to a writable sequence of 16 bytes (LE u128). The `request_size` and `max_res_bytes` parameters do not affect safety +ic0.cost_http_request_v2 : (params_src : I, params_size : I, dst : I) -> (); // * s + - `params_src` must be a pointer to a readable sequence of bytes with size `params_size` + - `dst` must be a pointer to a writable sequence of 16 bytes (LE u128) ic0.cost_sign_with_ecdsa : (src : I, size : I, ecdsa_curve: i32, dst : I) -> i32; // * s - `src` must be a pointer to a readable UTF-8 string with size `size` - `dst` must be a pointer to a writable sequence of 16 bytes (LE u128) diff --git a/ic0/src/lib.rs b/ic0/src/lib.rs index a7e5f2e80..c7deed733 100644 --- a/ic0/src/lib.rs +++ b/ic0/src/lib.rs @@ -260,6 +260,12 @@ pub fn subnet_self_size() -> usize { unsafe { sys::subnet_self_size() } } +#[inline] +pub fn subnet_self_node_count() -> u32 { + // SAFETY: ic0.subnet_self_node_count is always safe to call. + unsafe { sys::subnet_self_node_count() } +} + #[inline] pub fn subnet_self_copy(dst: &mut [u8], offset: usize) { // SAFETY: dst is a writable sequence of bytes and therefore safe to pass as ptr and len to ic0.subnet_self_copy @@ -580,6 +586,22 @@ pub fn cost_http_request(request_size: u64, max_res_bytes: u64) -> u128 { u128::from_le_bytes(dst_bytes) } +#[inline] +pub fn cost_http_request_v2(params: &[u8]) -> u128 { + let mut dst_bytes = [0_u8; 16]; + // SAFETY: params is a readable sequence of bytes of length params.len(), and dst_bytes is a + // writable sequence of 16 bytes, and therefore both are safe to pass as ptrs to + // ic0.cost_http_request_v2 + unsafe { + sys::cost_http_request_v2( + params.as_ptr() as usize, + params.len(), + dst_bytes.as_mut_ptr() as usize, + ); + } + u128::from_le_bytes(dst_bytes) +} + #[inline] pub fn cost_sign_with_ecdsa(key_name: &str, ecdsa_curve: u32) -> (u128, u32) { let mut dst_bytes = [0_u8; 16]; diff --git a/ic0/src/sys.rs b/ic0/src/sys.rs index 6be373523..7036d12e7 100644 --- a/ic0/src/sys.rs +++ b/ic0/src/sys.rs @@ -58,6 +58,8 @@ unsafe extern "C" { #[doc = "# Safety\n\n`dst` must be a pointer to a writable sequence of bytes with size `size`. The `offset` parameter does not affect safety."] pub fn subnet_self_copy(dst: usize, offset: usize, size: usize); #[doc = "# Safety\n\nAlways safe to call"] + pub fn subnet_self_node_count() -> u32; + #[doc = "# Safety\n\nAlways safe to call"] pub fn msg_method_name_size() -> usize; #[doc = "# Safety\n\n`dst` must be a pointer to a writable sequence of bytes with size `size`. The `offset` parameter does not affect safety."] pub fn msg_method_name_copy(dst: usize, offset: usize, size: usize); @@ -120,6 +122,8 @@ unsafe extern "C" { pub fn cost_create_canister(dst: usize); #[doc = "# Safety\n\n`dst` must be a pointer to a writable sequence of 16 bytes (LE u128). The `request_size` and `max_res_bytes` parameters do not affect safety"] pub fn cost_http_request(request_size: u64, max_res_bytes: u64, dst: usize); + #[doc = "# Safety\n\n- `params_src` must be a pointer to a readable sequence of bytes with size `params_size`\n- `dst` must be a pointer to a writable sequence of 16 bytes (LE u128)"] + pub fn cost_http_request_v2(params_src: usize, params_size: usize, dst: usize); #[doc = "# Safety\n\n- `src` must be a pointer to a readable UTF-8 string with size `size`\n- `dst` must be a pointer to a writable sequence of 16 bytes (LE u128)\n- The `ecdsa_curve` parameter does not affect safety"] pub fn cost_sign_with_ecdsa(src: usize, size: usize, ecdsa_curve: u32, dst: usize) -> u32; #[doc = "# Safety\n\n- `src` must be a pointer to a readable UTF-8 string with size `size`\n- `dst` must be a pointer to a writable sequence of 16 bytes (LE u128)\n- The `algorithm` parameter does not affect safety"] @@ -264,6 +268,10 @@ mod non_wasm { panic!("subnet_self_copy should only be called inside canisters."); } #[doc = "# Safety\n\nAlways safe to call"] + pub unsafe fn subnet_self_node_count() -> u32 { + panic!("subnet_self_node_count should only be called inside canisters."); + } + #[doc = "# Safety\n\nAlways safe to call"] pub unsafe fn msg_method_name_size() -> usize { panic!("msg_method_name_size should only be called inside canisters."); } @@ -380,6 +388,10 @@ mod non_wasm { pub unsafe fn cost_http_request(request_size: u64, max_res_bytes: u64, dst: usize) { panic!("cost_http_request should only be called inside canisters."); } + #[doc = "# Safety\n\n- `params_src` must be a pointer to a readable sequence of bytes with size `params_size`\n- `dst` must be a pointer to a writable sequence of 16 bytes (LE u128)"] + pub unsafe fn cost_http_request_v2(params_src: usize, params_size: usize, dst: usize) { + panic!("cost_http_request_v2 should only be called inside canisters."); + } #[doc = "# Safety\n\n- `src` must be a pointer to a readable UTF-8 string with size `size`\n- `dst` must be a pointer to a writable sequence of 16 bytes (LE u128)\n- The `ecdsa_curve` parameter does not affect safety"] pub unsafe fn cost_sign_with_ecdsa( src: usize, From 6b6c888c722727058bc6089f1ad11b4e5f5f7893 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 10 Sep 2026 11:45:46 +0000 Subject: [PATCH 2/5] fixes --- e2e-tests/src/bin/canister_info.rs | 6 +- e2e-tests/src/bin/management_canister.rs | 16 +++++- ic-cdk-management-canister/CHANGELOG.md | 1 + ic-cdk-management-canister/src/lib.rs | 70 +++++++++++++++++++----- ic-cdk/src/api.rs | 4 -- ic0/ic0.txt | 2 +- ic0/manual_safety_comments.txt | 2 +- 7 files changed, 78 insertions(+), 23 deletions(-) diff --git a/e2e-tests/src/bin/canister_info.rs b/e2e-tests/src/bin/canister_info.rs index 7d0f33b9f..0e470a2ca 100644 --- a/e2e-tests/src/bin/canister_info.rs +++ b/e2e-tests/src/bin/canister_info.rs @@ -69,14 +69,14 @@ async fn canister_lifecycle() -> Principal { memory_allocation: None, freezing_threshold: None, reserved_cycles_limit: None, + minimum_incoming_canister_call_cycles: None, log_visibility: None, log_memory_limit: None, + snapshot_visibility: None, + status_visibility: None, wasm_memory_limit: None, wasm_memory_threshold: None, environment_variables: None, - minimum_incoming_canister_call_cycles: None, - snapshot_visibility: None, - status_visibility: None, }, canister_id, }) diff --git a/e2e-tests/src/bin/management_canister.rs b/e2e-tests/src/bin/management_canister.rs index 368f5e473..9f5d6728b 100644 --- a/e2e-tests/src/bin/management_canister.rs +++ b/e2e-tests/src/bin/management_canister.rs @@ -16,12 +16,14 @@ async fn basic() { // Since around 2025-04, the freezing threshold is enforced to be at least 604800 seconds (7 days). freezing_threshold: Some(604_800u32.into()), reserved_cycles_limit: Some(0u8.into()), + minimum_incoming_canister_call_cycles: Some(1_000u32.into()), log_visibility: Some(LogVisibility::Public), log_memory_limit: Some(0u8.into()), + snapshot_visibility: Some(SnapshotVisibility::Public), + status_visibility: Some(StatusVisibility::Public), wasm_memory_limit: Some(0u8.into()), wasm_memory_threshold: Some(0u8.into()), environment_variables: Some(vec![]), - ..Default::default() }), }; // 500 B is the minimum cycles required to create a canister. @@ -42,11 +44,23 @@ async fn basic() { assert_eq!(definite_canister_setting.memory_allocation, 0u8); assert_eq!(definite_canister_setting.freezing_threshold, 604_800u32); assert_eq!(definite_canister_setting.reserved_cycles_limit, 0u8); + assert_eq!( + definite_canister_setting.minimum_incoming_canister_call_cycles, + 1_000u32 + ); assert_eq!( definite_canister_setting.log_visibility, LogVisibility::Public ); assert_eq!(definite_canister_setting.log_memory_limit, 0u8); + assert_eq!( + definite_canister_setting.snapshot_visibility, + SnapshotVisibility::Public + ); + assert_eq!( + definite_canister_setting.status_visibility, + StatusVisibility::Public + ); assert_eq!(definite_canister_setting.wasm_memory_limit, 0u8); assert_eq!(definite_canister_setting.wasm_memory_threshold, 0u8); // memory_metrics diff --git a/ic-cdk-management-canister/CHANGELOG.md b/ic-cdk-management-canister/CHANGELOG.md index 964535012..4551c495f 100644 --- a/ic-cdk-management-canister/CHANGELOG.md +++ b/ic-cdk-management-canister/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `with_expected_roundtrip_time_ms`, `with_expected_raw_response_bytes`, `with_expected_transformed_response_bytes` and `with_expected_transform_instructions` on both builders. Under pricing version `2` the attached cycles are also the budget each node may spend, so these narrow the reservation from "the most the outcall could consume" to what the caller expects. Anything left unset falls back to the maximum, which yields a reservation the outcall cannot exhaust but which holds far more cycles for the duration of the call. - `with_transform_closure` on both builders, replacing the free function `http_request_with_closure` and extending closure transforms to flexible outcalls. - `cost_http_request_v2` and its argument types `CostHttpRequestV2Args` and `HttpOutcallType`. +- Re-exports of `SnapshotVisibility` and `StatusVisibility`, the types of the `CanisterSettings` and `DefiniteCanisterSettings` fields of the same name, and of `RenameCanisterRecord` and `RenameToRecord`, the payload of `ChangeDetails::RenameCanister`. All four were reachable only by depending on `ic-management-canister-types` directly, which left those fields impossible to construct or match on. - Re-exports of the new `ic-management-canister-types` items: `FlexibleHttpRequestArgs`, `FlexibleHttpRequestResult`, `FlexibleHttpRequestErr`, `FlexibleHttpGlobalError`, `FlexibleHttpNodeDetail`, `FlexibleHttpNodeError`, `HttpRequestResourceReport`, `ReplicationCounts` and `ResourceUsage`. ### Removed diff --git a/ic-cdk-management-canister/src/lib.rs b/ic-cdk-management-canister/src/lib.rs index 17a978d81..fe7d18240 100644 --- a/ic-cdk-management-canister/src/lib.rs +++ b/ic-cdk-management-canister/src/lib.rs @@ -28,13 +28,14 @@ pub use ic_management_canister_types::{ NodeMetricsHistoryResult, OnLowWasmMemoryHookStatus, ProvisionalCreateCanisterWithCyclesResult, ProvisionalTopUpCanisterArgs, QueryStats, RawRandResult, ReadCanisterSnapshotDataArgs, ReadCanisterSnapshotDataResult, ReadCanisterSnapshotMetadataArgs, - ReadCanisterSnapshotMetadataResult, ReplicationCounts, ResourceUsage, SchnorrAlgorithm, - SchnorrAux, SchnorrKeyId, SchnorrPublicKeyArgs, SchnorrPublicKeyResult, SignWithEcdsaArgs, - SignWithEcdsaResult, SignWithSchnorrArgs, SignWithSchnorrResult, Snapshot, SnapshotDataKind, - SnapshotDataOffset, SnapshotId, SnapshotMetadataGlobal, SnapshotSource, StartCanisterArgs, - StopCanisterArgs, StoredChunksArgs, StoredChunksResult, SubnetInfoArgs, SubnetInfoResult, - TakeCanisterSnapshotArgs, TakeCanisterSnapshotResult, TransformArgs, TransformContext, - TransformFunc, UpgradeFlags, UploadCanisterSnapshotDataArgs, + ReadCanisterSnapshotMetadataResult, RenameCanisterRecord, RenameToRecord, ReplicationCounts, + ResourceUsage, SchnorrAlgorithm, SchnorrAux, SchnorrKeyId, SchnorrPublicKeyArgs, + SchnorrPublicKeyResult, SignWithEcdsaArgs, SignWithEcdsaResult, SignWithSchnorrArgs, + SignWithSchnorrResult, Snapshot, SnapshotDataKind, SnapshotDataOffset, SnapshotId, + SnapshotMetadataGlobal, SnapshotSource, SnapshotVisibility, StartCanisterArgs, + StatusVisibility, StopCanisterArgs, StoredChunksArgs, StoredChunksResult, SubnetInfoArgs, + SubnetInfoResult, TakeCanisterSnapshotArgs, TakeCanisterSnapshotResult, TransformArgs, + TransformContext, TransformFunc, UpgradeFlags, UploadCanisterSnapshotDataArgs, UploadCanisterSnapshotMetadataArgs, UploadCanisterSnapshotMetadataResult, UploadChunkArgs, UploadChunkResult, VetKDCurve, VetKDDeriveKeyArgs, VetKDDeriveKeyResult, VetKDKeyId, VetKDPublicKeyArgs, VetKDPublicKeyResult, WasmMemoryPersistence, WasmModule, @@ -581,6 +582,11 @@ fn request_bytes( /// the outcall could consume, which yields a reservation the outcall cannot exhaust but which /// holds far more cycles for the duration of the call. /// +/// Narrowing an expectation below what the call actually needs is not rejected up front. The +/// outcall runs with reduced limits, and potentially fails at a later point. A node that +/// exhausts its budget rejects instead of returning the response, possibly after the remote +/// server has already been contacted. +/// /// Use [`FlexibleHttpRequest`] for an outcall whose nodes return their individual responses. /// /// # Examples @@ -670,8 +676,8 @@ impl HttpRequest { /// Sets the maximum size of the response in bytes, up to 2MB. /// - /// Under pricing version `2` this no longer sets the price, but it still bounds the response - /// and it bounds how many cycles are held while the call runs. Setting it as low as the + /// Under pricing version `2` this does not set the price, but it still bounds the response + /// and it affects how many cycles are held while the call runs. Setting it as low as the /// response allows keeps the reservation small. pub fn with_max_response_bytes(mut self, max_response_bytes: u64) -> Self { self.args.max_response_bytes = Some(max_response_bytes); @@ -696,6 +702,9 @@ impl HttpRequest { /// Sets the round-trip time the outcall is expected to take, in milliseconds. /// + /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it + /// below what the call needs. + /// /// Defaults to the 60 second maximum the system allows. pub fn with_expected_roundtrip_time_ms(mut self, ms: u64) -> Self { self.reservation.roundtrip_time_ms = Some(ms); @@ -704,6 +713,9 @@ impl HttpRequest { /// Sets the size the response is expected to have as it arrives from the server. /// + /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it + /// below what the call needs. + /// /// Defaults to `max_response_bytes`, or 2MB if that is unset. pub fn with_expected_raw_response_bytes(mut self, bytes: u64) -> Self { self.reservation.raw_response_bytes = Some(bytes); @@ -712,6 +724,9 @@ impl HttpRequest { /// Sets the size the response is expected to have after the transform function. /// + /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it + /// below what the call needs. + /// /// Defaults to `max_response_bytes` plus the bytes reserved for the Candid encoding, or 2MB /// plus that reserve if `max_response_bytes` is unset. pub fn with_expected_transformed_response_bytes(mut self, bytes: u64) -> Self { @@ -721,8 +736,10 @@ impl HttpRequest { /// Sets the instructions the transform function is expected to use. /// - /// Defaults to the query call instruction limit, which almost no transform approaches, so - /// setting this is usually the single largest reduction in the reservation. + /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it + /// below what the call needs. + /// + /// Defaults to the query call instruction limit. pub fn with_expected_transform_instructions(mut self, instructions: u64) -> Self { self.reservation.transform_instructions = Some(instructions); self @@ -807,6 +824,21 @@ impl HttpRequest { /// rather than one the subnet agreed on, so reconciling them is the canister's job. Flexible /// outcalls are always priced with pricing version `2`. /// +/// Because the cycles attached to a version `2` outcall are also the budget the nodes may spend +/// on it, the amount to attach depends on how much the outcall is expected to consume. Every +/// `with_expected_*` method narrows that estimate; whatever is left unset falls back to the most +/// the outcall could consume, which yields a reservation the outcall cannot exhaust but which +/// holds far more cycles for the duration of the call. +/// +/// Here the budget is split between the `total_requests` nodes rather than across the subnet, and +/// too few cycles surface in one of two ways. A node that exhausts its own share rejects, counting +/// towards [`TooManyRejects`](FlexibleHttpGlobalError::TooManyRejects). Separately, what the +/// committee leaves unspent is pooled to pay for delivering the result, and once that pool no +/// longer covers any result the outcall could still produce, it fails with +/// [`OutOfCycles`](FlexibleHttpGlobalError::OutOfCycles) instead. Delivery is priced by the sizes +/// of the responses, so that verdict can come after the nodes have already made their HTTP +/// requests: the outcall can spend cycles and still deliver no responses. +/// /// # Examples /// /// ```no_run @@ -895,8 +927,8 @@ impl FlexibleHttpRequest { /// Sets the maximum size of any single node's response in bytes, up to 2MB. /// - /// The responses delivered together must also fit a 2MiB total, so this should be no larger - /// than `2MiB / min_responses` unless a transform brings the delivered size below that. + /// Note that at least `min_responses` must fit a 2MiB total, in order for a + /// result to be delivered. pub fn with_max_response_bytes(mut self, max_response_bytes: u64) -> Self { self.args.max_response_bytes = Some(max_response_bytes); self @@ -921,6 +953,9 @@ impl FlexibleHttpRequest { /// Sets the round-trip time the outcall is expected to take, in milliseconds. /// + /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it + /// below what the call needs. + /// /// Defaults to the 60 second maximum the system allows. pub fn with_expected_roundtrip_time_ms(mut self, ms: u64) -> Self { self.reservation.roundtrip_time_ms = Some(ms); @@ -929,6 +964,9 @@ impl FlexibleHttpRequest { /// Sets the size a response is expected to have as it arrives from the server. /// + /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it + /// below what the call needs. + /// /// Defaults to `max_response_bytes`, or 2MB if that is unset. pub fn with_expected_raw_response_bytes(mut self, bytes: u64) -> Self { self.reservation.raw_response_bytes = Some(bytes); @@ -937,6 +975,9 @@ impl FlexibleHttpRequest { /// Sets the size a response is expected to have after the transform function. /// + /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it + /// below what the call needs. + /// /// Defaults to `max_response_bytes` plus the bytes reserved for the Candid encoding, or 2MB /// plus that reserve if `max_response_bytes` is unset. pub fn with_expected_transformed_response_bytes(mut self, bytes: u64) -> Self { @@ -946,6 +987,9 @@ impl FlexibleHttpRequest { /// Sets the instructions the transform function is expected to use. /// + /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it + /// below what the call needs. + /// /// Defaults to the query call instruction limit. pub fn with_expected_transform_instructions(mut self, instructions: u64) -> Self { self.reservation.transform_instructions = Some(instructions); diff --git a/ic-cdk/src/api.rs b/ic-cdk/src/api.rs index 0266403b6..1f1949f73 100644 --- a/ic-cdk/src/api.rs +++ b/ic-cdk/src/api.rs @@ -268,10 +268,6 @@ pub fn subnet_self() -> Principal { } /// Gets the number of nodes on the subnet on which the canister is running. -/// -/// This is useful for computing valid replication bounds for the Management canister method -/// [`flexible_http_request`](https://internetcomputer.org/docs/references/ic-interface-spec#ic-flexible_http_request), -/// whose `total_requests` must not exceed the number of nodes on the subnet. pub fn subnet_self_node_count() -> u32 { ic0::subnet_self_node_count() } diff --git a/ic0/ic0.txt b/ic0/ic0.txt index 0ae4b2633..0a768ccfe 100644 --- a/ic0/ic0.txt +++ b/ic0/ic0.txt @@ -33,7 +33,7 @@ ic0.subnet_self_size : () -> I; // * ic0.subnet_self_copy : (dst : I, offset : I, size : I) -> (); // * - ic0.subnet_self_node_count : () -> i32; // * + ic0.subnet_self_node_count : () -> i32; // * ic0.msg_method_name_size : () -> I; // F ic0.msg_method_name_copy : (dst : I, offset : I, size : I) -> (); // F diff --git a/ic0/manual_safety_comments.txt b/ic0/manual_safety_comments.txt index 3343b8484..a1df8d7ad 100644 --- a/ic0/manual_safety_comments.txt +++ b/ic0/manual_safety_comments.txt @@ -57,7 +57,7 @@ ic0.subnet_self_size : () -> I; Always safe to call ic0.subnet_self_copy : (dst : I, offset : I, size : I) -> (); // * `dst` must be a pointer to a writable sequence of bytes with size `size`. The `offset` parameter does not affect safety. -ic0.subnet_self_node_count : () -> i32; // * +ic0.subnet_self_node_count : () -> i32; // * Always safe to call ic0.msg_method_name_size : () -> I; // F Always safe to call From 7a307729d2864af138e1bec053bed14a9ac7183c Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Mon, 14 Sep 2026 07:54:21 +0000 Subject: [PATCH 3/5] cap --- e2e-tests/src/bin/http_request.rs | 17 ++++ e2e-tests/tests/http_request.rs | 14 +-- ic-cdk-management-canister/src/lib.rs | 123 +++++++++++++++++++++++--- 3 files changed, 136 insertions(+), 18 deletions(-) diff --git a/e2e-tests/src/bin/http_request.rs b/e2e-tests/src/bin/http_request.rs index 4694378cc..94e0b1647 100644 --- a/e2e-tests/src/bin/http_request.rs +++ b/e2e-tests/src/bin/http_request.rs @@ -224,6 +224,23 @@ async fn flexible_too_many_rejects() { ); } +/// With default replication the expected transformed size is capped at what a deliverable +/// result can average, well below the worst case a single response could reach. +/// +/// Default replication means the cap is derived from `subnet_self_node_count`, so this only +/// works on a replica. +#[update] +async fn flexible_default_caps_transformed_bytes() { + let capped = FlexibleHttpRequest::new("https://example.com").get_cost(); + let uncapped = FlexibleHttpRequest::new("https://example.com") + .with_expected_transformed_response_bytes(2_000_000 + 1_024) + .get_cost(); + assert!( + capped < uncapped, + "capped {capped} should be below the uncapped worst case {uncapped}" + ); +} + /// Narrowing the expected resource usage must lower the cycles reservation here too. #[update] async fn flexible_expected_usage_lowers_cost() { diff --git a/e2e-tests/tests/http_request.rs b/e2e-tests/tests/http_request.rs index de96795ab..106122a12 100644 --- a/e2e-tests/tests/http_request.rs +++ b/e2e-tests/tests/http_request.rs @@ -67,14 +67,14 @@ fn test_flexible_http_request() { }, )] }); - // `get_cost` is pure, so this needs no mocked response. - pic.update_call( - canister_id, - Principal::anonymous(), + // `get_cost` is pure, so these need no mocked response. + for method in [ "flexible_expected_usage_lowers_cost", - vec![], - ) - .expect("flexible_expected_usage_lowers_cost failed"); + "flexible_default_caps_transformed_bytes", + ] { + pic.update_call(canister_id, Principal::anonymous(), method, vec![]) + .unwrap_or_else(|e| panic!("{method} failed: {e}")); + } } fn reply() -> CanisterHttpResponse { diff --git a/ic-cdk-management-canister/src/lib.rs b/ic-cdk-management-canister/src/lib.rs index fe7d18240..f4aaf39d8 100644 --- a/ic-cdk-management-canister/src/lib.rs +++ b/ic-cdk-management-canister/src/lib.rs @@ -6,7 +6,7 @@ use ic_cdk::api::{ SignCostError, canister_version, cost_create_canister, cost_sign_with_ecdsa as ic0_cost_sign_with_ecdsa, cost_sign_with_schnorr as ic0_cost_sign_with_schnorr, - cost_vetkd_derive_key as ic0_cost_vetkd_derive_key, + cost_vetkd_derive_key as ic0_cost_vetkd_derive_key, subnet_self_node_count, }; use ic_cdk::call::{Call, CallFailed, CallResult, CandidDecodeFailed}; use serde::{Deserialize, Serialize}; @@ -471,6 +471,9 @@ const MAX_ROUNDTRIP_TIME_MS: u64 = 60_000; const MAX_TRANSFORM_INSTRUCTIONS: u64 = 5_000_000_000; /// Bytes reserved on top of `max_response_bytes` for the Candid encoding of a response. const CANDID_OVERHEAD_RESERVE_BYTES: u64 = 1_024; +/// The block space the system has for the responses of one flexible outcall, which bounds the +/// combined size of the responses it can deliver. +const MAX_FLEXIBLE_RESULT_BYTES: u64 = 2 * 1024 * 1024; /// # HTTP Outcall Type. /// @@ -541,19 +544,47 @@ struct Reservation { impl Reservation { /// Resolves the expected usage against `max_response_bytes`, filling unset fields with the /// maximum the outcall could consume. - fn resolve(&self, max_response_bytes: Option) -> (u64, u64, u64, u64) { + /// + /// `transformed_default_cap` bounds the value an unset `transformed_response_bytes` falls + /// back to. An expectation the caller set explicitly is always used as given. + fn resolve( + &self, + max_response_bytes: Option, + transformed_default_cap: Option, + ) -> (u64, u64, u64, u64) { let cap = max_response_bytes.unwrap_or(MAX_RESPONSE_BYTES_LIMIT); + let transformed = self.transformed_response_bytes.unwrap_or_else(|| { + let worst_case = cap.saturating_add(CANDID_OVERHEAD_RESERVE_BYTES); + transformed_default_cap.map_or(worst_case, |c| worst_case.min(c)) + }); ( self.roundtrip_time_ms.unwrap_or(MAX_ROUNDTRIP_TIME_MS), self.raw_response_bytes.unwrap_or(cap), - self.transformed_response_bytes - .unwrap_or(cap.saturating_add(CANDID_OVERHEAD_RESERVE_BYTES)), + transformed, self.transform_instructions .unwrap_or(MAX_TRANSFORM_INSTRUCTIONS), ) } } +/// The largest a flexible outcall's responses can average and still be deliverable. +/// +/// `None` when no response is ever delivered, or when `min_responses` is zero. +fn flexible_transformed_default_cap(replication: Option<&ReplicationCounts>) -> Option { + let min_responses = match replication { + Some(counts) => { + if counts.max_responses == 0 { + // Fire-and-forget: no response is delivered, so nothing bounds its size. + return None; + } + counts.min_responses + } + // The system's own default when `replication` is unset. + None => 2 * subnet_self_node_count() / 3 + 1, + }; + (min_responses > 0).then(|| MAX_FLEXIBLE_RESULT_BYTES.div_ceil(u64::from(min_responses))) +} + /// Computes the `request_bytes` an outcall is charged for. fn request_bytes( url: &str, @@ -777,7 +808,7 @@ impl HttpRequest { /// Returns the cycles that [`Self::send`] will attach. pub fn get_cost(&self) -> u128 { let (roundtrip, raw, transformed, instructions) = - self.reservation.resolve(self.args.max_response_bytes); + self.reservation.resolve(self.args.max_response_bytes, None); cost_http_request_v2(&CostHttpRequestV2Args { request_bytes: request_bytes( &self.args.url, @@ -944,7 +975,7 @@ impl FlexibleHttpRequest { /// /// Must satisfy `0 <= min_responses <= max_responses <= total_requests` and /// `1 <= total_requests <= N`, where `N` is - /// [`subnet_self_node_count`](ic_cdk::api::subnet_self_node_count). Defaults to + /// [`subnet_self_node_count`]. Defaults to /// `floor(2 / 3 * N) + 1`, `N` and `N`. pub fn with_replication(mut self, replication: ReplicationCounts) -> Self { self.args.replication = Some(replication); @@ -1027,8 +1058,10 @@ impl FlexibleHttpRequest { /// Returns the cycles that [`Self::send`] will attach. pub fn get_cost(&self) -> u128 { - let (roundtrip, raw, transformed, instructions) = - self.reservation.resolve(self.args.max_response_bytes); + let (roundtrip, raw, transformed, instructions) = self.reservation.resolve( + self.args.max_response_bytes, + flexible_transformed_default_cap(self.args.replication.as_ref()), + ); cost_http_request_v2(&CostHttpRequestV2Args { request_bytes: request_bytes( &self.args.url, @@ -1630,7 +1663,8 @@ mod tests { /// An unset expectation must fall back to the maximum the outcall could consume. #[test] fn reservation_defaults_to_the_maxima() { - let (roundtrip, raw, transformed, instructions) = Reservation::default().resolve(None); + let (roundtrip, raw, transformed, instructions) = + Reservation::default().resolve(None, None); assert_eq!(roundtrip, MAX_ROUNDTRIP_TIME_MS); assert_eq!(raw, MAX_RESPONSE_BYTES_LIMIT); assert_eq!( @@ -1643,7 +1677,7 @@ mod tests { /// `max_response_bytes` bounds both response sizes when they are not given explicitly. #[test] fn reservation_defaults_follow_max_response_bytes() { - let (_, raw, transformed, _) = Reservation::default().resolve(Some(4_000)); + let (_, raw, transformed, _) = Reservation::default().resolve(Some(4_000), None); assert_eq!(raw, 4_000); assert_eq!(transformed, 4_000 + CANDID_OVERHEAD_RESERVE_BYTES); } @@ -1658,7 +1692,7 @@ mod tests { transform_instructions: Some(1_000_000), }; assert_eq!( - reservation.resolve(Some(4_000)), + reservation.resolve(Some(4_000), None), (300, 1_000, 900, 1_000_000) ); } @@ -1711,6 +1745,73 @@ mod tests { ); } + /// The flexible default for `transformed_response_bytes` is the block budget split across + /// the responses that have to be delivered together. + #[test] + fn flexible_default_cap_divides_the_block_budget() { + let counts = ReplicationCounts { + min_responses: 9, + max_responses: 13, + total_requests: 13, + }; + let cap = flexible_transformed_default_cap(Some(&counts)).unwrap(); + assert_eq!(cap, MAX_FLEXIBLE_RESULT_BYTES.div_ceil(9)); + // The rounding only matters when every node's response has to be delivered, since the + // reserve prices `total_requests` of them. Rounding down is 8 bytes short here. + let deterministic = ReplicationCounts { + min_responses: 9, + max_responses: 9, + total_requests: 9, + }; + let cap = flexible_transformed_default_cap(Some(&deterministic)).unwrap(); + assert!(9 * cap >= MAX_FLEXIBLE_RESULT_BYTES); + // Rounding down instead would have left the reserve 8 bytes short of a full block. + assert_eq!(9 * (cap - 1), MAX_FLEXIBLE_RESULT_BYTES - 8); + } + + /// Nothing bounds the response size when no response is delivered, or when no minimum is + /// required, so those must not divide by `min_responses`. + #[test] + fn flexible_default_cap_is_absent_when_nothing_must_be_delivered() { + // Fire-and-forget. + assert_eq!( + flexible_transformed_default_cap(Some(&ReplicationCounts { + min_responses: 0, + max_responses: 0, + total_requests: 3, + })), + None + ); + // No minimum, but responses may still arrive. + assert_eq!( + flexible_transformed_default_cap(Some(&ReplicationCounts { + min_responses: 0, + max_responses: 3, + total_requests: 3, + })), + None + ); + } + + /// The cap applies to the fallback only, and never raises it. + #[test] + fn flexible_cap_bounds_the_default_but_not_an_explicit_expectation() { + let cap = Some(233_016); + // Unset: the worst case is cut down to the cap. + let (_, _, transformed, _) = Reservation::default().resolve(None, cap); + assert_eq!(transformed, 233_016); + // A small `max_response_bytes` already sits below the cap, so nothing changes. + let (_, _, transformed, _) = Reservation::default().resolve(Some(4_000), cap); + assert_eq!(transformed, 4_000 + CANDID_OVERHEAD_RESERVE_BYTES); + // An explicit expectation is used as given, above the cap or below it. + let reservation = Reservation { + transformed_response_bytes: Some(1_000_000), + ..Default::default() + }; + let (_, _, transformed, _) = reservation.resolve(None, cap); + assert_eq!(transformed, 1_000_000); + } + /// `request_bytes` counts the URL, the headers, the body and the transform. #[test] fn request_bytes_counts_every_variable_part() { From ade455697209ec057fff326d8855fdf74a57871e Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Mon, 14 Sep 2026 09:12:28 +0000 Subject: [PATCH 4/5] don't reserve for absent transform --- e2e-tests/src/bin/http_request.rs | 19 ++++++++++ e2e-tests/tests/http_request.rs | 11 ++---- ic-cdk-management-canister/src/lib.rs | 54 +++++++++++++++++++++------ 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/e2e-tests/src/bin/http_request.rs b/e2e-tests/src/bin/http_request.rs index 94e0b1647..295363048 100644 --- a/e2e-tests/src/bin/http_request.rs +++ b/e2e-tests/src/bin/http_request.rs @@ -113,6 +113,25 @@ async fn non_replicated() { .unwrap(); } +/// A request that sets no transform reserves nothing for running one. +#[update] +async fn no_transform_lowers_cost() { + let without = HttpRequest::new("https://example.com") + .with_max_response_bytes(4_000) + .get_cost(); + let with = HttpRequest::new("https://example.com") + .with_max_response_bytes(4_000) + .with_transform(transform_context_from_query( + "transform".to_string(), + vec![42], + )) + .get_cost(); + assert!( + without < with, + "without a transform {without} should be below {with}" + ); +} + /// Narrowing the expected resource usage must lower the cycles reservation. #[update] async fn expected_usage_lowers_cost() { diff --git a/e2e-tests/tests/http_request.rs b/e2e-tests/tests/http_request.rs index 106122a12..5dc69b154 100644 --- a/e2e-tests/tests/http_request.rs +++ b/e2e-tests/tests/http_request.rs @@ -25,13 +25,10 @@ fn test_http_request() { test_one_http_request(&pic, canister_id, "get_with_transform_closure"); test_one_http_request(&pic, canister_id, "non_replicated"); // `get_cost` is pure, so this needs no mocked response. - pic.update_call( - canister_id, - Principal::anonymous(), - "expected_usage_lowers_cost", - vec![], - ) - .expect("expected_usage_lowers_cost failed"); + for method in ["expected_usage_lowers_cost", "no_transform_lowers_cost"] { + pic.update_call(canister_id, Principal::anonymous(), method, vec![]) + .unwrap_or_else(|e| panic!("{method} failed: {e}")); + } } #[test] diff --git a/ic-cdk-management-canister/src/lib.rs b/ic-cdk-management-canister/src/lib.rs index f4aaf39d8..2fb55dcbe 100644 --- a/ic-cdk-management-canister/src/lib.rs +++ b/ic-cdk-management-canister/src/lib.rs @@ -547,10 +547,15 @@ impl Reservation { /// /// `transformed_default_cap` bounds the value an unset `transformed_response_bytes` falls /// back to. An expectation the caller set explicitly is always used as given. + /// + /// `has_transform` says whether the request sets a `transform` function. Without one the + /// system never runs a transform, so an unset `transform_instructions` falls back to zero + /// rather than to the query call instruction limit. fn resolve( &self, max_response_bytes: Option, transformed_default_cap: Option, + has_transform: bool, ) -> (u64, u64, u64, u64) { let cap = max_response_bytes.unwrap_or(MAX_RESPONSE_BYTES_LIMIT); let transformed = self.transformed_response_bytes.unwrap_or_else(|| { @@ -561,8 +566,11 @@ impl Reservation { self.roundtrip_time_ms.unwrap_or(MAX_ROUNDTRIP_TIME_MS), self.raw_response_bytes.unwrap_or(cap), transformed, - self.transform_instructions - .unwrap_or(MAX_TRANSFORM_INSTRUCTIONS), + self.transform_instructions.unwrap_or(if has_transform { + MAX_TRANSFORM_INSTRUCTIONS + } else { + 0 + }), ) } } @@ -770,7 +778,8 @@ impl HttpRequest { /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it /// below what the call needs. /// - /// Defaults to the query call instruction limit. + /// Defaults to the query call instruction limit when a transform is set, and to zero when + /// none is, since the system then never runs one. pub fn with_expected_transform_instructions(mut self, instructions: u64) -> Self { self.reservation.transform_instructions = Some(instructions); self @@ -807,8 +816,11 @@ impl HttpRequest { /// Returns the cycles that [`Self::send`] will attach. pub fn get_cost(&self) -> u128 { - let (roundtrip, raw, transformed, instructions) = - self.reservation.resolve(self.args.max_response_bytes, None); + let (roundtrip, raw, transformed, instructions) = self.reservation.resolve( + self.args.max_response_bytes, + None, + self.args.transform.is_some(), + ); cost_http_request_v2(&CostHttpRequestV2Args { request_bytes: request_bytes( &self.args.url, @@ -1021,7 +1033,8 @@ impl FlexibleHttpRequest { /// A lower expectation reserves fewer cycles; see [`Self`] for the risk of setting it /// below what the call needs. /// - /// Defaults to the query call instruction limit. + /// Defaults to the query call instruction limit when a transform is set, and to zero when + /// none is, since the system then never runs one. pub fn with_expected_transform_instructions(mut self, instructions: u64) -> Self { self.reservation.transform_instructions = Some(instructions); self @@ -1061,6 +1074,7 @@ impl FlexibleHttpRequest { let (roundtrip, raw, transformed, instructions) = self.reservation.resolve( self.args.max_response_bytes, flexible_transformed_default_cap(self.args.replication.as_ref()), + self.args.transform.is_some(), ); cost_http_request_v2(&CostHttpRequestV2Args { request_bytes: request_bytes( @@ -1664,7 +1678,7 @@ mod tests { #[test] fn reservation_defaults_to_the_maxima() { let (roundtrip, raw, transformed, instructions) = - Reservation::default().resolve(None, None); + Reservation::default().resolve(None, None, true); assert_eq!(roundtrip, MAX_ROUNDTRIP_TIME_MS); assert_eq!(raw, MAX_RESPONSE_BYTES_LIMIT); assert_eq!( @@ -1677,7 +1691,7 @@ mod tests { /// `max_response_bytes` bounds both response sizes when they are not given explicitly. #[test] fn reservation_defaults_follow_max_response_bytes() { - let (_, raw, transformed, _) = Reservation::default().resolve(Some(4_000), None); + let (_, raw, transformed, _) = Reservation::default().resolve(Some(4_000), None, true); assert_eq!(raw, 4_000); assert_eq!(transformed, 4_000 + CANDID_OVERHEAD_RESERVE_BYTES); } @@ -1692,7 +1706,7 @@ mod tests { transform_instructions: Some(1_000_000), }; assert_eq!( - reservation.resolve(Some(4_000), None), + reservation.resolve(Some(4_000), None, true), (300, 1_000, 900, 1_000_000) ); } @@ -1798,20 +1812,36 @@ mod tests { fn flexible_cap_bounds_the_default_but_not_an_explicit_expectation() { let cap = Some(233_016); // Unset: the worst case is cut down to the cap. - let (_, _, transformed, _) = Reservation::default().resolve(None, cap); + let (_, _, transformed, _) = Reservation::default().resolve(None, cap, true); assert_eq!(transformed, 233_016); // A small `max_response_bytes` already sits below the cap, so nothing changes. - let (_, _, transformed, _) = Reservation::default().resolve(Some(4_000), cap); + let (_, _, transformed, _) = Reservation::default().resolve(Some(4_000), cap, true); assert_eq!(transformed, 4_000 + CANDID_OVERHEAD_RESERVE_BYTES); // An explicit expectation is used as given, above the cap or below it. let reservation = Reservation { transformed_response_bytes: Some(1_000_000), ..Default::default() }; - let (_, _, transformed, _) = reservation.resolve(None, cap); + let (_, _, transformed, _) = reservation.resolve(None, cap, true); assert_eq!(transformed, 1_000_000); } + /// Without a transform the system never runs one, so nothing is reserved for it. + #[test] + fn no_transform_reserves_no_instructions() { + let (.., instructions) = Reservation::default().resolve(None, None, false); + assert_eq!(instructions, 0); + let (.., instructions) = Reservation::default().resolve(None, None, true); + assert_eq!(instructions, MAX_TRANSFORM_INSTRUCTIONS); + // An explicit expectation still wins, transform or not. + let reservation = Reservation { + transform_instructions: Some(7), + ..Default::default() + }; + let (.., instructions) = reservation.resolve(None, None, false); + assert_eq!(instructions, 7); + } + /// `request_bytes` counts the URL, the headers, the body and the transform. #[test] fn request_bytes_counts_every_variable_part() { From fc0b3c492b2562b8269c1d4c91f7b75ce1d21166 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Tue, 15 Sep 2026 11:57:51 +0000 Subject: [PATCH 5/5] chore: address review feedback on flexible HTTPS outcalls - Document the cap that the flexible builder applies to the default expected transformed response size, which the previous doc contradicted. - Price every `outcall_type` variant through `cost_http_request_v2` on a replica, including `fully_replicated`, whose `reserved` payload was the one encoding no test exercised. - Cover a flexible outcall over a committee of three nodes with differing responses, so that each node transforming its own response via a closure is pinned down rather than only the single node case. - Mark the breaking CHANGELOG entries and note that pricing version 2 requires a replica providing `ic0.cost_http_request_v2`. Co-Authored-By: Claude Opus 5 (1M context) --- e2e-tests/src/bin/api.rs | 40 ++++++++++++++++++------- e2e-tests/src/bin/http_request.rs | 34 +++++++++++++++++++++ e2e-tests/tests/http_request.rs | 18 ++++++++++- ic-cdk-management-canister/CHANGELOG.md | 6 ++-- ic-cdk-management-canister/src/lib.rs | 7 ++++- 5 files changed, 89 insertions(+), 16 deletions(-) diff --git a/e2e-tests/src/bin/api.rs b/e2e-tests/src/bin/api.rs index c209981eb..f89a14544 100644 --- a/e2e-tests/src/bin/api.rs +++ b/e2e-tests/src/bin/api.rs @@ -250,18 +250,36 @@ fn call_cost_http_request() { #[unsafe(export_name = "canister_query call_cost_http_request_v2")] fn call_cost_http_request_v2() { - // The Candid encoding of the parameter record, with `outcall_type` left unset so a fully - // replicated outcall is priced. - let args = ic_cdk_management_canister::CostHttpRequestV2Args { - request_bytes: 100, - http_roundtrip_time_ms: 1_000, - raw_response_bytes: 1_000, - transformed_response_bytes: 1_000, - transform_instructions: 1_000_000, - outcall_type: None, + use ic_cdk_management_canister::{ + CostHttpRequestV2Args, HttpOutcallType, ReplicationCounts, cost_http_request_v2, }; - let res = ic_cdk_management_canister::cost_http_request_v2(&args); - assert!(res > 0); + // The System API traps if the parameter record is not encoded the way it expects, and the + // `fully_replicated` and `non_replicated` payloads in particular have to be encoded as + // `null`. So every `outcall_type` the CDK can produce is priced here, including the absent + // one, which prices a fully replicated outcall. + let outcall_types = [ + None, + Some(HttpOutcallType::FullyReplicated(candid::Reserved)), + Some(HttpOutcallType::NonReplicated(candid::Reserved)), + Some(HttpOutcallType::Flexible(None)), + Some(HttpOutcallType::Flexible(Some(ReplicationCounts { + min_responses: 2, + max_responses: 3, + total_requests: 3, + }))), + ]; + for outcall_type in outcall_types { + let args = CostHttpRequestV2Args { + request_bytes: 100, + http_roundtrip_time_ms: 1_000, + raw_response_bytes: 1_000, + transformed_response_bytes: 1_000, + transform_instructions: 1_000_000, + outcall_type, + }; + let res = cost_http_request_v2(&args); + assert!(res > 0); + } msg_reply(vec![]); } diff --git a/e2e-tests/src/bin/http_request.rs b/e2e-tests/src/bin/http_request.rs index 295363048..2489cb626 100644 --- a/e2e-tests/src/bin/http_request.rs +++ b/e2e-tests/src/bin/http_request.rs @@ -222,6 +222,40 @@ async fn flexible_with_transform_closure() { assert_eq!(responses[0].body, vec![42, 42]); } +/// Every node of a larger committee runs the closure on its own response. +/// +/// The responses differ per node, so this pins down that each one is transformed individually +/// rather than one of them standing in for the others. +#[update] +async fn flexible_multi_node_transform_closure() { + let res = FlexibleHttpRequest::new("https://example.com") + .with_replication(ReplicationCounts { + min_responses: 3, + max_responses: 3, + total_requests: 3, + }) + .with_transform_closure(|args: HttpRequestResult| { + let mut body = args.body; + body.push(42); + HttpRequestResult { + status: args.status, + headers: args.headers, + body, + } + }) + .send() + .await + .unwrap(); + let FlexibleHttpRequestResult::Ok(responses) = res else { + panic!("expected responses, got {res:?}"); + }; + // The order of the responses is not specified, so compare them sorted. + let mut bodies: Vec> = responses.into_iter().map(|r| r.body).collect(); + bodies.sort(); + // Each node's own response body, with the 42 that its own run of the closure appended. + assert_eq!(bodies, vec![vec![1, 42], vec![2, 42], vec![3, 42]]); +} + /// A committee that only rejects reports `too_many_rejects`, and does so as a reply. #[update] async fn flexible_too_many_rejects() { diff --git a/e2e-tests/tests/http_request.rs b/e2e-tests/tests/http_request.rs index 5dc69b154..26f5663f6 100644 --- a/e2e-tests/tests/http_request.rs +++ b/e2e-tests/tests/http_request.rs @@ -56,6 +56,18 @@ fn test_flexible_http_request() { test_one_flexible_http_request(&pic, canister_id, "flexible_with_transform_closure", |_| { vec![reply()] }); + // A response per node, each one different, so that the canister can tell whether every node + // ran the transform closure on its own. + test_one_flexible_http_request( + &pic, + canister_id, + "flexible_multi_node_transform_closure", + |_| { + (1..=3) + .map(|body| reply_with_body(vec![body])) + .collect::>() + }, + ); test_one_flexible_http_request(&pic, canister_id, "flexible_too_many_rejects", |_| { vec![CanisterHttpResponse::CanisterHttpReject( CanisterHttpReject { @@ -75,13 +87,17 @@ fn test_flexible_http_request() { } fn reply() -> CanisterHttpResponse { + reply_with_body(vec![42]) +} + +fn reply_with_body(body: Vec) -> CanisterHttpResponse { CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply { status: 200, headers: vec![CanisterHttpHeader { name: "response_header_name".to_string(), value: "response_header_value".to_string(), }], - body: vec![42], + body, }) } diff --git a/ic-cdk-management-canister/CHANGELOG.md b/ic-cdk-management-canister/CHANGELOG.md index 4551c495f..a639ef0a5 100644 --- a/ic-cdk-management-canister/CHANGELOG.md +++ b/ic-cdk-management-canister/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `HttpRequest`, a builder for `http_request`. It always selects pricing version `2` ("pay-as-you-go"), which charges for the resources the outcall consumes rather than for `max_response_bytes`. +- `HttpRequest`, a builder for `http_request`. It always selects pricing version `2` ("pay-as-you-go"), which charges for the resources the outcall consumes rather than for `max_response_bytes`. Every mainnet subnet supports that pricing version, but since both builders price the call through the `ic0.cost_http_request_v2` System API, a canister built with them requires a replica that provides it. - `FlexibleHttpRequest`, a builder for the new `flexible_http_request` method, in which a committee of nodes return their individual HTTP responses instead of the subnet reaching consensus on one. - `with_expected_roundtrip_time_ms`, `with_expected_raw_response_bytes`, `with_expected_transformed_response_bytes` and `with_expected_transform_instructions` on both builders. Under pricing version `2` the attached cycles are also the budget each node may spend, so these narrow the reservation from "the most the outcall could consume" to what the caller expects. Anything left unset falls back to the maximum, which yields a reservation the outcall cannot exhaust but which holds far more cycles for the duration of the call. - `with_transform_closure` on both builders, replacing the free function `http_request_with_closure` and extending closure transforms to flexible outcalls. @@ -18,13 +18,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -- The free functions `http_request`, `cost_http_request` and `http_request_with_closure`. Use `HttpRequest` instead, which prices the outcall with version `2`. Migrating deliberately rather than switching the pricing version underneath an unchanged call is the reason this is a breaking change rather than a silent one. +- [BREAKING] The free functions `http_request`, `cost_http_request` and `http_request_with_closure`. Use `HttpRequest` instead, which prices the outcall with version `2`. Migrating deliberately rather than switching the pricing version underneath an unchanged call is the reason this is a breaking change rather than a silent one. - `HttpRequest::from_args` accepts an existing `HttpRequestArgs`, so an existing call site can be migrated without rewriting how it builds its arguments. - `ic_cdk::api::cost_http_request` still exposes version `1` pricing for callers that need it. ### Changed -- `ic-management-canister-types` bumped from `0.7.1` to `0.10`, which adds the `pricing_version` field to `HttpRequestArgs`, the `flexible_http_request` types, and three fields to `CanisterSettings`. +- [BREAKING] `ic-management-canister-types` bumped from `0.7.1` to `0.10`, which adds the `pricing_version` field to `HttpRequestArgs`, the `flexible_http_request` types, and three fields to `CanisterSettings`. The new fields break code that constructs those re-exported types with exhaustive struct literals. ## [0.1.1] - 2026-03-10 diff --git a/ic-cdk-management-canister/src/lib.rs b/ic-cdk-management-canister/src/lib.rs index 2fb55dcbe..985007d81 100644 --- a/ic-cdk-management-canister/src/lib.rs +++ b/ic-cdk-management-canister/src/lib.rs @@ -1022,7 +1022,12 @@ impl FlexibleHttpRequest { /// below what the call needs. /// /// Defaults to `max_response_bytes` plus the bytes reserved for the Candid encoding, or 2MB - /// plus that reserve if `max_response_bytes` is unset. + /// plus that reserve if `max_response_bytes` is unset, and is then capped at the largest a + /// response can average and still leave a deliverable result, that is at the 2MiB total + /// result limit divided by `min_responses`. With the default replication counts on a + /// 13 node subnet that cap is roughly 233KB. The cap applies to this default only: an + /// expectation set here is used as given. Nothing is capped when no response has to be + /// delivered, that is when `min_responses` is zero. pub fn with_expected_transformed_response_bytes(mut self, bytes: u64) -> Self { self.reservation.transformed_response_bytes = Some(bytes); self