Skip to content

Commit 1a9a024

Browse files
authored
Merge pull request #1485 from dfinity/eichhorl/update-http_request-rust
feat(https-outcalls): adopt the 0.2 HttpRequest builder, add a flexible outcall example
2 parents 73899bd + 81f756a commit 1a9a024

14 files changed

Lines changed: 265 additions & 67 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: send_http_flexible
2+
3+
on:
4+
schedule:
5+
- cron: "30 6 * * 1" # weekly build to catch dependency drift (lock files are gitignored)
6+
push:
7+
branches:
8+
- master
9+
pull_request:
10+
paths:
11+
- rust/send_http_flexible/**
12+
- .github/workflows/send_http_flexible.yml
13+
- .github/workflows/_run-example.yml
14+
15+
concurrency:
16+
group: ${{ github.workflow }}-${{ github.ref }}
17+
cancel-in-progress: true
18+
19+
jobs:
20+
rust-send_http_flexible:
21+
uses: ./.github/workflows/_run-example.yml
22+
with:
23+
language: rust
24+
working-directory: rust/send_http_flexible
25+
run: |
26+
icp network start -d
27+
icp deploy
28+
bash test.sh

‎rust/daily_planner/backend/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ path = "lib.rs"
1010
[dependencies]
1111
candid = "0.10"
1212
ic-cdk = "0.20"
13-
ic-cdk-management-canister = "0.1.1"
13+
ic-cdk-management-canister = "0.2"
1414
ic-stable-structures = "0.6.5"
1515
serde = "1.0"
1616
serde_json = "1.0"

‎rust/daily_planner/backend/lib.rs‎

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
use candid::{CandidType, Decode, Deserialize, Encode, Nat};
22
use ic_cdk::api::canister_self;
33
use ic_cdk_management_canister::{
4-
http_request, HttpMethod, HttpRequestArgs, HttpRequestResult, TransformArgs, TransformContext,
5-
TransformFunc,
4+
HttpMethod, HttpRequest, HttpRequestResult, TransformArgs, TransformContext, TransformFunc,
65
};
76
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
87
use ic_stable_structures::storable::{Bound, Storable};
@@ -159,25 +158,27 @@ async fn fetch_and_store_on_this_day(date: String) -> Result<String, String> {
159158
// This is useful to e.g. filter out timestamps/sessionIDs out of headers that will be different across the responses the different replicas receive.
160159
// If the data (including status, headers and body) they receive does not match across the nodes, the canister will reject the response!
161160
// You can read more about it here: https://docs.internetcomputer.org/guides/backends/https-outcalls.
162-
let request = HttpRequestArgs {
163-
url,
164-
method: HttpMethod::GET,
165-
body: None,
166-
max_response_bytes: None, // Can be set to limit cost. Our response has no predictable size, so we set no limit.
167-
headers: vec![],
168-
transform: Some(TransformContext {
161+
// Replicated mode is the builder's default: all subnet nodes make the request
162+
// independently, providing strong integrity guarantees via consensus.
163+
//
164+
// max_response_bytes stays unset, because the response size is not known up
165+
// front. It then defaults to the 2MB ceiling, and the reservation covers
166+
// delivering that much. Only the round trip and the transform are narrowed
167+
// below. An expectation the response later exceeds would fail the call at
168+
// delivery, after the request was already made.
169+
let request = HttpRequest::new(url)
170+
.with_method(HttpMethod::GET)
171+
.with_transform(TransformContext {
169172
function: TransformFunc::new(canister_self(), "transform".to_string()),
170173
context: vec![],
171-
}),
172-
// Replicated mode: all subnet nodes make the request independently,
173-
// providing strong integrity guarantees via consensus.
174-
is_replicated: Some(true),
175-
};
174+
})
175+
.with_expected_roundtrip_time_ms(10_000)
176+
.with_expected_transform_instructions(1_000_000);
176177

177-
// Perform HTTPS outcall. Cycles are automatically calculated and attached.
178-
// Unused cycles are returned.
178+
// Perform HTTPS outcall. send() computes the cycles the call may need and
179+
// attaches them. Unused cycles are returned.
179180
// See https outcall cost calculator: https://7joko-hiaaa-aaaal-ajz7a-cai.icp.net.
180-
let quote = match http_request(&request).await {
181+
let quote = match request.send().await {
181182
Ok(response) => {
182183
let body_string =
183184
String::from_utf8(response.body).expect("Response is not UTF-8 encoded.");

‎rust/send_http_flexible/.gitignore‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Various IDEs and Editors
2+
.vscode/
3+
.idea/
4+
**/*~
5+
6+
# Mac OSX temporary files
7+
.DS_Store
8+
**/.DS_Store
9+
10+
# rust
11+
target/

‎rust/send_http_flexible/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[workspace]
2+
members = ["backend"]
3+
resolver = "2"

