use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
use std::time::Duration;
use aws_sdk_s3::config::HttpClient;
use aws_sdk_s3::config::RuntimeComponents;
use aws_sdk_s3::config::RuntimeComponentsBuilder;
use aws_sdk_s3::config::SharedHttpClient;
use aws_smithy_http_client::ConnectorBuilder;
use aws_smithy_http_client::proxy::ProxyConfig;
use aws_smithy_http_client::tls;
use aws_smithy_runtime_api::box_error::BoxError;
use aws_smithy_runtime_api::client::connector_metadata::ConnectorMetadata;
use aws_smithy_runtime_api::client::http::HttpConnector;
use aws_smithy_runtime_api::client::http::HttpConnectorFuture;
use aws_smithy_runtime_api::client::http::HttpConnectorSettings;
use aws_smithy_runtime_api::client::http::SharedHttpConnector;
use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
use aws_smithy_runtime_api::client::result::ConnectorError;
use aws_smithy_types::body::SdkBody;
use aws_smithy_types::config_bag::ConfigBag;
use aws_types::SdkConfig;
use tokio::sync::oneshot;
pub(super) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60);
pub(super) fn s3_client(config: &SdkConfig) -> aws_sdk_s3::Client {
s3_client_with_timeout(config, RESPONSE_TIMEOUT)
}
fn s3_client_with_timeout(config: &SdkConfig, timeout: Duration) -> aws_sdk_s3::Client {
let conf = aws_sdk_s3::config::Builder::from(config)
.http_client(ResponseTimeoutClient {
inner: default_https_client(),
timeout,
})
.build();
aws_sdk_s3::Client::from_conf(conf)
}
fn default_https_client() -> SharedHttpClient {
aws_smithy_http_client::Builder::new().build_with_connector_fn(|settings, components| {
let mut builder = ConnectorBuilder::default().tls_provider(tls::Provider::Rustls(
tls::rustls_provider::CryptoMode::AwsLc,
));
builder.set_connector_settings(settings.cloned());
if let Some(components) = components {
builder.set_sleep_impl(components.sleep_impl());
}
builder.set_proxy_config(Some(ProxyConfig::from_env()));
builder.build()
})
}
#[derive(Debug)]
struct ResponseTimeoutClient {
inner: SharedHttpClient,
timeout: Duration,
}
impl HttpClient for ResponseTimeoutClient {
fn http_connector(
&self,
settings: &HttpConnectorSettings,
components: &RuntimeComponents,
) -> SharedHttpConnector {
SharedHttpConnector::new(ResponseTimeoutConnector {
inner: self.inner.http_connector(settings, components),
timeout: self.timeout,
})
}
fn validate_base_client_config(
&self,
runtime_components: &RuntimeComponentsBuilder,
cfg: &ConfigBag,
) -> Result<(), BoxError> {
self.inner
.validate_base_client_config(runtime_components, cfg)
}
fn connector_metadata(&self) -> Option<ConnectorMetadata> {
self.inner.connector_metadata()
}
}
#[derive(Debug)]
struct ResponseTimeoutConnector {
inner: SharedHttpConnector,
timeout: Duration,
}
impl HttpConnector for ResponseTimeoutConnector {
fn call(&self, mut request: HttpRequest) -> HttpConnectorFuture {
let (sent_tx, sent) = oneshot::channel::<()>();
let body = std::mem::replace(request.body_mut(), SdkBody::taken());
if http_body::Body::is_end_stream(&body) {
*request.body_mut() = body;
drop(sent_tx);
} else {
*request.body_mut() = SdkBody::from_body_1_x(NotifyWhenSent {
inner: body,
pending: bytes::Bytes::new(),
sent: Some(sent_tx),
});
}
let response = self.inner.call(request);
let timeout = self.timeout;
HttpConnectorFuture::new(async move {
let silence = async move {
let _ = sent.await;
tokio::time::sleep(timeout).await;
};
tokio::select! {
response = response => response,
() = silence => Err(ConnectorError::timeout(
format!("S3 sent no response within {timeout:?} of the request").into(),
)),
}
})
}
}
const PIECE: usize = 8 * 1024;
struct NotifyWhenSent {
inner: SdkBody,
pending: bytes::Bytes,
sent: Option<oneshot::Sender<()>>,
}
impl NotifyWhenSent {
fn next_piece(&mut self) -> bytes::Bytes {
let piece = self.pending.split_to(self.pending.len().min(PIECE));
if self.pending.is_empty() && http_body::Body::is_end_stream(&self.inner) {
self.sent = None;
}
piece
}
}
impl http_body::Body for NotifyWhenSent {
type Data = bytes::Bytes;
type Error = BoxError;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
if !self.pending.is_empty() {
return Poll::Ready(Some(Ok(http_body::Frame::data(self.next_piece()))));
}
let frame = std::task::ready!(Pin::new(&mut self.inner).poll_frame(cx));
match frame {
Some(Ok(frame)) => match frame.into_data() {
Ok(data) => {
self.pending = data;
Poll::Ready(Some(Ok(http_body::Frame::data(self.next_piece()))))
}
Err(frame) => {
if http_body::Body::is_end_stream(&self.inner) {
self.sent = None;
}
Poll::Ready(Some(Ok(frame)))
}
},
other => {
self.sent = None;
Poll::Ready(other)
}
}
}
fn is_end_stream(&self) -> bool {
self.pending.is_empty() && http_body::Body::is_end_stream(&self.inner)
}
fn size_hint(&self) -> http_body::SizeHint {
let inner = http_body::Body::size_hint(&self.inner);
let pending = self.pending.len() as u64;
let mut hint = http_body::SizeHint::new();
hint.set_lower(inner.lower() + pending);
if let Some(upper) = inner.upper() {
hint.set_upper(upper + pending);
}
hint
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
use std::future::Future;
use std::net::SocketAddr;
use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
use std::time::Instant;
use aws_config::BehaviorVersion;
use aws_config::retry::RetryConfig;
use aws_credential_types::Credentials;
use aws_credential_types::provider::SharedCredentialsProvider;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::CompletedMultipartUpload;
use aws_sdk_s3::types::CompletedPart;
use aws_smithy_types::body::SdkBody;
use aws_types::region::Region;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use crate::io::remote::s3::tests::read_http_request;
const TIMEOUT: Duration = Duration::from_secs(1);
const TICK: Duration = Duration::from_millis(200);
const STEPS: usize = 15;
const SLOW: Duration = Duration::from_millis(200 * STEPS as u64);
const HANG: Duration = Duration::from_secs(20);
async fn spawn_endpoint<F, Fut>(handle: F) -> SocketAddr
where
F: Fn(TcpStream) -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(handle(stream));
}
});
addr
}
async fn silent(mut stream: TcpStream) {
if read_http_request(&mut stream).await.is_ok() {
std::future::pending::<()>().await;
}
}
fn client(addr: SocketAddr) -> aws_sdk_s3::Client {
let config = SdkConfig::builder()
.behavior_version(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.credentials_provider(SharedCredentialsProvider::new(Credentials::new(
"AK", "SK", None, None, "test",
)))
.endpoint_url(format!("http://{addr}"))
.retry_config(RetryConfig::disabled())
.build();
let s3 = s3_client_with_timeout(&config, TIMEOUT);
let conf = s3.config().to_builder().force_path_style(true).build();
aws_sdk_s3::Client::from_conf(conf)
}
async fn timed<T>(call: impl Future<Output = T>) -> (T, Duration) {
let start = Instant::now();
let out = tokio::time::timeout(HANG, call)
.await
.expect("the call hung: nothing bounded the wait");
(out, start.elapsed())
}
fn assert_timed_out<E, R>(
result: Result<impl std::fmt::Debug, SdkError<E, R>>,
elapsed: Duration,
) where
E: std::fmt::Debug,
R: std::fmt::Debug,
{
let err = result.expect_err("a silent server must fail the call");
let is_timeout = matches!(&err, SdkError::DispatchFailure(e) if e.is_timeout());
assert!(is_timeout, "expected a timeout, got: {err:?}");
assert!(
elapsed >= TIMEOUT && elapsed < TIMEOUT * 3,
"timed out after {elapsed:?}, not about {TIMEOUT:?}"
);
}
#[test(tokio::test)]
async fn silent_server_fails_a_bodiless_call() {
let addr = spawn_endpoint(silent).await;
let (result, elapsed) = timed(client(addr).head_object().bucket("b").key("k").send()).await;
assert_timed_out(result, elapsed);
}
#[test(tokio::test)]
async fn silent_server_fails_an_upload_after_its_body() {
let addr = spawn_endpoint(silent).await;
let (result, elapsed) = timed(Box::pin(
client(addr)
.put_object()
.bucket("b")
.key("k")
.body(ByteStream::from_static(b"payload"))
.send(),
))
.await;
assert_timed_out(result, elapsed);
}
struct SlowBody {
chunk: usize,
chunks_left: usize,
tick: Pin<Box<tokio::time::Sleep>>,
}
impl SlowBody {
fn new(chunk: usize, chunks: usize) -> Self {
Self {
chunk,
chunks_left: chunks,
tick: Box::pin(tokio::time::sleep(TICK)),
}
}
}
impl http_body::Body for SlowBody {
type Data = bytes::Bytes;
type Error = std::io::Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
if self.chunks_left == 0 {
return Poll::Ready(None);
}
std::task::ready!(self.tick.as_mut().poll(cx));
self.chunks_left -= 1;
let next = tokio::time::Instant::now() + TICK;
self.tick.as_mut().reset(next);
Poll::Ready(Some(Ok(http_body::Frame::data(bytes::Bytes::from(
vec![b'x'; self.chunk],
)))))
}
fn is_end_stream(&self) -> bool {
self.chunks_left == 0
}
fn size_hint(&self) -> http_body::SizeHint {
http_body::SizeHint::with_exact((self.chunk * self.chunks_left) as u64)
}
}
async fn answer(mut stream: TcpStream, head: &'static str) {
if read_http_request(&mut stream).await.is_ok() {
let _ = stream.write_all(head.as_bytes()).await;
let _ = stream.shutdown().await;
}
}
#[test(tokio::test)]
async fn slow_steady_upload_succeeds() {
let addr =
spawn_endpoint(|stream| answer(stream, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"))
.await;
let chunks = STEPS;
let body = SlowBody::new(1024, chunks);
let length = i64::try_from(1024 * chunks).unwrap();
let (result, elapsed) = timed(Box::pin(
client(addr)
.put_object()
.bucket("b")
.key("k")
.content_length(length)
.body(ByteStream::new(SdkBody::from_body_1_x(body)))
.send(),
))
.await;
result.expect("a slow upload that keeps moving must succeed");
assert!(
elapsed >= SLOW.saturating_sub(TICK),
"the body was not slow: {elapsed:?}"
);
}
async fn read_slowly(mut stream: TcpStream, per_tick: usize) {
use tokio::io::AsyncReadExt;
let mut request = Vec::new();
let mut buf = vec![0u8; per_tick];
let header_end = loop {
if let Some(i) = request.windows(4).position(|w| w == b"\r\n\r\n") {
break i + 4;
}
match stream.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => request.extend_from_slice(&buf[..n]),
}
};
let body_len: usize = String::from_utf8_lossy(&request[..header_end])
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().ok())?
})
.unwrap_or(0);
let mut read = request.len() - header_end;
let mut this_tick = 0;
while read < body_len {
if this_tick >= per_tick {
tokio::time::sleep(TICK).await;
this_tick = 0;
}
let want = (per_tick - this_tick).min(buf.len());
match stream.read(&mut buf[..want]).await {
Ok(0) | Err(_) => return,
Ok(n) => {
read += n;
this_tick += n;
}
}
}
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
let _ = stream.shutdown().await;
}
#[test(tokio::test)]
async fn slow_steady_in_memory_upload_succeeds() {
const PER_TICK: usize = 2 * 1024 * 1024;
let addr = spawn_endpoint(|stream| read_slowly(stream, PER_TICK)).await;
let body = vec![b'x'; PER_TICK * 2 * STEPS];
let (result, elapsed) = timed(Box::pin(
client(addr)
.put_object()
.bucket("b")
.key("k")
.body(ByteStream::from(body))
.send(),
))
.await;
result.expect("a slow upload that keeps moving must succeed");
assert!(elapsed >= SLOW, "the body was not slow: {elapsed:?}");
}
async fn trickle(mut stream: TcpStream, head: String, body: Vec<u8>) {
if read_http_request(&mut stream).await.is_err() {
return;
}
let _ = stream.write_all(head.as_bytes()).await;
for byte in body {
tokio::time::sleep(TICK).await;
if stream.write_all(&[byte]).await.is_err() {
return;
}
}
let _ = stream.shutdown().await;
}
#[test(tokio::test)]
async fn slow_steady_download_succeeds() {
let chunks = STEPS;
let body = vec![b'x'; chunks];
let expected = body.clone();
let addr = spawn_endpoint(move |stream| {
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
trickle(stream, head, body.clone())
})
.await;
let (result, elapsed) = timed(async {
let out = client(addr)
.get_object()
.bucket("b")
.key("k")
.send()
.await?;
Ok::<_, Box<dyn std::error::Error>>(out.body.collect().await?.into_bytes())
})
.await;
let bytes = result.expect("a slow download that keeps moving must succeed");
assert_eq!(bytes.as_ref(), expected.as_slice());
assert!(
elapsed >= SLOW.saturating_sub(TICK),
"the body was not slow: {elapsed:?}"
);
}
#[test(tokio::test)]
async fn slow_complete_multipart_upload_succeeds() {
const DECLARATION: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
const RESULT: &str = "<CompleteMultipartUploadResult><Bucket>b</Bucket>\
<Key>k</Key><ETag>\"e\"</ETag></CompleteMultipartUploadResult>";
let spaces = STEPS;
let addr = spawn_endpoint(move |mut stream| async move {
if read_http_request(&mut stream).await.is_err() {
return;
}
let length = DECLARATION.len() + spaces + RESULT.len();
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n\
Content-Length: {length}\r\n\r\n{DECLARATION}"
);
if stream.write_all(head.as_bytes()).await.is_err() {
return;
}
for _ in 0..spaces {
tokio::time::sleep(TICK).await;
if stream.write_all(b" ").await.is_err() {
return;
}
}
let _ = stream.write_all(RESULT.as_bytes()).await;
let _ = stream.shutdown().await;
})
.await;
let (result, elapsed) = timed(
client(addr)
.complete_multipart_upload()
.bucket("b")
.key("k")
.upload_id("u")
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(CompletedPart::builder().part_number(1).e_tag("e").build())
.build(),
)
.send(),
)
.await;
let out = result.expect("a slow completion that keeps sending must succeed");
assert_eq!(out.e_tag(), Some("\"e\""));
assert!(
elapsed >= SLOW.saturating_sub(TICK),
"the completion was not slow: {elapsed:?}"
);
}
}