mcpField
H
https-outcalls

https-outcalls

github.com/dfinity開発39311 回インストール今日更新
リポジトリ

https-outcalls について

Internet Computer の Motoko・Rust canister 開発者向けの HTTPS outcall ガイドです。外部 Web サービスへのリクエスト、レスポンス変換、呼び出しモード、依存バージョンごとの API と料金、制限や失敗要因を説明します。

主な機能

  1. 01外部 HTTPS サービスへのリクエスト
  2. 02複製・非複製・flexible モードの解説
  3. 03レスポンス変換とコンセンサスの注意点
  4. 04バージョン別 API と料金の案内
  5. 05リクエスト制限とエラー処理

活用シーン

  1. 01canister から公開 Web API のデータを取得
  2. 02複製レスポンスを正規化してコンセンサスを確保
  3. 03非冪等な POST リクエストの呼び出しモードを選択
  4. 04outcall のサイズ制限やタイムアウト失敗を診断

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dfinity/icskills --skill https-outcalls

インストール後、Claude Code で/https-outcallsを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
flexible-outcalls.md
json-responses.md
pricing-version-2.md

詳細

HTTPS 外部呼び出し

概要

HTTPS 外部呼び出しを使用すると、カニスターはオンチェーンコードから外部ウェブサービスに直接 HTTP リクエストを送信できます。デフォルトの複製モードでは、サブネット上のすべてのノードが同じリクエストを送信し、レスポンスについて全ノードが合意する必要があります。変換関数が非決定的なフィールド(タイムスタンプ、リクエスト ID、順序)を取り除くことで、すべてのレプリカが同一のレスポンスを確認し、コンセンサスに到達できるようにします。

合意の手順を省略するほかのモードが2つあります。非複製(is_replicated = false。1つのノードがリクエストを送信)とフレキシブル(flexible_http_request。ノードの委員会がそれぞれのレスポンスを返す)です。外部呼び出しのモードを参照してください。

サポートマトリックス

RustMotoko
パッケージic-cdk 0.20.3+ と ic-cdk-management-canister 0.2+core 2.5.0+ と moc 1.4.0+ が必要な ic 4.x(mops)
エントリーポイントHttpRequest::new(url) ビルダー、.send()Call.httpRequest(args)
料金バージョン1(レガシー)ic-cdk-management-canister 0.1 に固定するか、ic_cdk::api::cost_http_request で aaaaa-aa を直接呼び出す場合のみはい。Call.httpRequest はこれを使用します
料金バージョン2(従量課金制)はい。ビルダーは常にこれを選択します—(利用不可。未リリースの moc が必要です)
flexible_http_requestはい。FlexibleHttpRequest::new(url)—(利用不可。同じ理由です)
subnet_self_node_countic_cdk::api::subnet_self_node_count()—(利用不可。同じ理由です)

コードを書く前にこれを読んでください。利用できる機能は、プロジェクトで使われている言語と依存関係のバージョンによって異なります。現在、Motoko のコードはバージョン1のみで、常にそうなります。公開されている ic 4.x の HttpRequestArgs には pricing_version フィールドがなく、Candid では存在しないオプショナル値が省略され、レプリカはこれをバージョン1として読み取ります。これは、Call.httpRequest が資金を供給するバージョンと同じです。回避策は必要ありません。Motoko でバージョン2の料金体系を期待しないでください。

Rust では、プロジェクトがどの3つの状況にあるかによって、適切なコードが異なります。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) が固定されており、無効化できません。そのため、クレートのアップグレードがそのまま移行となります。Rust では JSON の解析に serde_json も必要です。

使用するバージョン。 バージョン1は非推奨で、今後はバージョン2が推奨されるため、新しい Rust コードでは0.2のビルダーを使用してください。ただし、無関係な作業に付随して移行するために、プロジェクトで固定されているバージョンを更新してはいけません。0.2は破壊的変更であり(自由関数の http_request が削除され、HttpRequestArgs に必須フィールドが追加されます)、アップグレードは呼び出し側が判断することです。プロジェクトが実際に使用しているバージョンに合わせて正しいコードを書き、アップグレードできることを伝えてください。