‎rust/send_http_flexible/README.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# HTTP: flexible outcall
2+
3+
[![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)
4+
5+
> 🥷 **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.
6+
7+
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.
8+
9+
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.
10+
11+
For a deeper understanding of HTTPS outcalls on the IC, see the [HTTPS outcalls documentation](https://docs.internetcomputer.org/concepts/https-outcalls/).
12+
13+
## Build and deploy from the command line
14+
15+
### Prerequisites
16+
- [Node.js](https://nodejs.org/) v18+
17+
- [icp-cli](https://cli.internetcomputer.org/): `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`
18+
- [Rust](https://www.rust-lang.org/tools/install) with `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`
19+
20+
### Install
21+
22+
```bash
23+
git clone https://github.com/dfinity/examples
24+
cd examples/rust/send_http_flexible
25+
```
26+
27+
### Deploy and test
28+
29+
```bash
30+
icp network start -d
31+
icp deploy
32+
bash test.sh
33+
icp network stop
34+
```
35+
36+
## Security considerations and best practices
37+
38+
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.
39+
40+
Refer to the [security best practices](https://docs.internetcomputer.org/guides/security/overview) for information on security and best practices for your ICP app.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[package]
2+
name = "backend"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[lib]
7+
crate-type = ["cdylib"]
8+
9+
[dependencies]
10+
candid = "0.10"
11+
ic-cdk = "0.20"
12+
ic-cdk-management-canister = "0.2"
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
use candid::{CandidType, Nat};
2+
use ic_cdk::api::subnet_self_node_count;
3+
use ic_cdk_management_canister::{
4+
FlexibleHttpRequest, FlexibleHttpRequestResult, HttpRequestResult, ReplicationCounts,
5+
};
6+
7+
/// One distinct answer, and how many nodes returned it.
8+
#[derive(CandidType)]
9+
struct Tally {
10+
value: String,
11+
count: u32,
12+
}
13+
14+
#[derive(CandidType)]
15+
struct TimeReport {
16+
/// How many nodes answered with a 200.
17+
responses: u32,
18+
/// Each distinct answer, most common first.
19+
tally: Vec<Tally>,
20+
}
21+
22+
// #region flexible_request
23+
#[ic_cdk::update]
24+
async fn fetch_server_time() -> Result<TimeReport, String> {
25+
// A committee of five, rather than every node on the subnet. Fewer requests
26+
// cost less and are gentler on the server's rate limits. The tradeoff is
27+
// that a smaller committee is easier for a single node to skew.
28+
let total_requests = subnet_self_node_count().min(5);
29+
// Return as soon as a strict majority of the committee has answered.
30+
let min_responses = total_requests / 2 + 1;
31+
32+
// No transform function. A flexible outcall hands back each node's own
33+
// response instead of one the subnet agreed on, so there is nothing to make
34+
// identical across nodes. Leaving it off also means no cycles are reserved
35+
// for running it.
36+
let result = FlexibleHttpRequest::new("https://postman-echo.com/time/now")
37+
.with_max_response_bytes(1_000)
38+
.with_replication(ReplicationCounts {
39+
total_requests,
40+
min_responses,
41+
max_responses: total_requests,
42+
})
43+
.with_expected_roundtrip_time_ms(10_000)
44+
.send()
45+
.await
46+
.map_err(|err| format!("Outcall failed: {err}"))?;
47+
48+
match result {
49+
// Any count between min_responses and max_responses is a normal success,
50+
// not a degraded one.
51+
FlexibleHttpRequestResult::Ok(responses) => Ok(reconcile(&responses)),
52+
// global_error says why the replication could not be met: a timeout,
53+
// out_of_cycles, responses_too_large, or too_many_rejects. node_details
54+
// reports what the individual nodes did.
55+
FlexibleHttpRequestResult::Err(err) => Err(format!(
56+
"Fewer than {min_responses} responses: {:?}, {}",
57+
err.global_error, err.message
58+
)),
59+
}
60+
}
61+
// #endregion flexible_request
62+
63+
// #region reconcile
64+
// Reconciling the responses is the canister's job. They do not say which node
65+
// produced them and their order is not specified, so treat them as an unordered
66+
// multiset. Each one carries its own status, because no node had to agree with
67+
// any other. This tallies the distinct bodies and puts the most common first.
68+
fn reconcile(responses: &[HttpRequestResult]) -> TimeReport {
69+
let mut tally: Vec<Tally> = Vec::new();
70+
let mut counted = 0;
71+
72+
for response in responses.iter().filter(|r| r.status == Nat::from(200u32)) {
73+
counted += 1;
74+
let value = String::from_utf8_lossy(&response.body).to_string();
75+
match tally.iter_mut().find(|entry| entry.value == value) {
76+
Some(entry) => entry.count += 1,
77+
None => tally.push(Tally { value, count: 1 }),
78+
}
79+
}
80+
81+
tally.sort_by(|a, b| b.count.cmp(&a.count));
82+
TimeReport {
83+
responses: counted,
84+
tally,
85+
}
86+
}
87+
// #endregion reconcile
88+
89+
ic_cdk::export_candid!();

‎rust/send_http_flexible/icp.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
canisters:
2+
- name: backend
3+
recipe:
4+
type: "@dfinity/rust@v3.3.0"

‎rust/send_http_flexible/test.sh‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bash
2+
set -e
3+
4+
echo "=== Test 1: fetch_server_time collects responses from the committee ==="
5+
result=$(icp canister call backend fetch_server_time '()')
6+
echo "$result"
7+
echo "$result" | grep -q "Ok" && echo "PASS" || (echo "FAIL" && exit 1)
8+
9+
echo "=== Test 2: the reconciled tally carries a date from the server ==="
10+
echo "$result" | grep -qE "GMT" && echo "PASS" || (echo "FAIL" && exit 1)

0 commit comments

Comments
 (0)