关于 https-outcalls
面向 Internet Computer Motoko 和 Rust canister 开发者的 HTTPS outcalls 指南,说明如何从链上代码请求外部 Web 服务。它按语言和依赖版本整理调用模式、响应转换、计费及常见失败原因。
主要功能
- 01调用外部 HTTPS 服务
- 02复制、非复制与 flexible 模式说明
- 03响应转换与共识注意事项
- 04按依赖版本区分调用方式与计费
- 05请求限制和错误处理指导
适用场景
- 01从 canister 获取公开 Web API 数据
- 02为复制请求规范化响应以达成共识
- 03为非幂等 POST 请求选择调用模式
- 04诊断 outcall 的大小限制或超时失败
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dfinity/ icskills --skill https-outcalls安装后在 Claude Code 中输入/https-outcalls确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/flexible-outcalls.mdjson-responses.mdpricing-version-2.md详细介绍
HTTPS 外部调用
这是什么
HTTPS 外部调用允许 canister 直接从链上代码向外部 Web 服务发送 HTTP 请求。在默认的复制模式下,子网上的每个节点都会发送相同的请求,并且必须就响应达成一致。转换函数会剔除非确定性字段(时间戳、请求 ID、排序),以便每个副本看到完全相同的响应并达成共识。
另外两种模式可以跳过这一一致性步骤:非复制(is_replicated = false,由一个节点发送请求)和灵活(flexible_http_request,由一个节点委员会分别返回各自的响应)。请参阅外部调用模式。
支持矩阵
| Rust | Motoko | |
|---|---|---|
| 软件包 | ic-cdk 0.20.3+,搭配 ic-cdk-management-canister 0.2+ | ic 4.x(mops),需要 core 2.5.0+ 和 moc 1.4.0+ |
| 入口点 | HttpRequest::new(url) 构建器,.send() | Call.httpRequest(args) |
| 定价版本 1(旧版) | 只能通过固定使用 ic-cdk-management-canister 0.1,或直接调用 aaaaa-aa 并使用 ic_cdk::api::cost_http_request | 支持,这是 Call.httpRequest 使用的版本 |
| 定价版本 2(按需付费) | 支持,构建器始终会选择此版本 | —(不可用,需要尚未发布的 moc) |
flexible_http_request | 支持,FlexibleHttpRequest::new(url) | —(不可用,原因相同) |
subnet_self_node_count | ic_cdk::api::subnet_self_node_count() | —(不可用,原因相同) |
编写代码前请先阅读本节;具体支持情况取决于项目所用的语言和依赖版本。目前 Motoko 代码一律使用版本 1。已发布的 ic 4.x HttpRequestArgs 没有 pricing_version 字段,Candid 会省略不存在的可选字段,副本会将其读取为版本 1——这正是 Call.httpRequest 预付费用的版本。无需采取变通措施;但目前不要指望 Motoko 能享受版本 2 的经济机制。
对于 Rust,正确的代码取决于项目属于以下三种情况中的哪一种。ic-cdk 0.19 提供 ic_cdk::management_canister::http_request(&args)(版本 1;该模块已在 0.20 中移除)。ic-cdk 0.20 搭配 ic-cdk-management-canister 0.1 时,提供 ic_cdk_management_canister::http_request(&args)(版本 1)。ic-cdk 0.20.3+ 搭配 ic-cdk-management-canister 0.2 时,提供构建器,并且仅支持版本 2:0.2.0 移除了自由函数 http_request,而构建器将 pricing_version: Some(2) 硬编码且不提供关闭选项,因此升级这个 crate 本身就是迁移。Rust 还需要 serde_json 来解析 JSON。
应该编写哪个版本的代码。 版本 1 已弃用,版本 2 才是发展方向,因此新的 Rust 代码应使用 0.2 构建器。但若项目要迁移只是某项无关任务的附带影响,不要因此提高项目锁定的版本:0.2 属于破坏性变更(会删除自由函数 http_request,而 HttpRequestArgs 会新增必填字段),是否升级应由调用方决定。请根据项目当前使用的版本编写正确代码,并告知对方可以升级。
Canister ID
HTTPS 外部调用使用 IC 管理 canister:
| 名称 | Canister ID | 用途 |
|---|---|---|
| 管理 canister | aaaaa-aa | http_request 和 flexible_http_request 管理调用的目标 |
无需额外部署任何内容。每个子网都内置了管理 canister。
会导致构建失败的错误
-
忘记提供转换函数。 在复制模式下,如果没有转换函数,各副本收到的原始 HTTP 响应通常会有所不同(例如,标头不同、JSON 字段顺序不同、时间戳不同)。共识会失败,调用也会被拒绝。此模式下务必提供转换函数。在非复制和灵活模式下,转换函数对于达成共识是可选的,因为每个节点自己的响应都会被传递。是否在这些模式下设置转换函数,需要权衡。不设置转换函数就不会为其预留费用(该指令项默认为 0,而不是查询上限),这是成本最低的配置。添加转换函数后,该指令项默认值会变为完整的查询上限,因此你还必须声明
with_expected_transform_instructions。这样做能省回传递费用;该费用按转换后的大小以字节计费:剔除的每个字节都能降低费用,因此关键在于可剔除内容的多少(臃肿的标头值得剔除,而响应主体本身已占大部分内容时则不然)。预留会将该指令项的下限设为约 1 KB,因此对于较小的响应,get_cost()会低估节省的费用。此外,还有两个与周期无关的原因可能让你无论如何都要设置转换函数:在灵活模式下,合计 2 MiB 的预算是在转换后计算的,因此响应越小,被丢弃的响应就越少;如果每个节点的细微差异导致所有响应都不相同,就无法进行协调。 -
未为调用附加 cycles。 在普通的 Application 子网中,HTTPS 外部调用并非免费——调用方 canister 必须附加足够的 cycles 来支付费用;附加 0 会导致调用失败。Motoko 和 Rust 都提供了可自动计算并附加所需 cycles 的封装:在 Motoko 中,使用
icmops 包中的await Call.httpRequest(args)(import Call "mo:ic/Call");在 Rust 中,使用ic-cdk-management-canister0.2 中HttpRequest构建器的.send()。使用哪个定价版本取决于封装:Motoko 的Call.httpRequest通过ic0.cost_http_request(request_size, max_response_bytes)使用定价版本 1,而 Rust 的HttpRequest::send()通过ic0.cost_http_request_v2使用定价版本 2。两者都能感知成本计划,因此同一封装在任何类型的子网上都会附加正确的数量。在版本 2 下,调用前的检查仅针对基础费用,因此附加的 cycles 不足时,调用时不会被拒绝:调用会在更严格的每节点限制下运行,并可能在中途失败,而此时远程服务已经收到请求。请参阅references/pricing-version-2.md。在云引擎(CloudEngine子网)上,该数量按设计始终为 0;不要在此处附加硬编码的非零费用来“修复”原本正常的外部调用——请参阅cloud-engine-canisters技能。 -
使用 HTTP 而不是 HTTPS。 IC 仅支持 HTTPS 外部调用。纯 HTTP URL 会被拒绝。目标服务必须具有有效的 TLS 证书。
-
根据预期的响应主体设置
max_response_bytes——上限不只针对主体。 规范将 HTTP 请求或响应的大小定义为表示 HTTP 标头名称和值以及 HTTP 主体的字节总数。响应标头也计入max_response_bytes。在收到主体的第一个字节之前,实际 API 通常会先发送 1–2 KB 的响应标头(唯一的请求 ID、Date、速率限制相关标头、CDN 标头);对于较严格的上限,这会占用预算的很大一部分。应根据从服务端接收的标头 + 主体设置上限,并留出余量。此问题会导致彻底失败:调用每次都会失败,而不是返回截断后的响应。最大值为 2MB =2_000_000字节(十进制,不是 2^21);同样按照标头加主体的定义,你发送的请求上限也是2_000_000字节。 -
期望转换函数能将超出上限的响应缩小到上限以内。 上限会检查两次:一次是在原始响应从服务端到达时(先检查标头,再根据剩余空间检查正文),另一次是在转换函数的 Candid 编码输出上,其中包含序列化开销。在转换函数中剥离标头,无法挽救已经超出上限的原始响应,因为第一次检查会在转换函数运行之前失败。这么做可以让转换函数自身的输出不超出上限——如果转换函数原本会将标头也回传,导致编码后的输出超限,那么剥离标头就值得一试。两次检查使用的都是同一个
max_response_bytes值,这就是为什么刚好满足上限的原始响应,在转换后仍可能失败:Candid 开销会叠加在原始响应之上。因此,max_response_bytes应按原始响应的大小设置,并确保转换函数的输出较小;切勿抱着「之后剥离标头就能让超限响应符合要求」的想法设置过于严格的上限。 -
忽略标头限制。 无论
max_response_bytes如何设置,规范都将 HTTP 请求和响应限制为最多 64 个标头,每个标头名称或值最多 8 KiB,所有标头名称和值合计最多 48 KiB。URL 长度不得超过 8192 字节。在请求端,当副本解码你的参数时会执行这些限制,因此超限请求不会离开子网,而是以InvalidManagementPayload失败——例如Deserialize error: The number of elements exceeds maximum allowed 64——而不是返回任何类似 HTTP 的错误。如果你没有发送user-agent标头,IC 会添加user-agent: ic/1.0,且该添加的标头不计入这些限制。 -
省略
max_response_bytes。 在版本 1 下,如果不设置此值,系统会采用最大值(2MB)并据此收取 cycles——在 13 节点子网上约为 20.85 billion cycles。在版本 2 下,该值不再决定价格,但仍会作为raw_response_bytes预期值的默认值,因此省略此值会按 2MB 预留:34.0 billion cycles,而设置 4,000 字节上限时只需 5.3 billion cycles。无论哪种情况,都应始终将其设置为符合预期响应大小的合理上限(关于哪些内容计入该值,请参阅陷阱 4)。 -
未经谨慎处理就发送非幂等 POST 请求。 由于多个副本会发送相同的请求,非幂等的 POST 端点(例如「创建订单」)会被调用 N 次(每个副本一次,在 13 节点子网上通常为 13 次)。请使用幂等键,或设计端点以处理重复请求;也可以设置
is_replicated = ?false(Rust:.non_replicated()),由单个节点发送请求,从而完全消除速率限制带来的压力——代价是你必须信任该节点不会窥探或修改响应。 -
未处理外呼失败。 外部服务可能宕机、响应缓慢或返回错误。务必处理错误情况。这里有两种不同的超时,都不会触发陷阱——两者都会以拒绝形式返回(在 Motoko 中,
await会抛出可捕获的Error;在 Rust 中,包装器会返回Err):- 远程服务在 30 秒内没有响应:
SysFatal,消息为Timeout expired。 - 子网在 60 秒内没有生成响应:
SysTransient,消息为Canister http request timed out。通常只有这种情况可以重试;例外是资金不足且缺少某个节点报告的版本 2 调用(参阅references/pricing-version-2.md)。
- 远程服务在 30 秒内没有响应:
-
调用 localhost 或私有 IP。 HTTPS 外呼只能访问公共互联网端点。localhost、10.x.x.x、192.168.x.x 和其他私有网段均会被阻止。
-
忘记设置
Host标头。 有些 API 端点要求显式设置Host标头。IC 不会根据 URL 自动设置该标头。 -
不设置版本 2 的预期值。 未声明的任何项目都会按其最大值预留:60 秒往返时间,以及运行到完整查询指令上限的转换。对于 4,000 字节的上限,这意味着占用约 53 亿个周期,其中几乎全部是转换预留;而声明
with_expected_transform_instructions和with_expected_roundtrip_time_ms后,约为 1.12 亿个周期。请声明这两项。raw_response_bytes保持不变,因为它由服务端决定;如果字节数相关的费用占主导,则调低max_response_bytes。当转换会限制自身输出时,请声明with_expected_transformed_response_bytes;这是唯一一种无法通过调整上限来解决的情况。除非较小的预算确实会提前终止调用,否则这一切都不会影响费用。references/pricing-version-2.md列出了具体数值、逐项资源说明以及故障模式。 -
指望通过
ic-cdk-management-canister0.2 使用版本 1。 这行不通。该 crate 只提供 builder 这一种途径,并将pricing_version: Some(2)硬编码其中,也没有with_pricing_version。HttpRequest::from_args看似是个绕行办法,但其实不是:它是文档中迁移现有调用点的方式,而且会静默地将pricing_version覆盖为 2,因此,将有意设为 1 的参数传入后,版本就会改变。若要继续使用版本 1,请固定使用ic-cdk-management-canister0.1,或直接调用aaaaa-aa,并使用仍存在于 0.20.3 中的ic_cdk::api::cost_http_request计算费用。 -
手动设置
pricing_version,却用错误的费用函数为其提供资金。 字段与附加的费用必须一致。若设置pricing_version = 2,却附加cost_http_request(版本 1)的金额,则调用会被接受,因为前置检查只验证基础费用;随后调用会获得低于版本 2 预期的每节点预算,可能在中途失败。反过来,用cost_http_request_v2的金额为版本 1 调用提供资金,则会被直接拒绝,并显示http_request request sent with <X> cycles, but <Y> cycles are required.,因为版本 1 要求预先提供完整的max_response_bytes费用。交由封装层同时设置两项:Rust 的HttpRequest::send()会将版本 2 与cost_http_request_v2配对,Motoko 的Call.httpRequest会将版本 1 与cost_http_request配对。另请注意,副本会将无法识别的版本过滤为版本 1,且不会报错,因此错误值会导致资金不匹配,而不是验证错误。 -
为灵活的外部调用硬编码
total_requests。total_requests不得超过子网大小,而子网大小因子网而异(主网上为 13 或 34)。请动态计算:// 错误:即使只需要五个节点也不行,因为 5 可能超过子网大小。 let replication = ReplicationCounts { total_requests: 5, min_responses: 3, max_responses: 5 }; // 正确:请求五个,但最多不超过子网拥有的节点数。 let total_requests = subnet_self_node_count().min(5); let min_responses = total_requests / 2 + 1;在成功分支中,也要处理
min_responses到max_responses之间的任何数量——少于max_responses也是正常成功,并非降级;还要分别检查每个响应的status,因为各节点之间不必达成一致。请参阅references/flexible-outcalls.md。
Outcall 模式
通过 is_replicated 选择复制或非复制模式;灵活模式则使用单独的方法。
| 复制(默认) | 非复制(is_replicated = false) | 灵活(flexible_http_request) | |
|---|---|---|---|
| 由谁发送 | 所有 N 个节点 | 系统选出的一个节点 | 由 total_requests 个节点组成的委员会 |
| 可获得的结果 | 一致通过的响应 | 该节点的响应 | min_responses 至 max_responses 个响应 |
| 转换 | 实际上必需 | 可选,但通常仍值得执行 | 可选,但通常仍值得执行 |
| 定价 | 版本 1 或 2 | 版本 1 或 2 | 始终为版本 2 |
| 额外方法 | GET、HEAD、POST | 另外支持 PUT、DELETE、PATCH | 当 total_requests、min_responses 和 max_responses 全部相等时,也支持这些方法 |
| 风险 | N 个并发请求可能触发 API 速率限制 | 该节点可能观察或修改响应 | 你需要负责协调这些响应 |
需要共识提供的完整性保证时,使用复制模式;对于有速率限制的 API 和非幂等 POST 请求,使用非复制模式;如果数据变化速度快到节点根本无法达成一致,则使用灵活模式。
实现
Motoko
从 ic mops 包中同时导入封装和类型:import Call "mo:ic/Call" 和 import IC "mo:ic/Types"。Call.httpRequest 会计算并附加所需的 cycles。
这是定价版本 1。 Motoko 尚无版本 2 路径,也不支持灵活的外部调用(请参阅支持矩阵)。以下内容均正确且受支持,只是采用了旧版定价模型。
import Blob "mo:core/Blob";
import Nat "mo:core/Nat";
import Text "mo:core/Text";
import Call "mo:ic/Call";
import IC "mo:ic/Types";
persistent actor {
// Transform function: strips headers so all replicas see the same response for consensus.
// MUST be a `shared query` function.
public query func transform({
context : Blob;
response : IC.HttpRequestResult;
}) : async IC.HttpRequestResult {
{
response with headers = []; // Strip headers -- they often contain non-deterministic values
};
};
// GET request: fetch a JSON API
public func getIcpPriceUsd() : async Text {
let url = "https://api.coingecko.com/api/v3/simple/price?ids=internet-computer&vs_currencies=usd";
let request : IC.HttpRequestArgs = {
url = url;
// Always set — omitting defaults to 2MB and charges accordingly.
// Budget for response headers + body: the cap covers both, and the
// transform cannot bring an oversized response back under it.
max_response_bytes = ?(10_000 : Nat64);
headers = [
{ name = "User-Agent"; value = "ic-canister" },
];
body = null;
method = #get;
transform = ?{
function = transform;
context = Blob.fromArray([]);
};
is_replicated = null;
};
// Call.httpRequest computes and attaches the required cycles automatically
let response = await Call.httpRequest(request);
switch (Text.decodeUtf8(response.body)) {
case (?text) { text };
case (null) { "Response is not valid UTF-8" };
};
};
// POST transform: also discards the body, because httpbin.org echoes the
// sender's IP in "origin", which differs across replicas.
public query func transformPost({
context : Blob;
response : IC.HttpRequestResult;
}) : async IC.HttpRequestResult {
{
response with
headers = [];
body = Blob.fromArray([]);
};
};
// POST request: send JSON data
public func postData(jsonPayload : Text) : async Text {
let url = "https://httpbin.org/post";
let request : IC.HttpRequestArgs = {
url = url;
max_response_bytes = ?(50_000 : Nat64);
headers = [
{ name = "Content-Type"; value = "application/json" },
{ name = "User-Agent"; value = "ic-canister" },
// Idempotency key: prevents duplicate processing if multiple replicas hit the endpoint
{ name = "Idempotency-Key"; value = "unique-request-id-12345" },
];
body = ?Text.encodeUtf8(jsonPayload);
method = #post;
transform = ?{
function = transformPost;
context = Blob.fromArray([]);
};
is_replicated = null;
};
// Call.httpRequest computes and attaches the required cycles automatically
let response = await Call.httpRequest(request);
if (response.status == 200) {
"POST successful (status 200)";
} else {
"POST failed with status " # Nat.toText(response.status);
};
};
};
Rust
# Cargo.toml
[package]
name = "https_outcalls_backend"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.20"
ic-cdk-management-canister = "0.2"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use ic_cdk_management_canister::{
transform_context_from_query, HttpMethod, HttpRequest, HttpRequestResult, TransformArgs,
};
use ic_cdk::{query, update};
/// Transform function: strips non-deterministic headers so all replicas agree.
/// MUST be a #[query] function.
#[query(hidden = true)]
fn transform(args: TransformArgs) -> HttpRequestResult {
HttpRequestResult {
status: args.response.status,
body: args.response.body,
headers: vec![], // Strip all headers for consensus
// If you need specific headers, filter them here:
// headers: args.response.headers.into_iter()
// .filter(|h| h.name.to_lowercase() == "content-type")
// .collect(),
}
}
/// GET request: Fetch JSON from an external API
#[update]
async fn fetch_price() -> String {
let url = "https://api.coingecko.com/api/v3/simple/price?ids=internet-computer&vs_currencies=usd";
// The builder always selects pricing version 2. `.send()` prices the call
// with ic0.cost_http_request_v2 and attaches that amount.
let request = HttpRequest::new(url)
.with_method(HttpMethod::GET)
// Budget for response headers + body: the cap covers both, and the
// transform cannot bring an oversized response back under it.
.with_max_response_bytes(10_000)
.with_header("User-Agent", "ic-canister")
.with_transform(transform_context_from_query("transform".to_string(), vec![]))
// Unset expectations reserve the worst case: a 60s round trip and a
// transform running to the full 5-billion-instruction query limit.
// Declaring them holds far fewer cycles. See pitfall 12.
.with_expected_roundtrip_time_ms(10_000)
.with_expected_transform_instructions(1_000_000);
match request.send().await {
Ok(response) => {
let body = String::from_utf8(response.body)
.unwrap_or_else(|_| "Invalid UTF-8 in response".to_string());
if response.status != candid::Nat::from(200u64) {
return format!("HTTP error: status {}", response.status);
}
body
}
Err(err) => {
format!("HTTP outcall failed: {:?}", err)
}
}
}
/// POST transform: strips headers AND body, because httpbin.org echoes the
/// sender's IP in "origin", which differs across replicas.
#[query(hidden = true)]
fn transform_post(args: TransformArgs) -> HttpRequestResult {
HttpRequestResult {
status: args.response.status,
body: vec![],
headers: vec![],
}
}
/// POST request: Send JSON data to an external API
#[update]
async fn post_data(json_payload: String) -> String {
let url = "https://httpbin.org/post";
let request = HttpRequest::new(url)
.with_method(HttpMethod::POST)
.with_max_response_bytes(50_000)
.with_header("Content-Type", "application/json")
.with_header("User-Agent", "ic-canister")
// Idempotency key: prevents duplicate processing across replicas
.with_header("Idempotency-Key", "unique-request-id-12345")
.with_body(json_payload.into_bytes())
.with_transform(transform_context_from_query("transform_post".to_string(), vec![]))
.with_expected_roundtrip_time_ms(10_000)
.with_expected_transform_instructions(1_000_000);
// Replicated, so every node POSTs: hence the idempotency key above, and
// `transform_post`. `.non_replicated()` would send it once instead and make
// both unnecessary, at the cost of trusting that one node (pitfall 8).
match request.send().await {
Ok(response) => {
if response.status == candid::Nat::from(200u64) {
"POST successful (status 200)".to_string()
} else {
format!("POST failed with status {}", response.status)
}
}
Err(err) => {
format!("HTTP outcall failed: {:?}", err)
}
}
}
周期成本估算
有两个定价版本。版本 1 是副本默认版本,现已弃用;版本 2(“按使用量付费”)按调用消耗的资源收费,而不是按预留的字节数收费。**Motoko 仅支持版本 1;使用 ic-cdk-management-canister 0.2 的 Rust 仅支持版本 2。**以下介绍的版本 1 仍是副本默认版本;如需了解完整的版本 2,包括预留机制及其独有的故障模式,请阅读 references/pricing-version-2.md。
ic0.cost_http_request 系统 API 会在运行时计算确切的周期成本,因此 Canister 无需硬编码公式。ic mops 包中的 Call.httpRequest(Motoko)会在内部调用该 API,并自动附加所需周期;Rust 端的 ic-cdk-management-canister 0.1 中的免费 http_request,以及 ic-cdk 0.19 中的 ic_cdk::management_canister::http_request 也有同样行为。如需手动调用:Motoko 使用 Prim.costHttpRequest(requestSize, maxResponseBytes)(通过 import Prim "mo:⛔");Rust 使用 ic_cdk::api::cost_http_request(request_size, max_res_bytes)。
request_size 是 URL、所有请求头名称和值、请求正文、转换函数名称以及转换上下文的字节长度之和。
作为参考,13 节点子网(n = 13)上的底层公式为:
Base cost: 49_140_000 cycles (= (3_000_000 + 60_000*13) * 13)
+ per request byte: 5_200 cycles (= 400 * 13)
+ per max_response_bytes byte: 10_400 cycles (= 800 * 13)
IMPORTANT: Under version 1 the charge is against max_response_bytes, NOT the
actual response size. Under version 2 it is the reverse: the charge follows
the bytes that arrive, the round-trip time and the transform instructions.
Omitting max_response_bytes assumes the 2MB maximum (2_000_000 bytes) and costs
49_140_000 + 10_400 * 2_000_000 = 20_849_140_000 cycles (~20.85B), plus the
per-request-byte term.
在版本 1 下,调用返回时会退还未使用的周期,因此超额预算安全,但并非免费。在版本 2 下,超额附加周期不只是浪费:超过基础费用的部分是每个节点的额度,它会设定每个节点的响应期限和转换指令限制,因此,预留余量相当于授予消费权限,而不是留作闲置储备。版本 2 的退款也会异步到账;如果某个节点始终未报告,则会在响应返回约一分钟后退还其额度。附加的周期在调用持续期间会从 Canister 的可用余额中扣除,因此,手动附加的余量会直接限制 Canister 在余额耗尽前可同时发起的出站调用数量。对于每次用户操作都发起一次出站调用的 Canister 来说,该余量就是并发限制。这就是为什么两个封装都会附加精确计算出的金额,而不是一个整数:正如 ic mops 包所说:“调用只会添加最少量的周期。这有助于 Canister 在周期耗尽前并行发起更多调用。”
不要为了“保险”而手动附加缓冲额度。请调用封装,或使用 Prim.costHttpRequest / ic_cdk::api::cost_http_request 计算确切成本,并附加该金额。在版本 1 下,降低出站调用成本的方法是收紧 max_response_bytes,而不是增加附加金额。在版本 2 下,价格取决于调用的实际消耗;往返时间和转换由 with_expected_* 设置项控制,大小仍由 max_response_bytes 控制,但它现在决定的是预留额度,而非价格。
此公式适用于普通的Application 子网。它不适用于云引擎(CloudEngine 子网):在云引擎上,无论 request_size 或 max_response_bytes 为何,ic0.cost_http_request 都会返回 0,因为引擎采用免费的成本方案。在编写或调试将在云引擎上运行的出站调用代码前,请加载 cloud-engine-canisters 技能,了解引擎的调用规则。
部署与测试
本地部署
icp network start -d
icp deploy backend
注意:HTTPS 出站调用可在本地副本上运行。icp-cli 会通过本地 HTTP 网关代理请求。灵活出站调用也可在本地运行,但所有响应都来自同一个节点,因此协调过程不会在此处发现意见分歧。
主网部署
icp canister status backend -e ic && icp deploy -e ic backend # check balance, then deploy
验证是否正常运行
# 1. GET outcall. Expected: '("{\"internet-computer\":{\"usd\":12.34}}")', price varies.
icp canister call backend getIcpPriceUsd '()' # Motoko example above
icp canister call backend fetch_price '()' # Rust example above
# 2. POST outcall. Expected: httpbin.org echoing your data back.
icp canister call backend postData '("{\"test\": \"hello\"}")' # Motoko
icp canister call backend post_data '("{\"test\": \"hello\"}")' # Rust
# 4. Balance should have decreased. Under version 2 it keeps settling for about
# a minute afterwards as per-node refunds arrive, so re-read before judging.
icp canister status backend
# 5. Error handling: add a function calling a non-existent domain and verify it
# returns an error rather than trapping.
出站调用故障排查
如果出站调用失败:
# Check the replica log for detailed error messages
# Local: icp output shows errors inline
# Mainnet: check the canister logs
# Exact reject messages from the replica (match on these, not on paraphrases):
#
# "Timeout expired" [SysFatal]
# The remote server did not respond within 30s.
#
# "Canister http request timed out" [SysTransient]
# The subnet did not produce a response within 60s. Normally retryable.
# Under version 2 it can also mean under-funding, but only when a node went
# silent AND the remaining contributors cannot fund delivery without it;
# then a retry just times out again. See references/pricing-version-2.md.
#
# "Deadline Exceeded" [SysTransient]
# The adapter did not answer the replica within its 60s deadline. Rarer
# than the two above: the adapter's own 30s timeout usually fires first.
#
# "No consensus could be reached. Replicas had different responses.
# Details: request_id: <id>, hashes: <...>" [SysTransient]
# Transform is missing or not stripping enough non-determinism.
#
# There are THREE distinct size-limit messages, all [SysFatal], because response
# headers are counted against max_response_bytes BEFORE the body is read:
#
# "Header size exceeds specified response size limit <N>"
# The response headers ALONE exceeded max_response_bytes.
#
# "Http body exceeds size limit of <N> bytes."
# The body exceeded the allowance REMAINING after header bytes were
# subtracted. Note the message prints the full cap <N>, not the remainder,
# so the body that failed can be well under <N>. Do not read this message
# as "my body is too big" -- it means headers + body are too big.
#
# "Transformed http response exceeds limit: <N>"
# The Candid-encoded output of your transform exceeded max_response_bytes.
#
# Raising max_response_bytes fixes all three. Stripping headers in the
# transform fixes ONLY this last one: the first two are checked before
# the transform runs.
#
# "http_request request sent with <X> cycles, but <Y> cycles are required."
# [CanisterReject]
# Attached less than the computed cost. Use the wrapper rather than a
# hand-picked number. Version 1 only: version 2 checks just the base fee up
# front and fails later instead, as one of the next two.
#
# "Insufficient cycles" [CanisterReject]
# Version 2: a node exhausted its own per-node allowance. Ordinary response
# content, so it reaches the canister only if a quorum produce the same
# reject; otherwise it becomes a divergence.
#
# "Out of cycles: <k> of the assigned replicas reported a collective spend of
# <n> cycles, leaving <m> cycles of the attached payment (after base fee
# deduction). Delivering a response would cost at least <min> cycles."
# [CanisterReject]
# Version 2 pooled shortfall: the nodes agreed, but what they left unspent
# does not cover putting the response in a block. Raise the reservation.
转换调试
如果遇到“no consensus could be reached”错误,说明你的转换函数未能让响应保持一致。常见原因:
- 响应标头不同——在转换中剥离所有标头
- JSON 字段顺序不同——在转换中解析并重新序列化 JSON
- 响应正文中包含时间戳——仅提取所需字段
如需了解类型化 JSON 解析器和用于规范化字段顺序的转换,请参阅 references/json-responses.md。
其他参考资料
references/pricing-version-2.md— 按需付费模式:收费方式、四项预期及其默认值、为何将其纳入每节点的共享预算,以及资金不足时的调用拒绝消息和迁移前的检查事项。references/json-responses.md— 使用serde_json解析类型化 JSON 正文,以及通过重新序列化来规范化字段顺序以达成共识的转换。references/flexible-outcalls.md—flexible_http_request完整说明:根据subnet_self_node_count计算复制数量、可编译示例、协调策略及其威胁模型、四种global_error值、两个独立的大小限制、本地运行为何始终不会显示意见不一致,以及转换是否值得投入。- 对于在云引擎上运行的 canister,请加载
cloud-engine-canisters,其中包括为何出站调用的成本会降至 0,以及为何绝不能通过引擎的控制台代理路由出站调用。