カニスター ID

HTTPS 外部呼び出しでは、IC 管理カニスターを使用します。

名前カニスター ID用途
管理カニスターaaaaa-aahttp_request と flexible_http_request の管理呼び出し先

追加で何かをデプロイする必要はありません。管理カニスターはすべてのサブネットに組み込まれています。

ビルドを失敗させる間違い

  1. 変換関数を忘れる。 複製モードでは、変換関数を指定しないと、複製ごとに生の HTTP レスポンスが異なることがよくあります(ヘッダーの違い、JSON フィールドの順序の違い、タイムスタンプなど)。合意に失敗し、呼び出しは拒否されます。ここでは必ず指定してください。非複製モードとフレキシブルモードでは、各ノードのレスポンスがそのまま返されるため、合意のためには省略できます。指定するかどうかはトレードオフです。変換関数を指定しない場合、この関数のための予約はありません(命令の項目はクエリ上限ではなく 0 がデフォルトです)。これは利用できる中で最も安価な設定です。追加すると、この項目はクエリ上限全体がデフォルト値になるため、with_expected_transform_instructions も宣言する必要があります。その代わりに得られるのは配信手数料の削減です。この手数料は変換後のサイズ 1 バイトごとに課金されます。つまり、削除するバイトごとに料金が下がるため、決め手となるのは削除する価値のあるデータ量です(かさばるヘッダーなら効果がありますが、すでに大半がペイロードのレスポンスでは効果がありません)。予約によってこの項目は約 1 KB が下限となるため、小さなレスポンスでは get_cost() が節約額を過小評価します。それでも、サイクル以外の理由で指定するかどうかが決まることがあります。フレキシブルモードでは、変換後のサイズで合計 2 MiB の予算が計算されるため、レスポンスが小さいほど破棄されるものが減ります。また、ノードごとのばらつきによってすべてのレスポンスが異なる場合は、照合できません。

  2. 呼び出しにサイクルを付けない。 通常の Application サブネットでは、HTTPS アウトコールは無料ではありません。呼び出し元のキャニスターは費用を賄うサイクルを付ける必要があり、0 を付けると呼び出しは失敗します。Motoko と Rust には、必要なサイクルを計算して自動的に付けるラッパーがあります。Motoko では、ic mops パッケージの await Call.httpRequest(args) を使用します(import Call "mo:ic/Call")。Rust では、ic-cdk-management-canister 0.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 では、呼び出し時の事前チェックで基本料金のみが対象となります。そのため、付けたサイクルが不足していても呼び出し時には拒否されません。呼び出しはノードごとの上限が厳しくなった状態で実行され、リモートサーバーへの接続後、途中で失敗することがあります。references/pricing-version-2.md を参照してください。クラウドエンジン(CloudEngine サブネット)では、設計上、この金額は常に 0 です。そこで動作しているアウトコールにハードコードしたゼロ以外の手数料を付けて「修正」しないでください。cloud-engine-canisters スキルを参照してください。

  3. HTTPS ではなく HTTP を使う。 IC がサポートしているのは HTTPS アウトコールのみです。通常の HTTP URL は拒否されます。接続先サーバーには有効な TLS 証明書が必要です。

  4. 予想される本文を基準に max_response_bytes を設定する — 上限の対象は本文だけではありません。 仕様では、HTTP リクエストまたはレスポンスのサイズを、HTTP ヘッダーの名前と値、および HTTP 本文を表すバイト数の合計と定義しています。レスポンスのヘッダーも max_response_bytes の上限に含まれます。実際の API では、本文の 1 バイト目が届く前に 1~2 KB のレスポンスヘッダー(一意のリクエスト ID、Date、レート制限関連のヘッダー、CDN ヘッダーなど)が送られることがよくあります。上限が厳しい場合、これは予算の大部分を占めます。サーバーから届くヘッダーと本文の合計に合わせて上限を設定し、さらに余裕を持たせてください。失敗の仕方は全面的です。レスポンスが切り詰められて返されるのではなく、呼び出しは毎回失敗します。最大値は 2MB = 2_000_000 バイト(2^21 ではなく 10 進数)です。同じヘッダーと本文の合計という定義により、送信するリクエストも 2_000_000 バイトが上限となります。

  5. 変換関数が、上限を超えたレスポンスを上限内に縮小してくれると思い込むこと。 上限は2回適用されます。1回目はサーバーから届いた生のレスポンスに対して(まずヘッダーを確認し、次に残りのサイズに対して本文を確認します)、2回目は変換後の Candid エンコード済みの出力に対してで、シリアライズのオーバーヘッドも含まれます。変換関数でヘッダーを削除しても、すでに生のレスポンスが上限を超えていた場合は救済できません。変換関数が実行される前に、最初のチェックで失敗するためです。変換後の出力が上限を超えてしまう場合に、変換関数がヘッダーをそのまま返すことを防げる点では有効です。2つのチェックは同じ max_response_bytes の値と比較されるため、生のレスポンスが上限ぎりぎりに収まっていても、変換後に失敗することがあります。Candid のオーバーヘッドが加算されるためです。つまり、生のレスポンスのサイズに合わせて max_response_bytes を設定し、変換後の出力は小さく保ってください。後からヘッダーを削除すれば上限を超えたレスポンスも収まる、という考えで厳しい上限を設定してはいけません。

  6. ヘッダーの上限を無視すること。 max_response_bytes とは別に、仕様では HTTP リクエストとレスポンスについて、ヘッダー数は64個、ヘッダー名または値はそれぞれ8 KiB、ヘッダー名と値の合計は48 KiBまでと定められています。URL は8192バイトを超えてはいけません。リクエスト側では、レプリカが引数をデコードする際にこれらの上限が適用されるため、上限を超えたリクエストはサブネットの外に送信されず、HTTP 関連のエラーではなく InvalidManagementPayload で失敗します。例:Deserialize error: The number of elements exceeds maximum allowed 64。user-agent ヘッダーを送信しない場合、IC が user-agent: ic/1.0 を追加しますが、この追加されたヘッダーは上限の計算に含まれません。

  7. max_response_bytes を省略すること。 バージョン 1では、設定しない場合、システムは最大値(2MB)を想定し、それに応じたサイクルを課金します。13 ノードのサブネットでは、約 208.5 億サイクルです。バージョン 2では価格の決定には使われなくなりましたが、raw_response_bytes の想定値のデフォルトとして引き続き使われるため、省略すると 2MB 分が予約されます。4,000 バイトの上限なら 53 億サイクルで済むところ、340 億サイクルが必要です。いずれの場合も、想定するレスポンスに対して妥当な上限を必ず設定してください(上限に含まれる内容については、落とし穴 4 を参照)。

  8. 注意せずに非冪等な POST リクエストを送信すること。 複数のレプリカが同じリクエストを送信するため、非冪等な POST エンドポイント(例:「注文の作成」)は N 回(1レプリカにつき1回。通常、13 ノードのサブネットでは13回)呼び出されます。冪等性キーを使用するか、重複リクエストを処理できるようにエンドポイントを設計してください。または is_replicated = ?false(Rust:.non_replicated())を設定してください。1つのノードだけがリクエストを送信し、レート制限の負荷を完全になくせます。ただし、そのノードがレスポンスを観測または変更しないことを信頼する必要があります。

  9. アウトコールの失敗を処理しないこと。 外部サーバーは停止中、応答が遅い、またはエラーを返すことがあります。必ずエラーケースを処理してください。タイムアウトは2種類あり、どちらもトラップにはなりません。どちらもリジェクトとして返されます(Motoko では await が捕捉可能な Error を発生させ、Rust ではラッパーが Err を返します)。

    • リモートサーバーが30秒以内に応答しない場合:SysFatal、メッセージ Timeout expired。
    • サブネットが60秒以内にレスポンスを生成しない場合:SysTransient、メッセージ Canister http request timed out。通常、再試行可能なのはこちらです。ただし、資金不足であり、かつノードの報告が欠けているバージョン 2 の呼び出しは例外です(references/pricing-version-2.md を参照)。
  10. localhost またはプライベート IP に接続すること。 HTTPS アウトコールでアクセスできるのは、パブリックインターネット上のエンドポイントのみです。localhost、10.x.x.x、192.168.x.x、その他のプライベート IP 範囲はブロックされます。

  11. Host ヘッダーを設定し忘れること。 一部の API エンドポイントでは、Host ヘッダーを明示的に設定する必要があります。IC が URL からこのヘッダーを自動設定することはありません。

  12. バージョン 2 の期待値を未設定のままにすること。 宣言しない項目はすべて、最大値で予約されます。往復時間は 60 秒、変換処理はクエリ命令の上限まで実行されます。上限が 4,000 バイトの場合、保持されるサイクル数は約 53 億に達し、そのほぼすべてが変換処理の予約分です。with_expected_transform_instructions と with_expected_roundtrip_time_ms を宣言した場合は約 1 億 1,200 万になります。この 2 つは宣言してください。raw_response_bytes はサーバーが決めるため、そのままにしてください。バイト数の項目が支配的な場合は、代わりに max_response_bytes を小さくします。変換処理自身の出力サイズに上限を設ける場合は、with_expected_transformed_response_bytes を宣言してください。上限値が調整手段にならないのはこの場合だけです。予算を小さくして実際に呼び出しが途中で打ち切られない限り、これらの設定によって 課金額 は変わりません。数値、リソースごとの考え方、失敗パターンについては references/pricing-version-2.md を参照してください。

  13. ic-cdk-management-canister 0.2 を通じてバージョン 1 に到達できると期待すること。 できません。このクレートが提供する方法はビルダーだけで、pricing_version: Some(2) がハードコードされており、with_pricing_version もありません。HttpRequest::from_args は回避策のように見えますが、そうではありません。これは既存の呼び出し箇所を移行するための文書化された方法であり、pricing_version を 暗黙に 2 で上書きするため、意図的に 1 を設定した引数を渡すとバージョンが変わります。バージョン 1 を使い続けるには、ic-cdk-management-canister 0.1 に固定するか、aaaaa-aa を直接呼び出し、現在も 0.20.3 に存在する ic_cdk::api::cost_http_request で料金を計算してください。

  14. pricing_version を手動で設定し、誤った料金関数で資金を割り当てること。 フィールドと添付するサイクル数は一致している必要があります。pricing_version = 2 を設定しながら、cost_http_request(バージョン 1)の金額を添付すると、呼び出しは 受け入れられます。事前チェックが基本料金のみを確認するためです。その後、バージョン 2 が想定するより少ないノードごとの割り当てで実行され、途中で失敗する可能性があります。逆に、cost_http_request_v2 の金額を割り当てたバージョン 1 の呼び出しは、バージョン 1 では max_response_bytes 全体の金額が事前に必要となるため、http_request request sent with <X> cycles, but <Y> cycles are required. というメッセージとともに拒否されます。ラッパーに両方を設定させてください。Rust の HttpRequest::send() はバージョン 2 と cost_http_request_v2 を組み合わせ、Motoko の Call.httpRequest はバージョン 1 と cost_http_request を組み合わせます。また、レプリカは認識できないバージョンをエラーなしでバージョン 1 に フィルタリングするため、無効な値は検証エラーではなく資金の不一致として失敗します。

  15. 柔軟なアウトコールで total_requests をハードコードすること。 total_requests はサブネットのサイズを超えてはいけません。サブネットのサイズはサブネットごとに異なります(メインネットでは 13 または 34)。値は次のように導出してください。

    // WRONG, even when you want five nodes: 5 may exceed the subnet size.
    let replication = ReplicationCounts { total_requests: 5, min_responses: 3, max_responses: 5 };
    
    // RIGHT: ask for five, but never more than the subnet has.
    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 個すべてのノードシステムが選択した 1 つのノードtotal_requests 個のノードで構成される委員会
