|
| 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!(); |
0 commit comments