https-outcalls

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

HTTPS 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:
    mo:core
    2.0 and
    ic >= 2.1.0
    in mops.toml
  • For Rust:
    ic-cdk >= 0.19
    ,
    serde_json
    for JSON parsing
  • 对于Motoko:mops.toml中需配置
    mo:core
    2.0和
    ic >= 2.1.0
  • 对于Rust:
    ic-cdk >= 0.19
    ,以及用于JSON解析的
    serde_json

Canister IDs

Canister ID

HTTPS outcalls use the IC management canister:
NameCanister IDUsed For
Management canister
aaaaa-aa
The
http_request
management call target
You do not deploy anything extra. The management canister is built into every subnet.
HTTPS外部调用使用IC管理Canister:
名称Canister ID用途
管理Canister
aaaaa-aa
http_request
管理调用的目标
无需额外部署任何内容。管理Canister内置于每个子网中。

Mistakes That Break Your Build

导致构建失败的常见错误

  1. 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.
  2. 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, use
    await Call.httpRequest(args)
    from the
    ic
    mops package (
    import Call "mo:ic/Call"
    ); in Rust, use
    ic_cdk::management_canister::http_request
    (available since ic-cdk 0.18). Under the hood, both use the
    ic0.cost_http_request
    system API to calculate the exact cost from
    request_size
    and
    max_response_bytes
    .
  3. 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.
  4. 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 the
    max_response_bytes
    field to set a limit and design your queries to return small responses.
  5. Omitting
    max_response_bytes
    .
    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.
  6. 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.
  7. 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.
  8. 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.
  9. Forgetting the
    Host
    header.
    Some API endpoints require the
    Host
    header to be explicitly set. The IC does not automatically set this from the URL.
  1. 忘记转换函数:如果没有转换函数,原始HTTP响应通常会在不同副本之间存在差异(不同的头信息、JSON字段顺序不同、时间戳不同)。共识会失败,调用会被拒绝。务必提供转换函数。
  2. 未为调用附加Cycle:HTTPS外部调用并非免费。发起调用的Canister必须附加Cycle以覆盖成本。如果附加的Cycle为0,调用会立即失败。Motoko和Rust都有自动计算并附加所需Cycle的封装函数:在Motoko中,使用
    ic
    mops包中的
    await Call.httpRequest(args)
    import Call "mo:ic/Call"
    );在Rust中,使用
    ic_cdk::management_canister::http_request
    (从ic-cdk 0.18版本开始可用)。在底层,两者都会调用
    ic0.cost_http_request
    系统API,根据
    request_size
    max_response_bytes
    计算精确成本。
  3. 使用HTTP而非HTTPS:IC仅支持HTTPS外部调用。纯HTTP URL会被拒绝。目标服务器必须拥有有效的TLS证书。
  4. 超出2MB响应限制:响应体的最大大小为2MB(2_097_152字节)。如果外部API返回的内容超过此限制,调用会失败。使用
    max_response_bytes
    字段设置限制,并设计查询以返回较小的响应。
  5. 省略
    max_response_bytes
    :如果未设置
    max_response_bytes
    ,系统会默认使用最大值(2MB)并据此收取Cycle费用——在13节点子网上大约为215亿Cycle。请始终将其设置为你预期响应的合理上限。
  6. 未谨慎处理非幂等POST请求:由于多个副本会发起相同的请求,非幂等的POST端点(例如“创建订单”)会被调用N次(每个副本调用一次,在13节点子网中通常为13次)。使用幂等键或设计可处理重复请求的端点。
  7. 未处理外部调用失败:外部服务器可能宕机、响应缓慢或返回错误。务必处理错误情况。在IC上,如果外部服务器在超时时间(约30秒)内未响应,调用会触发陷阱(trap)。
  8. 调用localhost或私有IP:HTTPS外部调用只能访问公共互联网端点。localhost、10.x.x.x、192.168.x.x以及其他私有IP范围会被阻止。
  9. 忘记
    Host
    :部分API端点要求显式设置
    Host
    头。IC不会自动从URL中设置此头。

Implementation

实现示例

Motoko

Motoko

The management canister types are imported via
import IC "ic:aaaaa-aa"
(compiler-provided). The
ic
mops package (
import Call "mo:ic/Call"
) provides
Call.httpRequest
which auto-computes and attaches the required cycles.
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);
    };
  };
};
管理Canister的类型可通过
import IC "ic:aaaaa-aa"
导入(由编译器提供)。
ic
mops包中的
Call.httpRequest
import Call "mo:ic/Call"
)可自动计算并附加所需Cycle。
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
undefined
toml
undefined

Cargo.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
ic0.cost_http_request
system API computes the exact cycle cost at runtime, so canisters do not need to hard-code the formula. Both
Call.httpRequest
from the
ic
mops package (Motoko) and
ic_cdk::management_canister::http_request
(Rust) call it internally and attach the required cycles automatically. For manual use: in Motoko,
Prim.costHttpRequest(requestSize, maxResponseBytes)
(via
import Prim "mo:⛔"
); in Rust,
ic_cdk::api::cost_http_request(request_size, max_res_bytes)
.
request_size
is the sum of byte lengths of the URL, all header names and values, the body, the transform function name, and the transform context.
For 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_request
系统API会在运行时计算精确的Cycle成本,因此Canister无需硬编码计算公式。Motoko的
ic
mops包中的
Call.httpRequest
和Rust的
ic_cdk::management_canister::http_request
都会在内部调用此API并自动附加所需Cycle。如果需要手动使用:在Motoko中,通过
import Prim "mo:⛔"
调用
Prim.costHttpRequest(requestSize, maxResponseBytes)
;在Rust中,调用
ic_cdk::api::cost_http_request(request_size, max_res_bytes)
request_size
是URL、所有头信息的名称和值、请求体、转换函数名称以及转换上下文的字节长度之和。
作为参考,在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
undefined
bash
undefined

Start 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
undefined
bash
undefined

Ensure 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
undefined
icp deploy -e ic backend
undefined

Verify It Works

验证功能正常

bash
undefined
bash
undefined

1. 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

undefined
undefined

Debugging Outcall Failures

调试外部调用失败

If an outcall fails:
bash
undefined
If an outcall fails:
bash
undefined

Check 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

undefined
undefined

Transform Debugging

转换函数调试

If you get "no consensus could be reached" errors, your transform function is not making responses identical. Common culprits:
  1. Response headers differ -- strip ALL headers in the transform
  2. JSON field ordering differs -- parse and re-serialize the JSON in the transform
  3. 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:
  1. Response headers differ -- strip ALL headers in the transform
  2. JSON field ordering differs -- parse and re-serialize the JSON in the transform
  3. 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![],
    }
}