取得できるもの合意された 1 つのレスポンスそのノードのレスポンスmin_responses から max_responses までのレスポンス
変換実質的に必須任意ですが、行う価値がある場合がよくあります任意ですが、行う価値がある場合がよくあります
料金体系バージョン 1 または 2バージョン 1 または 2常にバージョン 2
追加メソッドGET、HEAD、POSTさらに PUT、DELETE、PATCHtotal_requests、min_responses、max_responses がすべて同じ場合、これらのメソッドも追加
リスクN 個の同時リクエストにより API のレート制限に達する可能性がありますそのノードがレスポンスを観察または変更する可能性がありますレスポンスの調整はご自身で行う必要があります

整合性を保証するコンセンサスが必要な場合はレプリケートを、レート制限のある API や非冪等な POST には非レプリケートを、ノード間で合意できるよりも速くデータが変化する場合はフレキシブルを使用してください。

実装

Motoko

ic mops パッケージからラッパーと型の両方をインポートします。import Call "mo:ic/Call" と import IC "mo:ic/Types" を指定します。Call.httpRequest が必要なサイクル数を計算し、付加します。

これは料金体系バージョン 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)
        }
    }
}
サイクルコストの見積もり

料金体系は2つのバージョンがあります。バージョン1はレプリカのデフォルトで、非推奨です。バージョン2(「従量課金」)では、予約したバイト数ではなく、呼び出しで消費したリソースに対して料金が発生します。Motoko はバージョン1のみ、ic-cdk-management-canister 0.2 を使う Rust はバージョン2のみです。 以下で説明するバージョン1は、現在もレプリカのデフォルトです。予約の仕組みやバージョン固有の失敗モードを含むバージョン2の全容については、references/pricing-version-2.md を参照してください。

