diff --git a/CHANGELOG.md b/CHANGELOG.md index 6115ad07..e8f82bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +* feat: An instance of the `Agent` can now dispatch subsequent requests to URLs, which are generated dynamically, thanks to the new `RouteProvider` trait, which is added to the `HyperTransport` and `ReqwestTransport`. Also a simple `RoundRobinRouteProvider` implementation of the `RouteProvider` trait is provided. This provider generates routing URLs from an input list in a simple, fair and predictable way. * Added `read_subnet_state_raw` to `Agent` and `read_subnet_state` to `Transport` for looking up raw state by subnet ID instead of canister ID. * Added `read_state_subnet_metrics` to `Agent` to access subnet metrics, such as total spent cycles. * Types passed to the `to_request_id` function can now contain nested structs, signed integers, and externally tagged enums. diff --git a/ic-agent/src/agent/agent_error.rs b/ic-agent/src/agent/agent_error.rs index e02959d4..8f50c32a 100644 --- a/ic-agent/src/agent/agent_error.rs +++ b/ic-agent/src/agent/agent_error.rs @@ -163,6 +163,10 @@ pub enum AgentError { /// The rejected call had an invalid reject code (valid range 1..5). #[error(transparent)] InvalidRejectCode(#[from] InvalidRejectCodeError), + + /// Route provider failed to generate a url for some reason. + #[error("Route provider failed to generate url: {0}")] + RouteProviderError(String), } impl PartialEq for AgentError { diff --git a/ic-agent/src/agent/http_transport/hyper_transport.rs b/ic-agent/src/agent/http_transport/hyper_transport.rs index 05721806..e215f9fb 100644 --- a/ic-agent/src/agent/http_transport/hyper_transport.rs +++ b/ic-agent/src/agent/http_transport/hyper_transport.rs @@ -3,21 +3,17 @@ pub use hyper; use std::{any, error::Error, future::Future, marker::PhantomData, sync::atomic::AtomicPtr}; -use http::uri::{Authority, PathAndQuery}; use http_body::{LengthLimitError, Limited}; use hyper::{ - body::{Bytes, HttpBody}, - client::HttpConnector, - header::CONTENT_TYPE, - service::Service, - Client, Method, Request, Response, Uri, + body::HttpBody, client::HttpConnector, header::CONTENT_TYPE, service::Service, Client, Method, + Request, Response, }; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; use crate::{ agent::{ agent_error::HttpErrorPayload, - http_transport::{IC0_DOMAIN, IC0_SUB_DOMAIN}, + http_transport::route_provider::{RoundRobinRouteProvider, RouteProvider}, AgentFuture, Transport, }, export::Principal, @@ -28,7 +24,7 @@ use crate::{ #[derive(Debug)] pub struct HyperTransport, B1>> { _marker: PhantomData>, - url: Uri, + route_provider: Box, max_response_body_size: Option, service: S, } @@ -87,7 +83,7 @@ where impl HyperTransport { /// Creates a replica transport from a HTTP URL. - pub fn create>(url: U) -> Result { + pub fn create>(url: U) -> Result { let connector = HttpsConnectorBuilder::new() .with_webpki_roots() .https_or_http() @@ -104,49 +100,19 @@ where S: HyperService, { /// Creates a replica transport from a HTTP URL and a [`HyperService`]. - pub fn create_with_service>(url: U, service: S) -> Result { - // Parse the url - let url = url.into(); - let mut parts = url.clone().into_parts(); - parts.authority = parts - .authority - .map(|v| { - let host = v.host(); - let host = match host.len().checked_sub(IC0_SUB_DOMAIN.len()) { - None => host, - Some(start) if host[start..].eq_ignore_ascii_case(IC0_SUB_DOMAIN) => IC0_DOMAIN, - Some(_) => host, - }; - let port = v.port(); - let (colon, port) = match port.as_ref() { - Some(v) => (":", v.as_str()), - None => ("", ""), - }; - Authority::from_maybe_shared(Bytes::from(format!("{host}{colon}{port}"))) - }) - .transpose() - .map_err(|_| AgentError::InvalidReplicaUrl(format!("{url}")))?; - parts.path_and_query = Some( - parts - .path_and_query - .map_or(Ok(PathAndQuery::from_static("/api/v2")), |v| { - let mut found = false; - fn replace(a: T, b: &mut T) -> T { - std::mem::replace(b, a) - } - let v = v - .path() - .trim_end_matches(|c| !replace(found || c == '/', &mut found)); - PathAndQuery::from_maybe_shared(Bytes::from(format!("{v}/api/v2"))) - }) - .map_err(|_| AgentError::InvalidReplicaUrl(format!("{url}")))?, - ); - let url = - Uri::from_parts(parts).map_err(|_| AgentError::InvalidReplicaUrl(format!("{url}")))?; + pub fn create_with_service>(url: U, service: S) -> Result { + let route_provider = Box::new(RoundRobinRouteProvider::new(vec![url.into()])?); + Self::create_with_service_route(route_provider, service) + } + /// Creates a replica transport from a [`RouteProvider`] and a [`HyperService`]. + pub fn create_with_service_route( + route_provider: Box, + service: S, + ) -> Result { Ok(Self { _marker: PhantomData, - url, + route_provider, service, max_response_body_size: None, }) @@ -243,7 +209,10 @@ where _request_id: RequestId, ) -> AgentFuture<()> { Box::pin(async move { - let url = format!("{}/canister/{effective_canister_id}/call", self.url); + let url = format!( + "{}/canister/{effective_canister_id}/call", + self.route_provider.route()? + ); self.request(Method::POST, url, Some(envelope)).await?; Ok(()) }) @@ -255,28 +224,37 @@ where envelope: Vec, ) -> AgentFuture> { Box::pin(async move { - let url = format!("{}/canister/{effective_canister_id}/read_state", self.url); + let url = format!( + "{}/canister/{effective_canister_id}/read_state", + self.route_provider.route()? + ); self.request(Method::POST, url, Some(envelope)).await }) } fn read_subnet_state(&self, subnet_id: Principal, envelope: Vec) -> AgentFuture> { Box::pin(async move { - let url = format!("{}/subnet/{subnet_id}/read_state", self.url); + let url = format!( + "{}/subnet/{subnet_id}/read_state", + self.route_provider.route()? + ); self.request(Method::POST, url, Some(envelope)).await }) } fn query(&self, effective_canister_id: Principal, envelope: Vec) -> AgentFuture> { Box::pin(async move { - let url = format!("{}/canister/{effective_canister_id}/query", self.url); + let url = format!( + "{}/canister/{effective_canister_id}/query", + self.route_provider.route()? + ); self.request(Method::POST, url, Some(envelope)).await }) } fn status(&self) -> AgentFuture> { Box::pin(async move { - let url = format!("{}/status", self.url); + let url = format!("{}/status", self.route_provider.route()?); self.request(Method::GET, url, None).await }) } @@ -285,32 +263,38 @@ where #[cfg(test)] mod test { use super::HyperTransport; - use hyper::{Client, Uri}; + use hyper::Client; + use url::Url; #[test] fn redirect() { fn test(base: &str, result: &str) { let client: Client<_> = Client::builder().build_http(); - let uri: Uri = base.parse().unwrap(); - let t = HyperTransport::create_with_service(uri, client).unwrap(); - assert_eq!(t.url, result, "{}", base); + let url: Url = base.parse().unwrap(); + let t = HyperTransport::create_with_service(url, client).unwrap(); + assert_eq!( + t.route_provider.route().unwrap().as_str(), + result, + "{}", + base + ); } - test("https://ic0.app", "https://ic0.app/api/v2"); - test("https://IC0.app", "https://ic0.app/api/v2"); - test("https://foo.ic0.app", "https://ic0.app/api/v2"); - test("https://foo.IC0.app", "https://ic0.app/api/v2"); - test("https://foo.Ic0.app", "https://ic0.app/api/v2"); - test("https://foo.iC0.app", "https://ic0.app/api/v2"); - test("https://foo.bar.ic0.app", "https://ic0.app/api/v2"); - test("https://ic0.app/foo/", "https://ic0.app/foo/api/v2"); - test("https://foo.ic0.app/foo/", "https://ic0.app/foo/api/v2"); + test("https://ic0.app", "https://ic0.app/api/v2/"); + test("https://IC0.app", "https://ic0.app/api/v2/"); + test("https://foo.ic0.app", "https://ic0.app/api/v2/"); + test("https://foo.IC0.app", "https://ic0.app/api/v2/"); + test("https://foo.Ic0.app", "https://ic0.app/api/v2/"); + test("https://foo.iC0.app", "https://ic0.app/api/v2/"); + test("https://foo.bar.ic0.app", "https://ic0.app/api/v2/"); + test("https://ic0.app/foo/", "https://ic0.app/foo/api/v2/"); + test("https://foo.ic0.app/foo/", "https://ic0.app/foo/api/v2/"); - test("https://ic1.app", "https://ic1.app/api/v2"); - test("https://foo.ic1.app", "https://foo.ic1.app/api/v2"); - test("https://ic0.app.ic1.app", "https://ic0.app.ic1.app/api/v2"); + test("https://ic1.app", "https://ic1.app/api/v2/"); + test("https://foo.ic1.app", "https://foo.ic1.app/api/v2/"); + test("https://ic0.app.ic1.app", "https://ic0.app.ic1.app/api/v2/"); - test("https://fooic0.app", "https://fooic0.app/api/v2"); - test("https://fooic0.app.ic0.app", "https://ic0.app/api/v2"); + test("https://fooic0.app", "https://fooic0.app/api/v2/"); + test("https://fooic0.app.ic0.app", "https://ic0.app/api/v2/"); } } diff --git a/ic-agent/src/agent/http_transport/mod.rs b/ic-agent/src/agent/http_transport/mod.rs index 33694cc8..00c3cf0d 100644 --- a/ic-agent/src/agent/http_transport/mod.rs +++ b/ic-agent/src/agent/http_transport/mod.rs @@ -25,4 +25,17 @@ pub use hyper_transport::*; // remove after 0.25 #[allow(dead_code)] const IC0_DOMAIN: &str = "ic0.app"; #[allow(dead_code)] +const ICP0_DOMAIN: &str = "icp0.io"; +#[allow(dead_code)] +const ICP_API_DOMAIN: &str = "icp-api.io"; +#[allow(dead_code)] +const LOCALHOST_DOMAIN: &str = "localhost"; +#[allow(dead_code)] const IC0_SUB_DOMAIN: &str = ".ic0.app"; +#[allow(dead_code)] +const ICP0_SUB_DOMAIN: &str = ".icp0.io"; +#[allow(dead_code)] +const ICP_API_SUB_DOMAIN: &str = ".icp-api.io"; +#[allow(dead_code)] +const LOCALHOST_SUB_DOMAIN: &str = ".localhost"; +pub mod route_provider; diff --git a/ic-agent/src/agent/http_transport/reqwest_transport.rs b/ic-agent/src/agent/http_transport/reqwest_transport.rs index e0cbbd2f..54d1fb15 100644 --- a/ic-agent/src/agent/http_transport/reqwest_transport.rs +++ b/ic-agent/src/agent/http_transport/reqwest_transport.rs @@ -7,13 +7,13 @@ pub use reqwest; use futures_util::StreamExt; use reqwest::{ header::{HeaderMap, CONTENT_TYPE}, - Body, Client, Method, Request, StatusCode, Url, + Body, Client, Method, Request, StatusCode, }; use crate::{ agent::{ agent_error::HttpErrorPayload, - http_transport::{IC0_DOMAIN, IC0_SUB_DOMAIN}, + http_transport::route_provider::{RoundRobinRouteProvider, RouteProvider}, AgentFuture, Transport, }, export::Principal, @@ -23,7 +23,7 @@ use crate::{ /// A [`Transport`] using [`reqwest`] to make HTTP calls to the Internet Computer. #[derive(Debug)] pub struct ReqwestTransport { - url: Url, + route_provider: Box, client: Client, max_response_body_size: Option, } @@ -52,19 +52,17 @@ impl ReqwestTransport { /// Creates a replica transport from a HTTP URL and a [`reqwest::Client`]. pub fn create_with_client>(url: U, client: Client) -> Result { - let url = url.into(); + let route_provider = Box::new(RoundRobinRouteProvider::new(vec![url.into()])?); + Self::create_with_client_route(route_provider, client) + } + + /// Creates a replica transport from a [`RouteProvider`] and a [`reqwest::Client`]. + pub fn create_with_client_route( + route_provider: Box, + client: Client, + ) -> Result { Ok(Self { - url: Url::parse(&url) - .and_then(|mut url| { - // rewrite *.ic0.app to ic0.app - if let Some(domain) = url.domain() { - if domain.ends_with(IC0_SUB_DOMAIN) { - url.set_host(Some(IC0_DOMAIN))?; - } - } - url.join("api/v2/") - }) - .map_err(|_| AgentError::InvalidReplicaUrl(url.clone()))?, + route_provider, client, max_response_body_size: None, }) @@ -127,7 +125,7 @@ impl ReqwestTransport { endpoint: &str, body: Option>, ) -> Result, AgentError> { - let url = self.url.join(endpoint)?; + let url = self.route_provider.route()?.join(endpoint)?; let mut http_request = Request::new(method, url); http_request .headers_mut() @@ -226,7 +224,12 @@ mod test { fn redirect() { fn test(base: &str, result: &str) { let t = ReqwestTransport::create(base).unwrap(); - assert_eq!(t.url.as_str(), result, "{}", base); + assert_eq!( + t.route_provider.route().unwrap().as_str(), + result, + "{}", + base + ); } test("https://ic0.app", "https://ic0.app/api/v2/"); @@ -238,6 +241,10 @@ mod test { test("https://foo.bar.ic0.app", "https://ic0.app/api/v2/"); test("https://ic0.app/foo/", "https://ic0.app/foo/api/v2/"); test("https://foo.ic0.app/foo/", "https://ic0.app/foo/api/v2/"); + test( + "https://ryjl3-tyaaa-aaaaa-aaaba-cai.ic0.app", + "https://ic0.app/api/v2/", + ); test("https://ic1.app", "https://ic1.app/api/v2/"); test("https://foo.ic1.app", "https://foo.ic1.app/api/v2/"); @@ -245,5 +252,25 @@ mod test { test("https://fooic0.app", "https://fooic0.app/api/v2/"); test("https://fooic0.app.ic0.app", "https://ic0.app/api/v2/"); + + test("https://icp0.io", "https://icp0.io/api/v2/"); + test( + "https://ryjl3-tyaaa-aaaaa-aaaba-cai.icp0.io", + "https://icp0.io/api/v2/", + ); + test("https://ic0.app.icp0.io", "https://icp0.io/api/v2/"); + + test("https://icp-api.io", "https://icp-api.io/api/v2/"); + test( + "https://ryjl3-tyaaa-aaaaa-aaaba-cai.icp-api.io", + "https://icp-api.io/api/v2/", + ); + test("https://icp0.io.icp-api.io", "https://icp-api.io/api/v2/"); + + test("http://localhost:4943", "http://localhost:4943/api/v2/"); + test( + "http://ryjl3-tyaaa-aaaaa-aaaba-cai.localhost:4943", + "http://localhost:4943/api/v2/", + ); } } diff --git a/ic-agent/src/agent/http_transport/route_provider.rs b/ic-agent/src/agent/http_transport/route_provider.rs new file mode 100644 index 00000000..3200e691 --- /dev/null +++ b/ic-agent/src/agent/http_transport/route_provider.rs @@ -0,0 +1,105 @@ +//! A [`RouteProvider`] for dynamic generation of routing urls. +use std::{ + str::FromStr, + sync::atomic::{AtomicUsize, Ordering}, +}; +use url::Url; + +use crate::agent::{ + http_transport::{ + IC0_DOMAIN, IC0_SUB_DOMAIN, ICP0_DOMAIN, ICP0_SUB_DOMAIN, ICP_API_DOMAIN, + ICP_API_SUB_DOMAIN, LOCALHOST_DOMAIN, LOCALHOST_SUB_DOMAIN, + }, + AgentError, +}; + +/// A [`RouteProvider`] for dynamic generation of routing urls. +pub trait RouteProvider: std::fmt::Debug + Send + Sync { + /// Generate next routing url + fn route(&self) -> Result; +} + +/// A simple implementation of the [`RouteProvider`] which produces an even distribution of the urls from the input ones. +#[derive(Debug)] +pub struct RoundRobinRouteProvider { + routes: Vec, + current_idx: AtomicUsize, +} + +impl RouteProvider for RoundRobinRouteProvider { + fn route(&self) -> Result { + if self.routes.is_empty() { + return Err(AgentError::RouteProviderError( + "No routing urls provided".to_string(), + )); + } + // This operation wraps around an overflow, i.e. after max is reached the value is reset back to 0. + let prev_idx = self.current_idx.fetch_add(1, Ordering::Relaxed); + Ok(self.routes[prev_idx % self.routes.len()].clone()) + } +} + +impl RoundRobinRouteProvider { + /// Construct [`RoundRobinRouteProvider`] from a vector of urls. + pub fn new>(routes: Vec) -> Result { + let routes: Result, _> = routes + .into_iter() + .map(|url| { + Url::from_str(url.as_ref()).and_then(|mut url| { + // rewrite *.ic0.app to ic0.app + if let Some(domain) = url.domain() { + if domain.ends_with(IC0_SUB_DOMAIN) { + url.set_host(Some(IC0_DOMAIN))? + } else if domain.ends_with(ICP0_SUB_DOMAIN) { + url.set_host(Some(ICP0_DOMAIN))? + } else if domain.ends_with(ICP_API_SUB_DOMAIN) { + url.set_host(Some(ICP_API_DOMAIN))? + } else if domain.ends_with(LOCALHOST_SUB_DOMAIN) { + url.set_host(Some(LOCALHOST_DOMAIN))?; + } + } + url.join("api/v2/") + }) + }) + .collect(); + Ok(Self { + routes: routes?, + current_idx: AtomicUsize::new(0), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_routes() { + let provider = RoundRobinRouteProvider::new::<&str>(vec![]) + .expect("failed to create a route provider"); + let result = provider.route().unwrap_err(); + assert_eq!( + result, + AgentError::RouteProviderError("No routing urls provided".to_string()) + ); + } + + #[test] + fn test_routes_rotation() { + let provider = RoundRobinRouteProvider::new(vec!["https://url1.com", "https://url2.com"]) + .expect("failed to create a route provider"); + let url_strings = vec![ + "https://url1.com/api/v2/", + "https://url2.com/api/v2/", + "https://url1.com/api/v2/", + ]; + let expected_urls: Vec = url_strings + .iter() + .map(|url_str| Url::parse(url_str).expect("Invalid URL")) + .collect(); + let urls: Vec = (0..3) + .map(|_| provider.route().expect("failed to get next url")) + .collect(); + assert_eq!(expected_urls, urls); + } +}