use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use bytes::Bytes;
use futures_util::{FutureExt as _, StreamExt};
use thiserror::Error;
use super::Clock;
use crate::ports::timeout;
use crate::stream::BoxStream;
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;
pub const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
pub const MAX_STREAM_IDLE_TIMEOUT: Duration = Duration::from_mins(5);
pub const DEFAULT_STREAM_TOTAL_TIMEOUT: Duration = Duration::from_mins(10);
pub const MAX_STREAM_TOTAL_TIMEOUT: Duration = Duration::from_mins(30);
pub const MAX_STREAM_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StreamPolicy {
pub idle_timeout: Duration,
pub total_timeout: Duration,
pub max_bytes: usize,
}
impl Default for StreamPolicy {
fn default() -> Self {
Self {
idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
total_timeout: DEFAULT_STREAM_TOTAL_TIMEOUT,
max_bytes: MAX_STREAM_BYTES,
}
}
}
impl StreamPolicy {
#[must_use]
pub fn of_request<T>(request: &http::Request<T>) -> Self {
request
.extensions()
.get::<StreamPolicy>()
.copied()
.unwrap_or_default()
.clamped()
}
#[must_use]
pub fn clamped(self) -> Self {
Self {
idle_timeout: self.idle_timeout.min(MAX_STREAM_IDLE_TIMEOUT),
total_timeout: self.total_timeout.min(MAX_STREAM_TOTAL_TIMEOUT),
max_bytes: self.max_bytes.min(MAX_STREAM_BYTES),
}
}
}
pub type ByteStream = BoxStream<'static, Result<Bytes, HttpError>>;
#[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),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StatusOnly;
#[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),
#[error("outbound budget exhausted for {what}: retry after {retry_after:?}")]
BudgetExhausted {
what: String,
retry_after: Duration,
},
}
#[async_trait]
pub trait HttpClient: Send + Sync {
async fn send(&self, request: http::Request<Bytes>)
-> Result<http::Response<Bytes>, HttpError>;
async fn send_streaming(
&self,
request: http::Request<Bytes>,
) -> Result<http::Response<ByteStream>, HttpError> {
let (parts, body) = self.send(request).await?.into_parts();
let body: ByteStream = Box::pin(futures_util::stream::once(async move { Ok(body) }));
Ok(http::Response::from_parts(parts, body))
}
}
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)
}
async fn send_streaming(
&self,
request: http::Request<Bytes>,
) -> Result<http::Response<ByteStream>, HttpError> {
let policy = HttpPolicy::of_request(&request);
let stream_policy = StreamPolicy::of_request(&request);
let inner = Arc::clone(&self.inner);
let Some(outcome) = timeout(
self.clock.as_ref(),
async move { inner.send_streaming(request).await },
policy.timeout,
)
.await
else {
return Err(HttpError::DeadlineExceeded {
after: policy.timeout,
});
};
let (parts, body) = outcome?.into_parts();
if declared_content_length(&parts.headers).is_some_and(|len| len > stream_policy.max_bytes)
{
return Err(HttpError::ResponseTooLarge {
limit: stream_policy.max_bytes,
});
}
let total =
time::Duration::try_from(stream_policy.total_timeout).unwrap_or(time::Duration::MAX);
let deadline = self.clock.now().saturating_add(total);
let body: ByteStream = Box::pin(futures_util::stream::unfold(
BoundedBody {
stream: Some(body),
clock: Arc::clone(&self.clock),
deadline,
idle_timeout: stream_policy.idle_timeout,
total_timeout: stream_policy.total_timeout,
max_bytes: stream_policy.max_bytes,
delivered: 0,
done: false,
},
|mut state| async move {
if state.done {
return None;
}
let mut stream = state.stream.take()?;
let remaining: Duration = (state.deadline - state.clock.now())
.try_into()
.unwrap_or(Duration::ZERO);
if remaining.is_zero() {
state.done = true;
let expired = HttpError::DeadlineExceeded {
after: state.total_timeout,
};
return match stream.next().now_or_never() {
Some(None) => None,
Some(Some(_)) | None => Some((Err(expired), state)),
};
}
let budget = state.idle_timeout.min(remaining);
let Some((stream, item)) = timeout(
state.clock.as_ref(),
async move {
let item = stream.next().await;
(stream, item)
},
budget,
)
.await
else {
state.done = true;
let after = if state.idle_timeout <= remaining {
state.idle_timeout
} else {
state.total_timeout
};
return Some((Err(HttpError::DeadlineExceeded { after }), state));
};
match item {
None => None,
Some(Err(err)) => {
state.done = true;
Some((Err(err), state))
}
Some(Ok(chunk)) => {
if state.delivered.saturating_add(chunk.len()) > state.max_bytes {
state.done = true;
return Some((
Err(HttpError::ResponseTooLarge {
limit: state.max_bytes,
}),
state,
));
}
state.delivered += chunk.len();
state.stream = Some(stream);
Some((Ok(chunk), state))
}
}
},
));
Ok(http::Response::from_parts(parts, body))
}
}
struct BoundedBody {
stream: Option<ByteStream>,
clock: Arc<dyn Clock>,
deadline: time::OffsetDateTime,
idle_timeout: Duration,
total_timeout: Duration,
max_bytes: usize,
delivered: usize,
done: bool,
}
#[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}"
);
}
#[allow(clippy::duration_suboptimal_units)]
#[test]
fn a_stream_policy_clamps_to_the_port_ceilings() {
let loose = StreamPolicy {
idle_timeout: Duration::from_secs(3600),
total_timeout: Duration::from_secs(86_400),
max_bytes: usize::MAX,
};
let clamped = loose.clamped();
assert_eq!(clamped.idle_timeout, MAX_STREAM_IDLE_TIMEOUT);
assert_eq!(clamped.total_timeout, MAX_STREAM_TOTAL_TIMEOUT);
assert_eq!(clamped.max_bytes, MAX_STREAM_BYTES);
let tight = StreamPolicy {
idle_timeout: Duration::from_secs(5),
total_timeout: Duration::from_secs(30),
max_bytes: 1_024,
};
assert_eq!(tight.clamped(), tight, "tightening is honoured");
let defaulted = StreamPolicy::of_request(&http::Request::new(Bytes::new()));
assert_eq!(defaulted, StreamPolicy::default());
let mut request = http::Request::new(Bytes::new());
request.extensions_mut().insert(StreamPolicy {
idle_timeout: Duration::from_secs(3600),
total_timeout: Duration::from_secs(86_400),
max_bytes: usize::MAX,
});
let resolved = StreamPolicy::of_request(&request);
assert_eq!(resolved.idle_timeout, MAX_STREAM_IDLE_TIMEOUT);
assert_eq!(resolved.total_timeout, MAX_STREAM_TOTAL_TIMEOUT);
assert_eq!(resolved.max_bytes, MAX_STREAM_BYTES);
}
struct StreamingClient {
chunks: Vec<Vec<u8>>,
content_length: Option<&'static str>,
consumed: Arc<std::sync::atomic::AtomicUsize>,
}
impl StreamingClient {
fn new(chunks: Vec<Vec<u8>>) -> Arc<Self> {
Arc::new(Self {
chunks,
content_length: None,
consumed: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
})
}
fn with_content_length(chunks: Vec<Vec<u8>>, length: &'static str) -> Arc<Self> {
Arc::new(Self {
chunks,
content_length: Some(length),
consumed: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
})
}
fn consumed(&self) -> usize {
self.consumed.load(std::sync::atomic::Ordering::Relaxed)
}
}
#[async_trait]
impl HttpClient for StreamingClient {
async fn send(&self, _request: http::Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
Ok(Response::new(Bytes::from(self.chunks.concat())))
}
async fn send_streaming(
&self,
_request: http::Request<Bytes>,
) -> Result<Response<ByteStream>, HttpError> {
let mut builder = Response::builder();
if let Some(length) = self.content_length {
builder = builder.header(http::header::CONTENT_LENGTH, length);
}
let response = builder.body(()).expect("test response head");
let consumed = Arc::clone(&self.consumed);
let chunks = self.chunks.clone();
let body: ByteStream = Box::pin(
futures_util::stream::iter(chunks.into_iter().map(|chunk| Ok(Bytes::from(chunk))))
.inspect(move |_| {
consumed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}),
);
Ok(response.map(move |()| body))
}
}
struct HeadThenAbandon(std::sync::atomic::AtomicUsize);
#[async_trait]
impl Clock for HeadThenAbandon {
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>> {
if self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 0 {
Some(fut.await)
} else {
drop(fut);
None
}
}
}
struct AdvancingClock {
step_secs: i64,
ticks: std::sync::atomic::AtomicUsize,
}
impl AdvancingClock {
fn new(step_secs: i64) -> Arc<Self> {
Arc::new(Self {
step_secs,
ticks: std::sync::atomic::AtomicUsize::new(0),
})
}
}
impl Clock for AdvancingClock {
fn now(&self) -> time::OffsetDateTime {
let tick = i64::try_from(
self.ticks
.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
)
.unwrap_or(i64::MAX);
time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(tick * self.step_secs)
}
}
fn streaming_request(policy: Option<StreamPolicy>) -> http::Request<Bytes> {
let mut request = http::Request::new(Bytes::new());
if let Some(policy) = policy {
request.extensions_mut().insert(policy);
}
request
}
async fn drain(mut body: crate::ports::http::ByteStream) -> Vec<Result<Bytes, HttpError>> {
let mut items = Vec::new();
while let Some(item) = body.next().await {
items.push(item);
}
items
}
#[pollster::test]
async fn the_default_send_streaming_buffers_into_one_chunk() {
let inner = ScriptedClient {
response: Ok(Response::new(Bytes::from_static(b"buffered whole"))),
};
let client = BoundedHttpClient::new(Arc::new(inner), Arc::new(NoTimerClock));
let response = client
.send_streaming(http::Request::new(Bytes::new()))
.await
.expect("buffered fallback streams");
let mut items = drain(response.into_body()).await;
assert_eq!(items.len(), 1, "one round trip, one chunk: {items:?}");
assert_eq!(items.remove(0).unwrap(), &b"buffered whole"[..]);
}
#[pollster::test]
async fn a_streaming_head_abandoned_by_the_clock_is_a_deadline() {
let inner = StreamingClient::new(vec![b"a".to_vec(), b"b".to_vec()]);
let err = match BoundedHttpClient::new(inner.clone(), Arc::new(AlwaysTimeoutClock))
.send_streaming(http::Request::new(Bytes::new()))
.await
{
Ok(response) => {
drop(response);
panic!("an abandoned head must surface as the deadline");
}
Err(err) => err,
};
assert!(
matches!(err, HttpError::DeadlineExceeded { after } if after == DEFAULT_RESPONSE_TIMEOUT),
"got {err}"
);
assert_eq!(inner.consumed(), 0, "no body was ever handed out");
}
#[pollster::test]
async fn a_declared_length_over_the_stream_cap_is_refused_before_a_chunk() {
let inner = StreamingClient::with_content_length(vec![b"tiny".to_vec()], "9000");
let mut request = streaming_request(None);
request.extensions_mut().insert(StreamPolicy {
idle_timeout: Duration::from_secs(5),
total_timeout: Duration::from_secs(60),
max_bytes: 1_024,
});
let err = match BoundedHttpClient::new(inner.clone(), Arc::new(NoTimerClock))
.send_streaming(request)
.await
{
Ok(response) => {
drop(response);
panic!("a declared 9 KB over a 1 KiB stream cap must be refused");
}
Err(err) => err,
};
assert!(
matches!(err, HttpError::ResponseTooLarge { limit: 1_024 }),
"got {err}"
);
assert_eq!(inner.consumed(), 0, "the body was never touched");
}
#[pollster::test]
async fn an_idle_gap_that_goes_quiet_abandons_the_body() {
let inner = StreamingClient::new(vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]);
let client = BoundedHttpClient::new(
inner.clone(),
Arc::new(HeadThenAbandon(std::sync::atomic::AtomicUsize::new(0))),
);
let response = client
.send_streaming(http::Request::new(Bytes::new()))
.await
.expect("the head answers within the buffered deadline");
let items = drain(response.into_body()).await;
assert_eq!(
items.len(),
1,
"one terminal error, then the end: {items:?}"
);
assert!(
matches!(items[0], Err(HttpError::DeadlineExceeded { after }) if after == DEFAULT_STREAM_IDLE_TIMEOUT),
"the idle bound is what expired: {:?}",
items[0]
);
assert_eq!(inner.consumed(), 0, "the abandoned poll dropped the body");
}
#[pollster::test]
async fn a_total_ceiling_ends_a_steady_stream() {
let chunks: Vec<Vec<u8>> = (0..40).map(|i| i.to_string().into_bytes()).collect();
let inner = StreamingClient::new(chunks);
let client = BoundedHttpClient::new(inner, AdvancingClock::new(30));
let response = client
.send_streaming(http::Request::new(Bytes::new()))
.await
.expect("the head answers");
let items = drain(response.into_body()).await;
assert_eq!(items.len(), 20, "19 chunks and one terminal error");
for (index, item) in items.iter().enumerate().take(19) {
let chunk = item.as_ref().expect("every steady chunk arrives");
assert_eq!(&**chunk, index.to_string().as_bytes(), "chunk {index}");
}
assert!(
matches!(items[19], Err(HttpError::DeadlineExceeded { after }) if after == DEFAULT_STREAM_TOTAL_TIMEOUT),
"the whole-body ceiling is what expired: {:?}",
items[19]
);
}
#[pollster::test]
async fn a_body_that_ended_inside_its_own_time_ends_cleanly_at_the_ceiling() {
let inner = StreamingClient::new(vec![b"only".to_vec()]);
let mut request = streaming_request(None);
request.extensions_mut().insert(StreamPolicy {
idle_timeout: Duration::from_secs(60),
total_timeout: Duration::from_secs(60),
max_bytes: MAX_STREAM_BYTES,
});
let client = BoundedHttpClient::new(inner, AdvancingClock::new(30));
let response = client
.send_streaming(request)
.await
.expect("the head answers");
let items = drain(response.into_body()).await;
assert_eq!(
items.len(),
1,
"the chunk, then a clean end — no error after the data: {items:?}"
);
assert_eq!(&items[0].as_ref().expect("the chunk")[..], b"only");
}
#[pollster::test]
async fn a_chunk_already_in_hand_when_the_ceiling_expires_is_refused_not_delivered() {
let inner = StreamingClient::new(vec![b"first".to_vec(), b"second".to_vec()]);
let mut request = streaming_request(None);
request.extensions_mut().insert(StreamPolicy {
idle_timeout: Duration::from_secs(60),
total_timeout: Duration::from_secs(60),
max_bytes: MAX_STREAM_BYTES,
});
let client = BoundedHttpClient::new(inner, AdvancingClock::new(30));
let response = client
.send_streaming(request)
.await
.expect("the head answers");
let items = drain(response.into_body()).await;
assert_eq!(
items.len(),
2,
"the delivered chunk, the refusal: {items:?}"
);
assert_eq!(&items[0].as_ref().expect("the first chunk")[..], b"first");
assert!(
matches!(items[1], Err(HttpError::DeadlineExceeded { after }) if after == Duration::from_secs(60)),
"the whole-body ceiling is what expired: {:?}",
items[1]
);
}
#[pollster::test]
async fn the_error_names_the_whole_body_ceiling_when_that_is_what_expired() {
let inner = StreamingClient::new(vec![b"a".to_vec(), b"b".to_vec()]);
let mut request = streaming_request(None);
request.extensions_mut().insert(StreamPolicy {
idle_timeout: MAX_STREAM_IDLE_TIMEOUT,
total_timeout: Duration::from_secs(60),
max_bytes: MAX_STREAM_BYTES,
});
let client = BoundedHttpClient::new(
inner,
Arc::new(HeadThenAbandon(std::sync::atomic::AtomicUsize::new(0))),
);
let response = client
.send_streaming(request)
.await
.expect("the head answers");
let items = drain(response.into_body()).await;
assert_eq!(
items.len(),
1,
"one terminal error, then the end: {items:?}"
);
assert!(
matches!(items[0], Err(HttpError::DeadlineExceeded { after }) if after == Duration::from_secs(60)),
"the ceiling is what expired, not the idle gap: {:?}",
items[0]
);
}
#[pollster::test]
async fn a_steady_stream_outlives_the_buffered_deadline() {
let chunks: Vec<Vec<u8>> = (0..40).map(|i| i.to_string().into_bytes()).collect();
let inner = StreamingClient::new(chunks);
let mut request = streaming_request(None);
request.extensions_mut().insert(StreamPolicy {
idle_timeout: Duration::from_secs(60),
total_timeout: MAX_STREAM_TOTAL_TIMEOUT,
max_bytes: MAX_STREAM_BYTES,
});
let client = BoundedHttpClient::new(inner, AdvancingClock::new(31));
let response = client
.send_streaming(request)
.await
.expect("the head answers");
let items = drain(response.into_body()).await;
assert_eq!(
items.len(),
40,
"forty chunks under a half-hour ceiling, then a clean end"
);
assert!(items.iter().all(Result::is_ok), "no bound fired: {items:?}");
assert_eq!(
items
.iter()
.map(|item| item.as_ref().unwrap().len())
.sum::<usize>(),
10 + 60,
);
}
#[pollster::test]
async fn a_chunk_over_the_stream_byte_cap_is_refused_not_delivered() {
let inner = StreamingClient::new(vec![
b"12345".to_vec(),
b"67890".to_vec(),
b"unreached".to_vec(),
]);
let mut request = streaming_request(None);
request.extensions_mut().insert(StreamPolicy {
idle_timeout: Duration::from_secs(5),
total_timeout: Duration::from_secs(60),
max_bytes: 8,
});
let client = BoundedHttpClient::new(inner, Arc::new(NoTimerClock));
let response = client
.send_streaming(request)
.await
.expect("the head answers");
let items = drain(response.into_body()).await;
assert_eq!(items.len(), 2, "the fit chunk, the refusal, then the end");
assert_eq!(&items[0].as_ref().unwrap()[..], &b"12345"[..]);
assert!(
matches!(items[1], Err(HttpError::ResponseTooLarge { limit: 8 })),
"the crossing chunk is refused: {:?}",
items[1]
);
}
}