Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 113 additions & 17 deletions canhttp/src/client/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
#[cfg(test)]
mod tests;

use crate::convert::ConvertError;
use crate::ConvertServiceBuilder;
use ic_cdk::api::call::RejectionCode;
use ic_cdk::api::management_canister::http_request::{
CanisterHttpRequestArgument as IcHttpRequest, HttpResponse as IcHttpResponse,
CanisterHttpRequestArgument as IcHttpRequest, HttpResponse as IcHttpResponse, TransformContext,
};
use std::future::Future;
use std::pin::Pin;
Expand All @@ -16,21 +21,20 @@ use tower::{BoxError, Service, ServiceBuilder};
/// * [`crate::cycles::CyclesAccounting`]: handles cycles accounting.
/// * [`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;

impl Client {
/// Create a new client returning custom errors.
pub fn new_with_error<CustomError: From<IcError>>(
) -> impl Service<IcHttpRequestWithCycles, Response = IcHttpResponse, Error = CustomError> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some subtle issues here at play, which is the reason why I needed yet another type of conversion to somewhat emulate map_err but with a concrete type:

  1. The main issue is that in order to be able to use tower::retry, the service needs to be Clone.
  2. The current return type, because its an impl Something cannot be Clone.
  3. To be clone, we would need to return the exact type, which we cannot do because map_err only takes a closure (FnOnce) and unfortunately no concrete type can implement the FnOnce trait (see Rust issue 29625).
  4. We could try to use BoxService or BoxCloneService on Client to do type erasure but this also won't work because it requires the future type of the service to be Send + 'static which cannot be derived because the future of Client is not exposed by the ic_cdk so we have to resort to using <Box<dyn Future>>

All of that to say feel free to chime in if you have better ideas 🙈 .

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't say I have a better idea... Maybe @ninegua ?

pub fn new_with_error<CustomError: From<IcError>>() -> ConvertError<Client, CustomError> {
ServiceBuilder::new()
.map_err(CustomError::from)
.convert_error::<CustomError>()
.service(Client)
}

/// Creates a new client where error type is erased.
pub fn new_with_box_error(
) -> impl Service<IcHttpRequestWithCycles, Response = IcHttpResponse, Error = BoxError> {
pub fn new_with_box_error() -> ConvertError<Client, BoxError> {
Self::new_with_error::<BoxError>()
}
}
Expand All @@ -45,17 +49,6 @@ pub struct IcError {
pub message: String,
}

impl IcError {
/// Determines whether the error indicates that the response was larger than the specified
/// [`max_response_bytes`](https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-http_request) specified in the request.
///
/// If true, retrying with a larger value for `max_response_bytes` may help.
pub fn is_response_too_large(&self) -> bool {
self.code == RejectionCode::SysFatal
&& (self.message.contains("size limit") || self.message.contains("length limit"))
}
}

impl Service<IcHttpRequestWithCycles> for Client {
type Response = IcHttpResponse;
type Error = IcError;
Expand Down Expand Up @@ -88,3 +81,106 @@ pub struct IcHttpRequestWithCycles {
/// Number of cycles to attach.
pub cycles: u128,
}

/// Add support for max response bytes.
pub trait MaxResponseBytesRequestExtension: Sized {
Comment thread
gregorydemay marked this conversation as resolved.
/// Set the max response bytes.
///
/// If provided, the value must not exceed 2MB (2_000_000B).
/// The call will be charged based on this parameter.
/// If not provided, the maximum of 2MB will be used.
fn set_max_response_bytes(&mut self, value: u64);

/// Retrieves the current max response bytes value, if any.
fn get_max_response_bytes(&self) -> Option<u64>;

/// Convenience method to use the builder pattern.
fn max_response_bytes(mut self, value: u64) -> Self {
self.set_max_response_bytes(value);
self
}
}

impl MaxResponseBytesRequestExtension for IcHttpRequest {
fn set_max_response_bytes(&mut self, value: u64) {
self.max_response_bytes = Some(value);
}

fn get_max_response_bytes(&self) -> Option<u64> {
self.max_response_bytes
}
}

impl MaxResponseBytesRequestExtension for IcHttpRequestWithCycles {
fn set_max_response_bytes(&mut self, value: u64) {
self.request.set_max_response_bytes(value);
}

fn get_max_response_bytes(&self) -> Option<u64> {
self.request.get_max_response_bytes()
}
}

/// Add support for transform context to specify how the response will be canonicalized by the replica
/// to maximize chances of consensus.
///
/// See the [docs](https://internetcomputer.org/docs/references/https-outcalls-how-it-works#transformation-function)
/// on HTTPs outcalls for more details.
pub trait TransformContextRequestExtension: Sized {
Comment thread
gregorydemay marked this conversation as resolved.
/// Set the transform context.
fn set_transform_context(&mut self, value: TransformContext);

/// Retrieve the current transform context, if any.
fn get_transform_context(&self) -> Option<&TransformContext>;

/// Convenience method to use the builder pattern.
fn transform_context(mut self, value: TransformContext) -> Self {
self.set_transform_context(value);
self
}
}

impl TransformContextRequestExtension for IcHttpRequest {
fn set_transform_context(&mut self, value: TransformContext) {
self.transform = Some(value);
}

fn get_transform_context(&self) -> Option<&TransformContext> {
self.transform.as_ref()
}
}

impl TransformContextRequestExtension for IcHttpRequestWithCycles {
fn set_transform_context(&mut self, value: TransformContext) {
self.request.set_transform_context(value);
}

fn get_transform_context(&self) -> Option<&TransformContext> {
self.request.get_transform_context()
}
}

/// Characterize errors that are specific to HTTPs outcalls.
pub trait HttpsOutcallError {
/// Determines whether the error indicates that the response was larger than the specified
/// [`max_response_bytes`](https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-http_request) specified in the request.
///
/// If true, retrying with a larger value for `max_response_bytes` may help.
fn is_response_too_large(&self) -> bool;
}

impl HttpsOutcallError for IcError {
fn is_response_too_large(&self) -> bool {
self.code == RejectionCode::SysFatal
&& (self.message.contains("size limit") || self.message.contains("length limit"))
}
}

impl HttpsOutcallError for BoxError {
fn is_response_too_large(&self) -> bool {
if let Some(ic_error) = self.downcast_ref::<IcError>() {
return ic_error.is_response_too_large();
}
false
}
}
43 changes: 43 additions & 0 deletions canhttp/src/client/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
use crate::retry::DoubleMaxResponseBytes;
use crate::{Client, HttpsOutcallError, IcError};
use tower::{ServiceBuilder, ServiceExt};

// Some middlewares like tower::retry need the underlying service to be cloneable.
#[test]
fn should_be_clone() {
let client = Client::new_with_box_error();
let _ = client.clone();

let client = Client::new_with_error::<CustomError>();
let _ = client.clone();
}

// Note that calling `Client::call` would require a canister environment.
Comment thread
gregorydemay marked this conversation as resolved.
// We just ensure that the trait bounds are satisfied to have a service.
#[tokio::test]
async fn should_be_able_to_use_retry_layer() {
let mut service = ServiceBuilder::new()
.retry(DoubleMaxResponseBytes)
.service(Client::new_with_error::<CustomError>());
let _ = service.ready().await.unwrap();

let mut service = ServiceBuilder::new()
.retry(DoubleMaxResponseBytes)
.service(Client::new_with_box_error());
let _ = service.ready().await.unwrap();
}

#[derive(Debug)]
struct CustomError(IcError);

impl HttpsOutcallError for CustomError {
fn is_response_too_large(&self) -> bool {
self.0.is_response_too_large()
}
}

impl From<IcError> for CustomError {
fn from(value: IcError) -> Self {
CustomError(value)
}
}
115 changes: 115 additions & 0 deletions canhttp/src/convert/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
use pin_project::pin_project;
use std::future::Future;
use std::marker::PhantomData;
use std::pin::Pin;
use std::task::{Context, Poll};
use tower::Service;
use tower_layer::Layer;

/// Convert error of a service into another type, where the conversion does *not* fail.
///
/// This [`Layer`] produces instances of the [`ConvertError`] service.
///
/// [`Layer`]: tower::Layer
#[derive(Debug)]
pub struct ConvertErrorLayer<E> {
_marker: PhantomData<E>,
}

impl<E> ConvertErrorLayer<E> {
/// Returns a new [`ConvertErrorLayer`]
pub fn new() -> Self {
Self {
_marker: PhantomData,
}
}
}

impl<E> Default for ConvertErrorLayer<E> {
fn default() -> Self {
Self::new()
}
}

impl<E> Clone for ConvertErrorLayer<E> {
fn clone(&self) -> Self {
Self {
_marker: self._marker,
}
}
}

/// Convert the inner service error to another type, where the conversion does *not* fail.
#[derive(Debug)]
pub struct ConvertError<S, E> {
inner: S,
_marker: PhantomData<E>,
}

impl<S: Clone, E> Clone for ConvertError<S, E> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
_marker: self._marker,
}
}
}

