https-outcalls
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseHTTPS Outcalls
HTTPS外部调用
What This Is
什么是HTTPS外部调用
HTTPS outcalls allow canisters to make HTTP requests to external web services directly from on-chain code. Because the Internet Computer runs on a replicated subnet (multiple nodes execute the same code), all nodes must agree on the response. A transform function strips non-deterministic fields (timestamps, request IDs, ordering) so that every replica sees an identical response and can reach consensus.
HTTPS外部调用允许Canister直接从链上代码向外部Web服务发起HTTP请求。由于Internet Computer运行在复制子网(多个节点执行相同代码)上,所有节点必须对响应达成一致。转换函数会去除非确定性字段(时间戳、请求ID、字段顺序),以便每个副本都能看到完全相同的响应并达成共识。
Prerequisites
前置条件
- For Motoko: 2.0 and
mo:corein mops.tomlic >= 2.1.0 - For Rust: ,
ic-cdk >= 0.19for JSON parsingserde_json
- 对于Motoko:mops.toml中需配置2.0和
mo:coreic >= 2.1.0 - 对于Rust:,以及用于JSON解析的
ic-cdk >= 0.19serde_json
Canister IDs
Canister ID
HTTPS outcalls use the IC management canister:
| Name | Canister ID | Used For |
|---|---|---|
| Management canister | | The |
You do not deploy anything extra. The management canister is built into every subnet.
HTTPS外部调用使用IC管理Canister:
| 名称 | Canister ID | 用途 |
|---|---|---|
| 管理Canister | | |
无需额外部署任何内容。管理Canister内置于每个子网中。
Mistakes That Break Your Build
导致构建失败的常见错误
-
Forgetting the transform function. Without a transform, the raw HTTP response often differs between replicas (different headers, different ordering in JSON fields, timestamps). Consensus fails and the call is rejected. ALWAYS provide a transform function.
-
Not attaching cycles to the call. HTTPS outcalls are not free. The calling canister must attach cycles to cover the cost. If you attach zero cycles, the call fails immediately. Both Motoko and Rust have wrappers that compute and attach the required cycles automatically: in Motoko, usefrom the
await Call.httpRequest(args)mops package (ic); in Rust, useimport Call "mo:ic/Call"(available since ic-cdk 0.18). Under the hood, both use theic_cdk::management_canister::http_requestsystem API to calculate the exact cost fromic0.cost_http_requestandrequest_size.max_response_bytes -
Using HTTP instead of HTTPS. The IC only supports HTTPS outcalls. Plain HTTP URLs are rejected. The target server must have a valid TLS certificate.
-
Exceeding the 2MB response limit. The maximum response body is 2MB (2_097_152 bytes). If the external API returns more, the call fails. Use thefield to set a limit and design your queries to return small responses.
max_response_bytes -
Omitting. If you do not set
max_response_bytes, the system assumes the maximum (2MB) and charges cycles accordingly — roughly 21.5 billion cycles on a 13-node subnet. Always set this to a reasonable upper bound for your expected response.max_response_bytes -
Non-idempotent POST requests without caution. Because multiple replicas make the same request, a POST endpoint that is not idempotent (e.g., "create order") will be called N times (once per replica, typically 13 on a 13-node subnet). Use idempotency keys or design endpoints to handle duplicate requests.
-
Not handling outcall failures. External servers can be down, slow, or return errors. Always handle the error case. On the IC, if the external server does not respond within the timeout (~30 seconds), the call traps.
-
Calling localhost or private IPs. HTTPS outcalls can only reach public internet endpoints. Localhost, 10.x.x.x, 192.168.x.x, and other private ranges are blocked.
-
Forgetting theheader. Some API endpoints require the
Hostheader to be explicitly set. The IC does not automatically set this from the URL.Host
-
忘记转换函数:如果没有转换函数,原始HTTP响应通常会在不同副本之间存在差异(不同的头信息、JSON字段顺序不同、时间戳不同)。共识会失败,调用会被拒绝。务必提供转换函数。
-
未为调用附加Cycle:HTTPS外部调用并非免费。发起调用的Canister必须附加Cycle以覆盖成本。如果附加的Cycle为0,调用会立即失败。Motoko和Rust都有自动计算并附加所需Cycle的封装函数:在Motoko中,使用mops包中的
ic(await Call.httpRequest(args));在Rust中,使用import Call "mo:ic/Call"(从ic-cdk 0.18版本开始可用)。在底层,两者都会调用ic_cdk::management_canister::http_request系统API,根据ic0.cost_http_request和request_size计算精确成本。max_response_bytes -
使用HTTP而非HTTPS:IC仅支持HTTPS外部调用。纯HTTP URL会被拒绝。目标服务器必须拥有有效的TLS证书。
-
超出2MB响应限制:响应体的最大大小为2MB(2_097_152字节)。如果外部API返回的内容超过此限制,调用会失败。使用字段设置限制,并设计查询以返回较小的响应。
max_response_bytes -
省略:如果未设置
max_response_bytes,系统会默认使用最大值(2MB)并据此收取Cycle费用——在13节点子网上大约为215亿Cycle。请始终将其设置为你预期响应的合理上限。max_response_bytes -
未谨慎处理非幂等POST请求:由于多个副本会发起相同的请求,非幂等的POST端点(例如“创建订单”)会被调用N次(每个副本调用一次,在13节点子网中通常为13次)。使用幂等键或设计可处理重复请求的端点。
-
未处理外部调用失败:外部服务器可能宕机、响应缓慢或返回错误。务必处理错误情况。在IC上,如果外部服务器在超时时间(约30秒)内未响应,调用会触发陷阱(trap)。
-
调用localhost或私有IP:HTTPS外部调用只能访问公共互联网端点。localhost、10.x.x.x、192.168.x.x以及其他私有IP范围会被阻止。
-
忘记头:部分API端点要求显式设置
Host头。IC不会自动从URL中设置此头。Host
Implementation
实现示例
Motoko
Motoko
The management canister types are imported via (compiler-provided). The mops package () provides which auto-computes and attaches the required cycles.
import IC "ic:aaaaa-aa"icimport Call "mo:ic/Call"Call.httpRequestmotoko
import Blob "mo:core/Blob";
import Nat "mo:core/Nat";
import Text "mo:core/Text";
import IC "ic:aaaaa-aa";
import Call "mo:ic/Call";
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.http_request_result;
}) : async IC.http_request_result {
{
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.http_request_args = {
url = url;
max_response_bytes = ?(10_000 : Nat64); // Always set — omitting defaults to 2MB and charges accordingly
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 includes the
// sender's IP in the "origin" field, which differs across replicas.
public query func transformPost({
context : Blob;
response : IC.http_request_result;
}) : async IC.http_request_result {
{
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.http_request_args = {
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);
};
};
};管理Canister的类型可通过导入(由编译器提供)。 mops包中的()可自动计算并附加所需Cycle。
import IC "ic:aaaaa-aa"icCall.httpRequestimport Call "mo:ic/Call"motoko
import Blob "mo:core/Blob";
import Nat "mo:core/Nat";
import Text "mo:core/Text";
import IC "ic:aaaaa-aa";
import Call "mo:ic/Call";
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.http_request_result;
}) : async IC.http_request_result {
{
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.http_request_args = {
url = url;
max_response_bytes = ?(10_000 : Nat64); // Always set — omitting defaults to 2MB and charges accordingly
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 includes the
// sender's IP in the "origin" field, which differs across replicas.
public query func transformPost({
context : Blob;
response : IC.http_request_result;
}) : async IC.http_request_result {
{
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.http_request_args = {
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
Rust
toml
undefinedtoml
undefinedCargo.toml
Cargo.toml
[package]
name = "https_outcalls_backend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```rust
use ic_cdk::api::canister_self;
use ic_cdk::management_canister::{
http_request, HttpHeader, HttpMethod, HttpRequestArgs, HttpRequestResult,
TransformArgs, TransformContext, TransformFunc,
};
use ic_cdk::{query, update};
use serde::Deserialize;
/// 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";
let request = HttpRequestArgs {
url: url.to_string(),
max_response_bytes: Some(10_000),
method: HttpMethod::GET,
headers: vec![
HttpHeader {
name: "User-Agent".to_string(),
value: "ic-canister".to_string(),
},
],
body: None,
transform: Some(TransformContext {
function: TransformFunc::new(canister_self(), "transform".to_string()),
context: vec![],
}),
is_replicated: None,
};
// http_request calls automatically attaches the required cycles
match http_request(&request).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)
}
}
}
/// Typed response parsing example
#[derive(Deserialize)]
struct PriceResponse {
#[serde(rename = "internet-computer")]
internet_computer: PriceData,
}
#[derive(Deserialize)]
struct PriceData {
usd: f64,
}
#[update]
async fn get_icp_price_usd() -> String {
let body = fetch_price().await;
match serde_json::from_str::<PriceResponse>(&body) {
Ok(parsed) => format!("ICP price: ${:.2}", parsed.internet_computer.usd),
Err(e) => format!("Failed to parse price response: {}", e),
}
}
/// POST transform: strips headers AND body because httpbin.org includes the
/// sender's IP in the "origin" field, 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 = HttpRequestArgs {
url: url.to_string(),
max_response_bytes: Some(50_000),
method: HttpMethod::POST,
headers: vec![
HttpHeader {
name: "Content-Type".to_string(),
value: "application/json".to_string(),
},
HttpHeader {
name: "User-Agent".to_string(),
value: "ic-canister".to_string(),
},
// Idempotency key: prevents duplicate processing across replicas
HttpHeader {
name: "Idempotency-Key".to_string(),
value: "unique-request-id-12345".to_string(),
},
],
body: Some(json_payload.into_bytes()),
transform: Some(TransformContext {
function: TransformFunc::new(canister_self(), "transform_post".to_string()),
context: vec![],
}),
is_replicated: None,
};
// http_request automatically attaches the required cycles
match http_request(&request).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)
}
}
}[package]
name = "https_outcalls_backend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```rust
use ic_cdk::api::canister_self;
use ic_cdk::management_canister::{
http_request, HttpHeader, HttpMethod, HttpRequestArgs, HttpRequestResult,
TransformArgs, TransformContext, TransformFunc,
};
use ic_cdk::{query, update};
use serde::Deserialize;
/// 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";
let request = HttpRequestArgs {
url: url.to_string(),
max_response_bytes: Some(10_000),
method: HttpMethod::GET,
headers: vec![
HttpHeader {
name: "User-Agent".to_string(),
value: "ic-canister".to_string(),
},
],
body: None,
transform: Some(TransformContext {
function: TransformFunc::new(canister_self(), "transform".to_string()),
context: vec![],
}),
is_replicated: None,
};
// http_request calls automatically attaches the required cycles
match http_request(&request).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)
}
}
}
/// Typed response parsing example
#[derive(Deserialize)]
struct PriceResponse {
#[serde(rename = "internet-computer")]
internet_computer: PriceData,
}
#[derive(Deserialize)]
struct PriceData {
usd: f64,
}
#[update]
async fn get_icp_price_usd() -> String {
let body = fetch_price().await;
match serde_json::from_str::<PriceResponse>(&body) {
Ok(parsed) => format!("ICP price: ${:.2}", parsed.internet_computer.usd),
Err(e) => format!("Failed to parse price response: {}", e),
}
}
/// POST transform: strips headers AND body because httpbin.org includes the
/// sender's IP in the "origin" field, 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 = HttpRequestArgs {
url: url.to_string(),
max_response_bytes: Some(50_000),
method: HttpMethod::POST,
headers: vec![
HttpHeader {
name: "Content-Type".to_string(),
value: "application/json".to_string(),
},
HttpHeader {
name: "User-Agent".to_string(),
value: "ic-canister".to_string(),
},
// Idempotency key: prevents duplicate processing across replicas
HttpHeader {
name: "Idempotency-Key".to_string(),
value: "unique-request-id-12345".to_string(),
},
],
body: Some(json_payload.into_bytes()),
transform: Some(TransformContext {
function: TransformFunc::new(canister_self(), "transform_post".to_string()),
context: vec![],
}),
is_replicated: None,
};
// http_request automatically attaches the required cycles
match http_request(&request).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)
}
}
}Cycle Cost Estimation
Cycle成本估算
The system API computes the exact cycle cost at runtime, so canisters do not need to hard-code the formula. Both from the mops package (Motoko) and (Rust) call it internally and attach the required cycles automatically. For manual use: in Motoko, (via ); in Rust, .
ic0.cost_http_requestCall.httpRequesticic_cdk::management_canister::http_requestPrim.costHttpRequest(requestSize, maxResponseBytes)import Prim "mo:⛔"ic_cdk::api::cost_http_request(request_size, max_res_bytes)request_sizeFor reference, the underlying formula on a 13-node subnet (n = 13) is:
text
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: The charge is against max_response_bytes, NOT actual response size.
If you omit max_response_bytes, the system assumes 2MB and charges ~21.5B cycles.Unused cycles are refunded to the canister, so it is safe to over-budget.
ic0.cost_http_requesticCall.httpRequestic_cdk::management_canister::http_requestimport Prim "mo:⛔"Prim.costHttpRequest(requestSize, maxResponseBytes)ic_cdk::api::cost_http_request(request_size, max_res_bytes)request_size作为参考,在13节点子网(n=13)上的底层计算公式为:
text
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: The charge is against max_response_bytes, NOT actual response size.
If you omit max_response_bytes, the system assumes 2MB and charges ~21.5B cycles.未使用的Cycle会退还给Canister,因此超额预算是安全的。
Deploy & Test
部署与测试
Local Deployment
本地部署
bash
undefinedbash
undefinedStart the local replica
Start the local replica
icp network start -d
icp network start -d
Deploy your canister
Deploy your canister
icp deploy backend
Note: HTTPS outcalls work on the local replica. icp-cli proxies the requests through the local HTTP gateway.icp deploy backend
Note: HTTPS outcalls work on the local replica. icp-cli proxies the requests through the local HTTP gateway.Mainnet Deployment
主网部署
bash
undefinedbash
undefinedEnsure your canister has enough cycles (check balance first)
Ensure your canister has enough cycles (check balance first)
icp canister status backend -e ic
icp canister status backend -e ic
Deploy
Deploy
icp deploy -e ic backend
undefinedicp deploy -e ic backend
undefinedVerify It Works
验证功能正常
bash
undefinedbash
undefined1. Test the GET outcall (fetch price)
1. Test the GET outcall (fetch price)
icp canister call backend fetchPrice
icp canister call backend fetchPrice
Expected: Something like '("{"internet-computer":{"usd":12.34}}")'
Expected: Something like '("{"internet-computer":{"usd":12.34}}")'
(actual price will vary)
(actual price will vary)
2. Test the POST outcall
2. Test the POST outcall
icp canister call backend postData '("{"test": "hello"}")'
icp canister call backend postData '("{"test": "hello"}")'
Expected: JSON response from httpbin.org echoing back your data
Expected: JSON response from httpbin.org echoing back your data
3. If using Rust with the typed parser:
3. If using Rust with the typed parser:
icp canister call backend get_icp_price_usd
icp canister call backend get_icp_price_usd
Expected: '("ICP price: $12.34")'
Expected: '("ICP price: $12.34")'
4. Check canister cycle balance (outcalls consume cycles)
4. Check canister cycle balance (outcalls consume cycles)
icp canister status backend
icp canister status backend
Verify the balance decreased slightly after outcalls
Verify the balance decreased slightly after outcalls
5. Test error handling: call with an unreachable URL
5. Test error handling: call with an unreachable URL
Add a test function that calls a non-existent domain and verify
Add a test function that calls a non-existent domain and verify
it returns an error message rather than trapping
it returns an error message rather than trapping
undefinedundefinedDebugging Outcall Failures
调试外部调用失败
If an outcall fails:
bash
undefinedIf an outcall fails:
bash
undefinedCheck the replica log for detailed error messages
Check the replica log for detailed error messages
Local: icp output shows errors inline
Local: icp output shows errors inline
Mainnet: check the canister logs
Mainnet: check the canister logs
Common errors:
Common errors:
"Timeout" -- external server took too long (>30s)
"Timeout" -- external server took too long (>30s)
"No consensus" -- transform function is missing or not stripping enough
"No consensus" -- transform function is missing or not stripping enough
"Body size exceeds limit" -- response > max_response_bytes
"Body size exceeds limit" -- response > max_response_bytes
"Not enough cycles" -- attach more cycles to the call
"Not enough cycles" -- attach more cycles to the call
undefinedundefinedTransform Debugging
转换函数调试
If you get "no consensus could be reached" errors, your transform function is not making responses identical. Common culprits:
- Response headers differ -- strip ALL headers in the transform
- JSON field ordering differs -- parse and re-serialize the JSON in the transform
- Timestamps in response body -- extract only the fields you need
Advanced transform that normalizes JSON:
rust
#[query]
fn transform_normalize(args: TransformArgs) -> HttpRequestResult {
// Parse and re-serialize to normalize field ordering
let body = if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&args.response.body) {
serde_json::to_vec(&json).unwrap_or(args.response.body)
} else {
args.response.body
};
HttpRequestResult {
status: args.response.status,
body,
headers: vec![],
}
}If you get "no consensus could be reached" errors, your transform function is not making responses identical. Common culprits:
- Response headers differ -- strip ALL headers in the transform
- JSON field ordering differs -- parse and re-serialize the JSON in the transform
- Timestamps in response body -- extract only the fields you need
Advanced transform that normalizes JSON:
rust
#[query]
fn transform_normalize(args: TransformArgs) -> HttpRequestResult {
// Parse and re-serialize to normalize field ordering
let body = if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&args.response.body) {
serde_json::to_vec(&json).unwrap_or(args.response.body)
} else {
args.response.body
};
HttpRequestResult {
status: args.response.status,
body,
headers: vec![],
}
}