use std::pin::Pin;
use bytes::Bytes;
use futures::{Stream, StreamExt, stream};
use kithara_platform::{
CancelToken,
time::{Duration, sleep, timeout},
tokio,
};
use num_traits::{AsPrimitive, ToPrimitive};
mod kithara {
pub(crate) use kithara_test_macros::flash;
}
use crate::{
ByteStream,
error::{NetError, Retryability},
types::RetryPolicy,
};
#[cfg(not(target_arch = "wasm32"))]
type RawBody = Pin<Box<dyn Stream<Item = Result<Bytes, NetError>> + Send>>;
#[cfg(target_arch = "wasm32")]
type RawBody = Pin<Box<dyn Stream<Item = Result<Bytes, NetError>>>>;
pub(crate) struct Resumed {
pub(crate) stream: ByteStream,
pub(crate) skip: u64,
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) type Refetch = Box<
dyn Fn(u64) -> futures::future::BoxFuture<'static, Result<Resumed, NetError>> + Send + Sync,
>;
#[cfg(target_arch = "wasm32")]
pub(crate) type Refetch =
Box<dyn Fn(u64) -> futures::future::LocalBoxFuture<'static, Result<Resumed, NetError>>>;
struct State {
inner: ByteStream,
cancel: CancelToken,
expected_len: Option<u64>,
stall: Duration,
refetch: Refetch,
policy: RetryPolicy,
resumes: u32,
consumed: u64,
to_skip: u64,
}
impl State {
async fn resume(&mut self, cause: NetError) -> Result<(), NetError> {
if cause.retryability() == Retryability::Fatal {
return Err(cause);
}
if self.resumes >= self.policy.max_retries {
return Err(exhausted(self.policy.max_retries, cause));
}
let delay = self.policy.delay_for_attempt(self.resumes);
self.resumes += 1;
if !delay.is_zero() {
sleep(delay).await;
}
let resumed = (self.refetch)(self.consumed).await?;
self.inner = resumed.stream;
self.to_skip = resumed.skip;
Ok(())
}
fn take(&mut self, mut bytes: Bytes) -> Option<Bytes> {
let len: u64 = bytes.len().as_();
let skip = self.to_skip.min(len);
self.to_skip -= skip;
self.consumed = self.consumed.saturating_add(len - skip);
let rest = bytes.split_off(skip.to_usize().unwrap_or(bytes.len()));
(!rest.is_empty()).then_some(rest)
}
fn body_complete(&self) -> bool {
self.expected_len
.is_some_and(|expected| self.consumed >= expected)
}
}
#[kithara::flash(io)]
async fn next_chunk(st: &mut State) -> Option<Result<Bytes, NetError>> {
if st.body_complete() {
return None;
}
tokio::select! {
biased;
res = timeout(st.stall, st.inner.next()) => {
match res {
Ok(None) if st.expected_len.is_some() && !st.body_complete() => {
Some(Err(NetError::Network("HTTP body ended before content-length".to_string())))
}
Ok(item) => item,
Err(_) => Some(Err(NetError::Timeout)),
}
},
() = st.cancel.cancelled() => {
if st.body_complete() {
None
} else {
Some(Err(NetError::Cancelled))
}
},
}
}
pub(crate) fn resumable_body(
first: ByteStream,
refetch: Refetch,
stall: Duration,
policy: RetryPolicy,
cancel: CancelToken,
) -> RawBody {
let expected_len = content_length(&first);
let state = State {
refetch,
stall,
policy,
cancel,
inner: first,
expected_len,
consumed: 0,
to_skip: 0,
resumes: 0,
};
Box::pin(stream::unfold(Some(state), |st| async move {
let mut st = st?;
loop {
let cause = match next_chunk(&mut st).await {
Some(Ok(bytes)) => match st.take(bytes) {
Some(out) => return Some((Ok(out), Some(st))),
None => continue,
},
None => return None,
Some(Err(cause)) => cause,
};
if let Err(terminal) = st.resume(cause).await {
return Some((Err(terminal), None));
}
}
}))
}
pub(crate) fn exhausted(max_retries: u32, source: NetError) -> NetError {
NetError::RetryExhausted {
max_retries,
source: Box::new(source),
}
}
fn content_length(stream: &ByteStream) -> Option<u64> {
stream
.headers
.get("content-length")
.or_else(|| stream.headers.get("Content-Length"))
.and_then(|value| value.parse().ok())
}
#[cfg(test)]
mod tests {
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use kithara_test_utils::kithara;
use super::*;
use crate::types::Headers;
const STALL: Duration = Duration::from_millis(40);
fn policy(max_retries: u32) -> RetryPolicy {
RetryPolicy::builder()
.max_retries(max_retries)
.base_delay(Duration::from_millis(1))
.max_delay(Duration::from_millis(5))
.build()
}
fn byte_stream(chunks: Vec<Result<Bytes, NetError>>) -> ByteStream {
ByteStream::new(Headers::default(), Box::pin(stream::iter(chunks)))
}
fn byte_stream_with_len(len: u64, chunks: Vec<Result<Bytes, NetError>>) -> ByteStream {
let mut headers = Headers::default();
headers.insert("content-length", len.to_string());
ByteStream::new(headers, Box::pin(stream::iter(chunks)))
}
fn withheld() -> ByteStream {
ByteStream::new(Headers::default(), Box::pin(stream::pending()))
}
async fn collect(body: RawBody) -> Result<Vec<u8>, NetError> {
let mut out = Vec::new();
let mut body = body;
while let Some(item) = body.next().await {
out.extend_from_slice(item?.as_ref());
}
Ok(out)
}
fn resumed(stream: ByteStream, skip: u64) -> Resumed {
Resumed { stream, skip }
}
#[kithara::test(tokio, timeout(Duration::from_secs(10)))]
async fn withheld_body_exhausts_bounded() {
let refetch: Refetch = Box::new(|_off| Box::pin(async { Ok(resumed(withheld(), 0)) }));
let started = Instant::now();
let result = collect(resumable_body(
withheld(),
refetch,
STALL,
policy(2),
CancelToken::never(),
))
.await;
let elapsed = started.elapsed();
assert!(
matches!(result, Err(NetError::RetryExhausted { .. })),
"withheld body must terminate with RetryExhausted, got {result:?}"
);
assert!(
elapsed < Duration::from_secs(5),
"must be bounded ({elapsed:?})"
);
}
#[kithara::test(tokio, timeout(Duration::from_secs(10)))]
async fn live_body_passes_through() {
let refetch: Refetch =
Box::new(|_off| Box::pin(async { Ok(resumed(byte_stream(vec![]), 0)) }));
let body = byte_stream(vec![
Ok(Bytes::from_static(b"abc")),
Ok(Bytes::from_static(b"def")),
]);
let out = collect(resumable_body(
body,
refetch,
STALL,
policy(2),
CancelToken::never(),
))
.await
.expect("live body must pass through");
assert_eq!(out, b"abcdef");
}
#[kithara::test(tokio, timeout(Duration::from_secs(10)))]
async fn resumes_from_consumed_offset() {
let resume_off = Arc::new(AtomicU64::new(u64::MAX));
let seen = Arc::clone(&resume_off);
let refetch: Refetch = Box::new(move |off| {
seen.store(off, Ordering::SeqCst);
Box::pin(async move {
Ok(resumed(
byte_stream(vec![Ok(Bytes::from_static(b"def"))]),
0,
))
})
});
let first = ByteStream::new(
Headers::default(),
Box::pin(
stream::once(async { Ok(Bytes::from_static(b"abc")) }).chain(stream::pending()),
),
);
let out = collect(resumable_body(
first,
refetch,
STALL,
policy(2),
CancelToken::never(),
))
.await
.expect("resume must complete the body");
assert_eq!(out, b"abcdef");
assert_eq!(
resume_off.load(Ordering::SeqCst),
3,
"resume from consumed offset"
);
}
#[kithara::test(tokio, timeout(Duration::from_secs(10)))]
async fn early_eof_before_content_length_resumes() {
let resume_off = Arc::new(AtomicU64::new(u64::MAX));
let seen = Arc::clone(&resume_off);
let refetch: Refetch = Box::new(move |off| {
seen.store(off, Ordering::SeqCst);
Box::pin(async move {
Ok(resumed(
byte_stream(vec![Ok(Bytes::from_static(b"def"))]),
0,
))
})
});
let out = collect(resumable_body(
byte_stream_with_len(6, vec![Ok(Bytes::from_static(b"abc"))]),
refetch,
STALL,
policy(2),
CancelToken::never(),
))
.await
.expect("early EOF before content-length must resume");
assert_eq!(out, b"abcdef");
assert_eq!(resume_off.load(Ordering::SeqCst), 3);
}
#[kithara::test(tokio, timeout(Duration::from_secs(10)))]
async fn non_range_server_skips_prefix_no_duplication() {
let refetch: Refetch = Box::new(|off| {
Box::pin(async move {
Ok(resumed(
byte_stream(vec![Ok(Bytes::from_static(b"abcdef"))]),
off,
))
})
});
let first = ByteStream::new(
Headers::default(),
Box::pin(
stream::once(async { Ok(Bytes::from_static(b"abc")) }).chain(stream::pending()),
),
);
let out = collect(resumable_body(
first,
refetch,
STALL,
policy(2),
CancelToken::never(),
))
.await
.expect("non-range resume must complete without duplication");
assert_eq!(out, b"abcdef", "prefix must be skipped, not duplicated");
}
}