impl<S, E> Layer<S> for ConvertErrorLayer<E> {
type Service = ConvertError<S, E>;

fn layer(&self, inner: S) -> Self::Service {
Self::Service {
inner,
_marker: PhantomData,
}
}
}

impl<S, Request, Error, NewError> Service<Request> for ConvertError<S, NewError>
where
S: Service<Request, Error = Error>,
Error: Into<NewError>,
{
type Response = S::Response;
type Error = NewError;
type Future = ResponseFuture<S::Future, NewError>;

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx).map_err(Into::into)
}

fn call(&mut self, req: Request) -> Self::Future {
ResponseFuture {
response_future: self.inner.call(req),
_marker: PhantomData,
}
}
}

#[pin_project]
pub struct ResponseFuture<F, NewError> {
#[pin]
response_future: F,
_marker: PhantomData<NewError>,
}

impl<F, Response, Error, NewError> Future for ResponseFuture<F, NewError>
where
F: Future<Output = Result<Response, Error>>,
Error: Into<NewError>,
{
type Output = Result<Response, NewError>;

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
let result_fut = this.response_future.poll(cx);
match result_fut {
Poll::Ready(result) => match result {
Ok(response) => Poll::Ready(Ok(response)),
Err(e) => Poll::Ready(Err(e.into())),
},
Poll::Pending => Poll::Pending,
}
}
}
Loading
Loading