use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use bytes::Bytes;
use thiserror::Error;
use super::Clock;
use crate::ports::timeout;
pub const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
pub const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
pub const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
pub const MAX_CONCURRENT_REQUESTS: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HttpPolicy {
pub max_response_bytes: usize,
pub timeout: Duration,
}
impl Default for HttpPolicy {
fn default() -> Self {
Self {
max_response_bytes: MAX_RESPONSE_BYTES,
timeout: DEFAULT_RESPONSE_TIMEOUT,
}
}
}
impl HttpPolicy {
#[must_use]
pub fn of_request<T>(request: &http::Request<T>) -> Self {
request
.extensions()
.get::<HttpPolicy>()
.copied()
.unwrap_or_default()
.clamped()
}
#[must_use]
pub fn clamped(self) -> Self {
Self {
max_response_bytes: self.max_response_bytes.min(MAX_RESPONSE_BYTES),
timeout: self.timeout.min(MAX_RESPONSE_TIMEOUT),
}
}
}
#[must_use]
pub fn declared_content_length(headers: &http::HeaderMap) -> Option<usize> {
headers
.get(http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok())
}
#[derive(Debug, Clone, Error)]
pub enum HttpError {
#[error("http request failed: {0}")]
Transport(String),
#[error("upstream response exceeds the {limit}-byte bound")]
ResponseTooLarge {
limit: usize,
},
#[error("upstream deadline of {after:?} exceeded")]
DeadlineExceeded {
after: Duration,
},
#[error("destination refused by outbound policy: {0}")]
BlockedDestination(String),
}
#[async_trait]
pub trait HttpClient: Send + Sync {
async fn send(&self, request: http::Request<Bytes>)
-> Result<http::Response<Bytes>, HttpError>;
}
pub struct BoundedHttpClient {
inner: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
}
impl BoundedHttpClient {
#[must_use]
pub fn new(inner: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
Self { inner, clock }
}
}
#[async_trait]
impl HttpClient for BoundedHttpClient {
async fn send(
&self,
request: http::Request<Bytes>,
) -> Result<http::Response<Bytes>, HttpError> {
let policy = HttpPolicy::of_request(&request);
let inner = Arc::clone(&self.inner);
let Some(outcome) = timeout(
self.clock.as_ref(),
async move { inner.send(request).await },
policy.timeout,
)
.await
else {
return Err(HttpError::DeadlineExceeded {
after: policy.timeout,
});
};
let response = outcome?;
if declared_content_length(response.headers())
.is_some_and(|len| len > policy.max_response_bytes)
{
return Err(HttpError::ResponseTooLarge {
limit: policy.max_response_bytes,
});
}
if response.body().len() > policy.max_response_bytes {
return Err(HttpError::ResponseTooLarge {
limit: policy.max_response_bytes,
});
}
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::Response;
#[test]
fn a_policy_clamps_to_the_port_ceilings() {
let loose = HttpPolicy {
max_response_bytes: usize::MAX,
timeout: Duration::from_secs(3600),
};
let clamped = loose.clamped();
assert_eq!(clamped.max_response_bytes, MAX_RESPONSE_BYTES);
assert_eq!(clamped.timeout, MAX_RESPONSE_TIMEOUT);
let tight = HttpPolicy {
max_response_bytes: 1024,
timeout: Duration::from_millis(50),
};
assert_eq!(tight.clamped(), tight, "tightening is honoured");
let defaulted = HttpPolicy::of_request(&http::Request::new(Bytes::new()));
assert_eq!(defaulted, HttpPolicy::default());
let mut request = http::Request::new(Bytes::new());
request.extensions_mut().insert(HttpPolicy {
max_response_bytes: usize::MAX,
timeout: Duration::from_secs(3600),
});
let resolved = HttpPolicy::of_request(&request);
assert_eq!(resolved.max_response_bytes, MAX_RESPONSE_BYTES);
assert_eq!(resolved.timeout, MAX_RESPONSE_TIMEOUT);
}
struct ScriptedClient {
response: Result<Response<Bytes>, HttpError>,
}
#[async_trait]
impl HttpClient for ScriptedClient {
async fn send(&self, _request: http::Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
self.response.clone()
}
}
struct NoTimerClock;
#[async_trait]
impl Clock for NoTimerClock {
fn now(&self) -> time::OffsetDateTime {
time::OffsetDateTime::UNIX_EPOCH
}
}
struct AlwaysTimeoutClock;
#[async_trait]
impl Clock for AlwaysTimeoutClock {
fn now(&self) -> time::OffsetDateTime {
time::OffsetDateTime::UNIX_EPOCH
}
async fn timeout_any(
&self,
fut: futures_core::future::BoxFuture<'static, Box<dyn std::any::Any + Send>>,
_after: Duration,
) -> Option<Box<dyn std::any::Any + Send>> {
drop(fut);
None
}
}
fn bounded(inner: ScriptedClient, clock: Arc<dyn Clock>) -> BoundedHttpClient {
BoundedHttpClient::new(Arc::new(inner), clock)
}
#[pollster::test]
async fn an_oversized_response_body_is_refused() {
let inner = ScriptedClient {
response: Ok(Response::new(Bytes::from(vec![0_u8; 4_096]))),
};
let mut request = http::Request::new(Bytes::new());
request.extensions_mut().insert(HttpPolicy {
max_response_bytes: 1_024,
timeout: Duration::from_secs(5),
});
let err = bounded(inner, Arc::new(NoTimerClock))
.send(request)
.await
.expect_err("4 KiB over a 1 KiB cap must be refused");
assert!(
matches!(err, HttpError::ResponseTooLarge { limit: 1_024 }),
"got {err}"
);
}
#[pollster::test]
async fn an_oversized_declared_length_is_refused_before_the_body_counts() {
let inner = ScriptedClient {
response: Ok(Response::builder()
.header(http::header::CONTENT_LENGTH, "9000")
.body(Bytes::from_static(b"hi"))
.expect("test response")),
};
let mut request = http::Request::new(Bytes::new());
request.extensions_mut().insert(HttpPolicy {
max_response_bytes: 1_024,
timeout: Duration::from_secs(5),
});
let err = bounded(inner, Arc::new(NoTimerClock))
.send(request)
.await
.expect_err("a declared 9 KB over a 1 KiB cap must be refused");
assert!(
matches!(err, HttpError::ResponseTooLarge { .. }),
"got {err}"
);
}
#[pollster::test]
async fn an_abandoned_send_is_a_deadline_not_a_hang() {
let inner = ScriptedClient {
response: Ok(Response::new(Bytes::from_static(b"late"))),
};
let err = bounded(inner, Arc::new(AlwaysTimeoutClock))
.send(http::Request::new(Bytes::new()))
.await
.expect_err("an abandoned send must surface as the deadline");
assert!(
matches!(&err, HttpError::DeadlineExceeded { after } if *after == DEFAULT_RESPONSE_TIMEOUT),
"got {err}"
);
}
}