Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/send_http_flexible.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: send_http_flexible

on:
schedule:
- cron: "30 6 * * 1" # weekly build to catch dependency drift (lock files are gitignored)
push:
branches:
- master
pull_request:
paths:
- rust/send_http_flexible/**
- .github/workflows/send_http_flexible.yml
- .github/workflows/_run-example.yml

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
rust-send_http_flexible:
uses: ./.github/workflows/_run-example.yml
with:
language: rust
working-directory: rust/send_http_flexible
run: |
icp network start -d
icp deploy
bash test.sh
2 changes: 1 addition & 1 deletion rust/daily_planner/backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ path = "lib.rs"
[dependencies]
candid = "0.10"
ic-cdk = "0.20"
ic-cdk-management-canister = "0.1.1"
ic-cdk-management-canister = "0.2"
ic-stable-structures = "0.6.5"
serde = "1.0"
serde_json = "1.0"
35 changes: 18 additions & 17 deletions rust/daily_planner/backend/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use candid::{CandidType, Decode, Deserialize, Encode, Nat};
use ic_cdk::api::canister_self;
use ic_cdk_management_canister::{
http_request, HttpMethod, HttpRequestArgs, HttpRequestResult, TransformArgs, TransformContext,
TransformFunc,
HttpMethod, HttpRequest, HttpRequestResult, TransformArgs, TransformContext, TransformFunc,
};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::storable::{Bound, Storable};
Expand Down Expand Up @@ -159,25 +158,27 @@ async fn fetch_and_store_on_this_day(date: String) -> Result<String, String> {
// This is useful to e.g. filter out timestamps/sessionIDs out of headers that will be different across the responses the different replicas receive.
// If the data (including status, headers and body) they receive does not match across the nodes, the canister will reject the response!
// You can read more about it here: https://docs.internetcomputer.org/guides/backends/https-outcalls.
let request = HttpRequestArgs {
url,
method: HttpMethod::GET,
body: None,
max_response_bytes: None, // Can be set to limit cost. Our response has no predictable size, so we set no limit.
headers: vec![],
transform: Some(TransformContext {
// Replicated mode is the builder's default: all subnet nodes make the request
// independently, providing strong integrity guarantees via consensus.
//
// max_response_bytes stays unset, because the response size is not known up
// front. It then defaults to the 2MB ceiling, and the reservation covers
// delivering that much. Only the round trip and the transform are narrowed
// below. An expectation the response later exceeds would fail the call at
// delivery, after the request was already made.
let request = HttpRequest::new(url)
.with_method(HttpMethod::GET)
.with_transform(TransformContext {
function: TransformFunc::new(canister_self(), "transform".to_string()),
context: vec![],
}),
// Replicated mode: all subnet nodes make the request independently,
// providing strong integrity guarantees via consensus.
is_replicated: Some(true),
};
})
.with_expected_roundtrip_time_ms(10_000)
.with_expected_transform_instructions(1_000_000);

// Perform HTTPS outcall. Cycles are automatically calculated and attached.
// Unused cycles are returned.
// Perform HTTPS outcall. send() computes the cycles the call may need and
// attaches them. Unused cycles are returned.
// See https outcall cost calculator: https://7joko-hiaaa-aaaal-ajz7a-cai.icp.net.
let quote = match http_request(&request).await {
let quote = match request.send().await {
Ok(response) => {
let body_string =
String::from_utf8(response.body).expect("Response is not UTF-8 encoded.");
Expand Down
11 changes: 11 additions & 0 deletions rust/send_http_flexible/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Various IDEs and Editors
.vscode/
.idea/
**/*~

# Mac OSX temporary files
.DS_Store
**/.DS_Store

# rust
target/
3 changes: 3 additions & 0 deletions rust/send_http_flexible/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[workspace]
members = ["backend"]
resolver = "2"
40 changes: 40 additions & 0 deletions rust/send_http_flexible/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# HTTP: flexible outcall