ic0.cost_http_request システム API は実行時に正確なサイクルコストを計算するため、キャニスターで計算式をハードコードする必要はありません。ic mops パッケージ(Motoko)の Call.httpRequest は内部でこの 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の返金も非同期で行われ、応答から約1分後に、報告を返さなかったノードの許容量が返金されます。付加したサイクルは呼び出しの継続中、キャニスターの使用可能な残高から差し引かれるため、手動で付加した余裕分によって、残高が尽きるまでにキャニスターが同時に実行できるアウトコールの数が直接制限されます。ユーザーの操作ごとに1回のアウトコールを行うキャニスターでは、その余裕分が同時実行数の上限になります。そのため、どちらのラッパーも概算ではなく、計算した正確な量を付加します。ic mops パッケージの説明にもあるとおりです。「最小限のサイクルのみが呼び出しに追加されます。これにより、キャニスターはサイクルを使い果たすことなく、より多くの呼び出しを並列に実行できます。」

「安全のため」に余分なバッファを手動で付加しないでください。ラッパーを呼び出すか、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」というエラーが発生する場合、トランスフォーム関数によってレスポンスが同一になっていません。よくある原因は次のとおりです。

  1. レスポンスヘッダーが異なる -- トランスフォームですべてのヘッダーを削除します
  2. JSON のフィールド順が異なる -- トランスフォームで JSON をパースし、再シリアライズします
  3. レスポンスボディ内のタイムスタンプ -- 必要なフィールドだけを抽出します

型付き JSON パーサーと、フィールド順を正規化するトランスフォームについては、references/json-responses.md を参照してください。

参考資料

  • references/pricing-version-2.md — 従量課金モデルの料金体系、4 つの期待値とそのデフォルト値、それらがノードごとの共有予算にまとめられている理由、資金不足の呼び出しに対する拒否メッセージ、移行前に確認すべき事項について説明します。
  • references/json-responses.md — serde_json を使った型付き JSON ボディのパースと、フィールド順を正規化してコンセンサスを得るための再シリアライズを行うトランスフォームについて説明します。
  • references/flexible-outcalls.md — flexible_http_request の詳細です。subnet_self_node_count から導出されるレプリケーション数、コンパイル可能な例、調整戦略とその脅威モデル、4 つの global_error 値、2 つの個別のサイズ制限、ローカル実行では不一致が発生しない理由、トランスフォームのコストに見合う効果があるかどうかについて説明します。
  • クラウドエンジン上で実行されるカニスターについては、cloud-engine-canisters を読み込んでください。アウトコールのコストがそこで 0 になる理由と、アウトコールをエンジンのコンソールプロキシ経由でルーティングしてはならない理由も説明されています。

コメント