diff --git a/canhttp/src/client/mod.rs b/canhttp/src/client/mod.rs index aeb14c3..ac28ef5 100644 --- a/canhttp/src/client/mod.rs +++ b/canhttp/src/client/mod.rs @@ -14,6 +14,7 @@ use std::{ }; use thiserror::Error; use tower::{BoxError, Service, ServiceBuilder}; +use tower_layer::Layer; /// Thin wrapper around [`ic_cdk::management_canister::http_request`] that implements the /// [`tower::Service`] trait. Its functionality can be extended by composing so-called @@ -235,3 +236,58 @@ impl HttpsOutcallError for BoxError { false } } + +/// A [`tower::Layer`] that wraps services in a [`CanisterReadyService`] middleware. +#[derive(Clone, Debug, Default)] +pub struct CanisterReadyLayer; + +impl Layer for CanisterReadyLayer { + type Service = CanisterReadyService; + + fn layer(&self, inner: S) -> Self::Service { + Self::Service { inner } + } +} + +/// A [`tower::Service`] that checks that the canister is running before calling the inner service. +/// +/// This is useful to prevent the canister making new HTTPs outcalls when it is in the stopping state +/// (see [stop_canister](https://docs.internetcomputer.org/references/ic-interface-spec#ic-stop_canister)) +/// and ensure that the canister will be promptly stopped. +pub struct CanisterReadyService { + inner: S, +} + +/// Error returned by the [`CanisterReadyService`]. +#[derive(Error, Clone, Debug, Eq, PartialEq)] +pub enum CanisterReadyError { + /// Canister is not running and has the given status code. + #[error("Canister is not running and has status {0}")] + CanisterNotRunning(u32), +} + +impl Service for CanisterReadyService +where + S: Service, + CanisterReadyError: Into, +{ + type Response = S::Response; + type Error = S::Error; + type Future = S::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + use ic_cdk::api::CanisterStatusCode; + + match ic_cdk::api::canister_status() { + CanisterStatusCode::Running => self.inner.poll_ready(cx), + status => Poll::Ready(Err(CanisterReadyError::CanisterNotRunning(u32::from( + status, + )) + .into())), + } + } + + fn call(&mut self, req: Req) -> Self::Future { + self.inner.call(req) + } +} diff --git a/canhttp/src/lib.rs b/canhttp/src/lib.rs index 4432714..5f04820 100644 --- a/canhttp/src/lib.rs +++ b/canhttp/src/lib.rs @@ -6,8 +6,9 @@ #![forbid(missing_docs)] pub use client::{ - Client, HttpsOutcallError, IcError, IsReplicatedRequestExtension, - MaxResponseBytesRequestExtension, TransformContextRequestExtension, + CanisterReadyError, CanisterReadyLayer, CanisterReadyService, Client, HttpsOutcallError, + IcError, IsReplicatedRequestExtension, MaxResponseBytesRequestExtension, + TransformContextRequestExtension, }; pub use convert::ConvertServiceBuilder; diff --git a/examples/http_canister/src/main.rs b/examples/http_canister/src/main.rs index 9d8dbb9..e5e3434 100644 --- a/examples/http_canister/src/main.rs +++ b/examples/http_canister/src/main.rs @@ -4,25 +4,20 @@ use canhttp::{ cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, http::HttpConversionLayer, observability::ObservabilityLayer, - Client, MaxResponseBytesRequestExtension, + CanisterReadyLayer, Client, MaxResponseBytesRequestExtension, }; +use http::Request; use ic_cdk::update; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; /// Make an HTTP POST request. #[update] pub async fn make_http_post_request() -> String { - let request = http::Request::post(format!("{}/anything", httpbin_base_url())) - .max_response_bytes(1_000) - .header("X-Id", "42") - .body("Hello, World!".as_bytes().to_vec()) - .unwrap(); - let response = http_client() .ready() .await .expect("Client should be ready") - .call(request) + .call(request()) .await .expect("Request should succeed"); @@ -31,6 +26,25 @@ pub async fn make_http_post_request() -> String { String::from_utf8_lossy(response.body()).to_string() } +/// Make multiple HTTP POST requests in a loop, +/// ensuring via [`CanisterReadyLayer`] that the loop will stop if the canister is stopped. +#[update] +pub async fn infinite_loop_make_http_post_request() -> String { + let mut client = ServiceBuilder::new() + .layer(CanisterReadyLayer) + .service(http_client()); + + loop { + match client.ready().await { + Ok(ready) => { + let response = ready.call(request()).await.expect("Request should succeed"); + assert_eq!(response.status(), http::StatusCode::OK); + } + Err(e) => return format!("Not ready: {}", e), + } + } +} + fn http_client( ) -> impl Service>, Response = http::Response>, Error = BoxError> { ServiceBuilder::new() @@ -53,10 +67,18 @@ fn http_client( .service(Client::new_with_box_error()) } -fn httpbin_base_url() -> String { - option_env!("HTTPBIN_URL") - .unwrap_or_else(|| "https://httpbin.org") - .to_string() +fn request() -> Request> { + fn httpbin_base_url() -> String { + option_env!("HTTPBIN_URL") + .unwrap_or_else(|| "https://httpbin.org") + .to_string() + } + + http::Request::post(format!("{}/anything", httpbin_base_url())) + .max_response_bytes(1_000) + .header("X-Id", "42") + .body("Hello, World!".as_bytes().to_vec()) + .unwrap() } fn main() {} diff --git a/examples/http_canister/tests/tests.rs b/examples/http_canister/tests/tests.rs index ce35f3f..4f4b9ac 100644 --- a/examples/http_canister/tests/tests.rs +++ b/examples/http_canister/tests/tests.rs @@ -1,3 +1,10 @@ +use candid::{Decode, Encode, Principal}; +use ic_management_canister_types::CanisterIdRecord; +use ic_management_canister_types::CanisterSettings; +use pocket_ic::common::rest::{ + CanisterHttpReply, CanisterHttpResponse, MockCanisterHttpResponse, RawEffectivePrincipal, +}; +use pocket_ic::PocketIc; use test_fixtures::Setup; #[tokio::test] @@ -12,3 +19,62 @@ async fn should_make_http_post_request() { assert!(http_request_result.contains("Hello, World!")); assert!(http_request_result.contains("\"X-Id\": \"42\"")); } + +#[test] +fn should_not_make_http_request_when_stopping() { + let env = PocketIc::new(); + let canister_id = env.create_canister_with_settings( + None, + Some(CanisterSettings { + controllers: Some(vec![Setup::DEFAULT_CONTROLLER]), + ..CanisterSettings::default() + }), + ); + env.add_cycles(canister_id, u64::MAX as u128); + env.install_canister( + canister_id, + test_fixtures::canister_wasm("http_canister"), + Encode!().unwrap(), + Some(Setup::DEFAULT_CONTROLLER), + ); + + let http_request = env + .submit_call( + canister_id, + Principal::anonymous(), + "infinite_loop_make_http_post_request", + Encode!().unwrap(), + ) + .unwrap(); + + while env.get_canister_http().is_empty() { + env.tick(); + } + for request in env.get_canister_http() { + env.mock_canister_http_response(MockCanisterHttpResponse { + subnet_id: request.subnet_id, + request_id: request.request_id, + response: CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply { + status: 200, + headers: vec![], + body: vec![], + }), + additional_responses: vec![], + }) + } + + let _stopping = env + .submit_call_with_effective_principal( + Principal::management_canister(), + RawEffectivePrincipal::CanisterId(canister_id.as_slice().to_vec()), + Setup::DEFAULT_CONTROLLER, + "stop_canister", + Encode!(&CanisterIdRecord { canister_id }).unwrap(), + ) + .unwrap(); + + let result = Decode!(&env.await_call(http_request).unwrap(), String).unwrap(); + + assert!(result.contains("Canister is not running and has status 2")); //Stopping + assert_eq!(env.get_canister_http(), vec![]); +} diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index d0c4b9f..dbb6bc9 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -79,7 +79,7 @@ impl Canister<'_> { } } -fn canister_wasm(canister_binary_name: &str) -> Vec { +pub fn canister_wasm(canister_binary_name: &str) -> Vec { ic_test_utilities_load_wasm::load_wasm( PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join("."), canister_binary_name,