[![Open in ICP Ninja](https://icp.ninja/assets/open.svg)](https://icp.ninja/i?g=https://github.com/dfinity/examples/tree/master/rust/send_http_flexible)

> 🥷 **Try it live — no local setup.** [ICP Ninja](https://icp.ninja) is a web-based IDE that builds and deploys this project to the mainnet for free, right in your browser. Click the badge above, or hit **Deploy** if you're already in Ninja. To build and run it locally instead, follow the steps below.

This example demonstrates a **flexible** HTTPS outcall, made through the management canister's `flexible_http_request` method. A committee of nodes each make the request independently, and the canister receives their individual responses instead of one the subnet reached consensus on. Reconciling them is the canister's job.

That is useful when the data changes faster than the nodes could ever agree on it, which is where a replicated outcall fails consensus. This example asks `postman-echo.com` for the current time, then tallies the distinct answers it got back and reports the most common first.

For a deeper understanding of HTTPS outcalls on the IC, see the [HTTPS outcalls documentation](https://docs.internetcomputer.org/concepts/https-outcalls/).

## Build and deploy from the command line

### Prerequisites
- [Node.js](https://nodejs.org/) v18+
- [icp-cli](https://cli.internetcomputer.org/): `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`
- [Rust](https://www.rust-lang.org/tools/install) with `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`

### Install

```bash
git clone https://github.com/dfinity/examples
cd examples/rust/send_http_flexible
```

### Deploy and test

```bash
icp network start -d
icp deploy
bash test.sh
icp network stop
```

## Security considerations and best practices

A flexible outcall gives up the integrity guarantee that consensus provides, so how the responses are reconciled is part of the security of your canister. Handle any count between `min_responses` and `max_responses`, check each response's status separately, and pick a reconciliation rule that a minority of nodes cannot skew.

Refer to the [security best practices](https://docs.internetcomputer.org/guides/security/overview) for information on security and best practices for your ICP app.
12 changes: 12 additions & 0 deletions rust/send_http_flexible/backend/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
candid = "0.10"
ic-cdk = "0.20"
ic-cdk-management-canister = "0.2"
89 changes: 89 additions & 0 deletions rust/send_http_flexible/backend/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
use candid::{CandidType, Nat};
use ic_cdk::api::subnet_self_node_count;
use ic_cdk_management_canister::{
FlexibleHttpRequest, FlexibleHttpRequestResult, HttpRequestResult, ReplicationCounts,
};

/// One distinct answer, and how many nodes returned it.
#[derive(CandidType)]
struct Tally {
value: String,
count: u32,
}

#[derive(CandidType)]
struct TimeReport {
/// How many nodes answered with a 200.
responses: u32,
/// Each distinct answer, most common first.
tally: Vec<Tally>,
}

// #region flexible_request
#[ic_cdk::update]
async fn fetch_server_time() -> Result<TimeReport, String> {
// A committee of five, rather than every node on the subnet. Fewer requests
// cost less and are gentler on the server's rate limits. The tradeoff is
// that a smaller committee is easier for a single node to skew.
let total_requests = subnet_self_node_count().min(5);
// Return as soon as a strict majority of the committee has answered.
let min_responses = total_requests / 2 + 1;

// No transform function. A flexible outcall hands back each node's own
// response instead of one the subnet agreed on, so there is nothing to make
// identical across nodes. Leaving it off also means no cycles are reserved
// for running it.
let result = FlexibleHttpRequest::new("https://postman-echo.com/time/now")
.with_max_response_bytes(1_000)
.with_replication(ReplicationCounts {
total_requests,
min_responses,
max_responses: total_requests,
})
.with_expected_roundtrip_time_ms(10_000)
.send()
.await
.map_err(|err| format!("Outcall failed: {err}"))?;

match result {
// Any count between min_responses and max_responses is a normal success,
// not a degraded one.
FlexibleHttpRequestResult::Ok(responses) => Ok(reconcile(&responses)),
// global_error says why the replication could not be met: a timeout,
// out_of_cycles, responses_too_large, or too_many_rejects. node_details
// reports what the individual nodes did.
FlexibleHttpRequestResult::Err(err) => Err(format!(
"Fewer than {min_responses} responses: {:?}, {}",
err.global_error, err.message
)),
}
}
// #endregion flexible_request

// #region reconcile
// Reconciling the responses is the canister's job. They do not say which node
// produced them and their order is not specified, so treat them as an unordered
// multiset. Each one carries its own status, because no node had to agree with
// any other. This tallies the distinct bodies and puts the most common first.
fn reconcile(responses: &[HttpRequestResult]) -> TimeReport {
let mut tally: Vec<Tally> = Vec::new();
let mut counted = 0;

for response in responses.iter().filter(|r| r.status == Nat::from(200u32)) {
counted += 1;
let value = String::from_utf8_lossy(&response.body).to_string();
match tally.iter_mut().find(|entry| entry.value == value) {
Some(entry) => entry.count += 1,
None => tally.push(Tally { value, count: 1 }),
}
}

tally.sort_by(|a, b| b.count.cmp(&a.count));
TimeReport {
responses: counted,
tally,
}
}
// #endregion reconcile

ic_cdk::export_candid!();
4 changes: 4 additions & 0 deletions rust/send_http_flexible/icp.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
canisters:
- name: backend
recipe:
type: "@dfinity/rust@v3.3.0"
10 changes: 10 additions & 0 deletions rust/send_http_flexible/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -e

echo "=== Test 1: fetch_server_time collects responses from the committee ==="
result=$(icp canister call backend fetch_server_time '()')
echo "$result"
echo "$result" | grep -q "Ok" && echo "PASS" || (echo "FAIL" && exit 1)

echo "=== Test 2: the reconciled tally carries a date from the server ==="
echo "$result" | grep -qE "GMT" && echo "PASS" || (echo "FAIL" && exit 1)
2 changes: 1 addition & 1 deletion rust/send_http_get/backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ crate-type = ["cdylib"]
[dependencies]
candid = "0.10"
ic-cdk = "0.20"
ic-cdk-management-canister = "0.1.1"
ic-cdk-management-canister = "0.2"
45 changes: 22 additions & 23 deletions rust/send_http_get/backend/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
use ic_cdk_management_canister::{
http_request, transform_context_from_query, HttpHeader, HttpMethod, HttpRequestArgs,
HttpRequestResult, TransformArgs,
transform_context_from_query, HttpMethod, HttpRequest, HttpRequestResult, TransformArgs,
};

// #region transform
// Strip HTTP response headers (date, cookies, tracking IDs) that vary across replicas.
// In replicated mode, all replicas must see an identical response for consensus to
// succeed — the transform ensures this by discarding non-deterministic fields.
// succeed. The transform ensures this by discarding non-deterministic fields.
#[ic_cdk::query(hidden = true)]
fn transform(raw: TransformArgs) -> HttpRequestResult {
HttpRequestResult {
Expand All @@ -19,27 +18,27 @@ fn transform(raw: TransformArgs) -> HttpRequestResult {
// #region get_request
#[ic_cdk::update]
async fn send_http_get_request() -> String {
let request = HttpRequestArgs {
url: "https://postman-echo.com/get?greeting=hello-from-icp".to_string(),
method: HttpMethod::GET,
// Always set max_response_bytes to a tight bound. The cycle cost scales
// with this value, not the actual response size. If omitted, the system
// assumes 2MB. Unused cycles are refunded, but you still pay for the
// declared maximum.
max_response_bytes: Some(3_000),
headers: vec![HttpHeader {
name: "User-Agent".to_string(),
value: "ic-canister".to_string(),
}],
body: None,
transform: Some(transform_context_from_query("transform".to_string(), vec![])),
// Replicated mode: all subnet nodes make the request independently,
// providing strong integrity guarantees via consensus.
is_replicated: Some(true),
};
// Replicated mode is the builder's default: all subnet nodes make the request
// independently, providing strong integrity guarantees via consensus.
let request = HttpRequest::new("https://postman-echo.com/get?greeting=hello-from-icp")
.with_method(HttpMethod::GET)
// max_response_bytes bounds the response. A larger one fails the call.
// Under pay-as-you-go pricing it no longer sets the price, because only
// the bytes that actually arrive are charged.
.with_max_response_bytes(3_000)
.with_header("User-Agent", "ic-canister")
.with_transform(transform_context_from_query("transform".to_string(), vec![]))
// Unset expectations are reserved at their worst case: a 60 second round
// trip, and a transform running to the full query instruction limit.
// Declaring what this call expects holds far fewer cycles while it runs.
// The charge is unchanged, because only the resources actually used are
// billed. Leave headroom, since the reservation doubles as each node's
// budget for the call.
.with_expected_roundtrip_time_ms(10_000)
.with_expected_transform_instructions(1_000_000);

// http_request auto-calculates and attaches the required cycles
match http_request(&request).await {
// send() computes the cycles the call may need and attaches them.
match request.send().await {
// postman-echo.com echoes back the request metadata as JSON, letting you
// verify the query params and headers were sent correctly.
Ok(response) => String::from_utf8(response.body).unwrap_or_default(),
Expand Down
2 changes: 1 addition & 1 deletion rust/send_http_post/backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ crate-type = ["cdylib"]
[dependencies]
candid = "0.10"
ic-cdk = "0.20"
ic-cdk-management-canister = "0.1.1"
ic-cdk-management-canister = "0.2"
Loading
Loading