From 94a4bb2c058f3d8063b57f5d9f502fc010716ff5 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 4 Feb 2026 09:32:19 +0100 Subject: [PATCH 1/6] DEFI-2566: prevent HTTPs outcalls when stopping --- canhttp/src/client/mod.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/canhttp/src/client/mod.rs b/canhttp/src/client/mod.rs index aeb14c3..bbbfd29 100644 --- a/canhttp/src/client/mod.rs +++ b/canhttp/src/client/mod.rs @@ -24,15 +24,17 @@ use tower::{BoxError, Service, ServiceBuilder}; /// * [`crate::observability`]: add logging or metrics. /// * [`crate::http`]: use types from the [http](https://crates.io/crates/http) crate for requests and responses. /// * [`crate::retry::DoubleMaxResponseBytes`]: automatically retry failed requests due to the response being too big. -#[derive(Clone, Debug)] -pub struct Client; +#[derive(Clone, Debug, Default)] +pub struct Client { + http_request_when_stopping: bool, +} impl Client { /// Create a new client returning custom errors. pub fn new_with_error>() -> ConvertError { ServiceBuilder::new() .convert_error::() - .service(Client) + .service(Client::default()) } /// Creates a new client where the error type is erased. @@ -71,6 +73,14 @@ impl Service for Client { type Future = Pin>>>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + if !self.http_request_when_stopping + && ic_cdk::api::canister_status() != ic_cdk::api::CanisterStatusCode::Running + { + return Poll::Ready(Err(IcError::CallRejected { + code: RejectCode::CanisterError, + message: "Canister is stopping".to_string(), + })); + } Poll::Ready(Ok(())) } From 9afd0602104298654200faedcb5f2fc635972543 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 4 Feb 2026 10:38:18 +0100 Subject: [PATCH 2/6] DEFI-2566: implement it as layer --- canhttp/src/client/mod.rs | 52 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/canhttp/src/client/mod.rs b/canhttp/src/client/mod.rs index bbbfd29..76a223e 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 @@ -245,3 +246,54 @@ impl HttpsOutcallError for BoxError { false } } + +/// TODO +#[derive(Clone, Debug, Default)] +pub struct CanisterReadyLayer; + +impl Layer for CanisterReadyLayer { + type Service = CanisterReadyService; + + fn layer(&self, inner: S) -> Self::Service { + Self::Service { inner } + } +} + +/// TODO +pub struct CanisterReadyService { + inner: S, +} + +/// TODO +#[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) + } +} From d3db54cd0320646fadcf94fd6abbc1a95db557fe Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 4 Feb 2026 10:38:29 +0100 Subject: [PATCH 3/6] Revert "DEFI-2566: prevent HTTPs outcalls when stopping" This reverts commit 94a4bb2c058f3d8063b57f5d9f502fc010716ff5. --- canhttp/src/client/mod.rs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/canhttp/src/client/mod.rs b/canhttp/src/client/mod.rs index 76a223e..873171b 100644 --- a/canhttp/src/client/mod.rs +++ b/canhttp/src/client/mod.rs @@ -25,17 +25,15 @@ use tower_layer::Layer; /// * [`crate::observability`]: add logging or metrics. /// * [`crate::http`]: use types from the [http](https://crates.io/crates/http) crate for requests and responses. /// * [`crate::retry::DoubleMaxResponseBytes`]: automatically retry failed requests due to the response being too big. -#[derive(Clone, Debug, Default)] -pub struct Client { - http_request_when_stopping: bool, -} +#[derive(Clone, Debug)] +pub struct Client; impl Client { /// Create a new client returning custom errors. pub fn new_with_error>() -> ConvertError { ServiceBuilder::new() .convert_error::() - .service(Client::default()) + .service(Client) } /// Creates a new client where the error type is erased. @@ -74,14 +72,6 @@ impl Service for Client { type Future = Pin>>>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { - if !self.http_request_when_stopping - && ic_cdk::api::canister_status() != ic_cdk::api::CanisterStatusCode::Running - { - return Poll::Ready(Err(IcError::CallRejected { - code: RejectCode::CanisterError, - message: "Canister is stopping".to_string(), - })); - } Poll::Ready(Ok(())) } From 36131e433d807e14fdd60df1431d9b64b37a3413 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 4 Feb 2026 10:39:00 +0100 Subject: [PATCH 4/6] DEFI-2566: test --- canhttp/src/lib.rs | 5 +- examples/http_canister/src/main.rs | 45 +++++++++++++----- examples/http_canister/tests/tests.rs | 67 +++++++++++++++++++++++++++ test_fixtures/src/lib.rs | 2 +- 4 files changed, 104 insertions(+), 15 deletions(-) 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..05480fc 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,24 @@ pub async fn make_http_post_request() -> String { String::from_utf8_lossy(response.body()).to_string() } +/// Make an HTTP POST request. +#[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 +66,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..0ea94dc 100644 --- a/examples/http_canister/tests/tests.rs +++ b/examples/http_canister/tests/tests.rs @@ -12,3 +12,70 @@ 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() { + 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; + + 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, From 8265798d94bd48fd879209b67d579a03fc30d894 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 4 Feb 2026 10:48:42 +0100 Subject: [PATCH 5/6] DEFI-2566: docs --- canhttp/src/client/mod.rs | 10 +++++++--- examples/http_canister/src/main.rs | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/canhttp/src/client/mod.rs b/canhttp/src/client/mod.rs index 873171b..ac28ef5 100644 --- a/canhttp/src/client/mod.rs +++ b/canhttp/src/client/mod.rs @@ -237,7 +237,7 @@ impl HttpsOutcallError for BoxError { } } -/// TODO +/// A [`tower::Layer`] that wraps services in a [`CanisterReadyService`] middleware. #[derive(Clone, Debug, Default)] pub struct CanisterReadyLayer; @@ -249,12 +249,16 @@ impl Layer for CanisterReadyLayer { } } -/// TODO +/// 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, } -/// TODO +/// Error returned by the [`CanisterReadyService`]. #[derive(Error, Clone, Debug, Eq, PartialEq)] pub enum CanisterReadyError { /// Canister is not running and has the given status code. diff --git a/examples/http_canister/src/main.rs b/examples/http_canister/src/main.rs index 05480fc..e5e3434 100644 --- a/examples/http_canister/src/main.rs +++ b/examples/http_canister/src/main.rs @@ -26,7 +26,8 @@ pub async fn make_http_post_request() -> String { String::from_utf8_lossy(response.body()).to_string() } -/// Make an HTTP POST request. +/// 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() From 06f006712fbcb3c2a68ffe81e42721bba313b1ef Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 4 Feb 2026 11:33:21 +0100 Subject: [PATCH 6/6] DEFI-2566: move imports --- examples/http_canister/tests/tests.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/examples/http_canister/tests/tests.rs b/examples/http_canister/tests/tests.rs index 0ea94dc..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] @@ -15,14 +22,6 @@ async fn should_make_http_post_request() { #[test] fn should_not_make_http_request_when_stopping() { - 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; - let env = PocketIc::new(); let canister_id = env.create_canister_with_settings( None,