use std::time::Duration;
use crate::WyrdClient;
use bytes::Bytes;
use wyrd_spec::storage::{
CompletedPart, HeaderPair, MultipartComplete, UploadCompleteRequest, UploadPlan,
};
use super::{UploadHooks, UploadOutcome, checked_size, report};
use crate::storage::error::{StorageClientError, map_backend_response};
use crate::storage::upload::reader::SourceReader;
const MAX_PART_ATTEMPTS: u32 = 3;
pub(crate) async fn upload(
client: &WyrdClient,
plan: &UploadPlan,
mut reader: SourceReader,
total: Option<u64>,
hooks: &mut UploadHooks<'_>,
) -> Result<UploadOutcome, StorageClientError> {
let UploadPlan::Multipart {
part_count,
part_size_bytes,
required_headers,
..
} = plan
else {
return Err(StorageClientError::PlanMismatch {
expected: "Multipart",
actual: super::plan_variant(plan),
});
};
let part_size = checked_size(*part_size_bytes, "multipart part size")?;
let capacity = usize::try_from(*part_count)
.map_err(|_| StorageClientError::PlanInvalid("multipart part count exceeds usize"))?;
let mut parts = Vec::with_capacity(capacity);
let mut uploaded = 0u64;
for part_number in 1..=*part_count {
let bytes =
reader
.next_chunk(part_size)
.await?
.ok_or(StorageClientError::SizeMismatch {
expected: total.unwrap_or(u64::from(*part_count) * *part_size_bytes),
actual: uploaded,
})?;
let e_tag =
send_part_with_retry(client, hooks, part_number, bytes.clone(), required_headers)
.await?;
uploaded += bytes.len() as u64;
report(hooks.progress.as_ref(), uploaded, total);
parts.push(CompletedPart { part_number, e_tag });
}
if reader.has_more().await? {
return Err(StorageClientError::SizeMismatch {
expected: total.unwrap_or(u64::from(*part_count) * *part_size_bytes),
actual: uploaded + 1,
});
}
if let Some(expected) = total
&& expected != uploaded
{
return Err(StorageClientError::SizeMismatch {
expected,
actual: uploaded,
});
}
Ok(UploadOutcome::NeedsServerComplete(
UploadCompleteRequest::Multipart(MultipartComplete { parts }),
))
}
async fn send_part_with_retry(
client: &WyrdClient,
hooks: &mut UploadHooks<'_>,
part_number: u32,
bytes: Bytes,
required_headers: &[HeaderPair],
) -> Result<String, StorageClientError> {
let mut attempt = 0u32;
loop {
let url = mint_part_url(hooks, part_number).await?;
let mut headers: Vec<(&str, &str)> = required_headers
.iter()
.map(|h| (h.name.as_str(), h.value.as_str()))
.collect();
headers.push(("Idempotency-Key", hooks.idempotency_key));
let outcome = client
.http
.request_external_stream(
reqwest::Method::PUT,
&url,
Some(reqwest::Body::from(bytes.clone())),
&headers,
)
.await;
match outcome {
Err(_err) => {
if attempt + 1 < MAX_PART_ATTEMPTS {
sleep_backoff(attempt).await;
attempt += 1;
continue;
}
return Err(StorageClientError::Transport {
operation: "upload",
});
}
Ok(response) => {
let status = response.status().as_u16();
if response.status().is_success() {
let e_tag = response
.headers()
.get("etag")
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
.ok_or(StorageClientError::MissingETag { part_number })?;
return Ok(e_tag);
}
let retryable = status == 403 || (500..600).contains(&status);
if retryable && attempt + 1 < MAX_PART_ATTEMPTS {
sleep_backoff(attempt).await;
attempt += 1;
continue;
}
return Err(map_backend_response(response).await);
}
}
}
}
async fn mint_part_url(
hooks: &mut UploadHooks<'_>,
part_number: u32,
) -> Result<String, StorageClientError> {
let minter = hooks
.part_url_minter
.as_mut()
.ok_or(StorageClientError::MissingHook("part_url_minter"))?;
minter(part_number).await
}
async fn sleep_backoff(attempt: u32) {
const DELAY_MS: &[u64] = &[50, 250];
let ms = DELAY_MS.get(attempt as usize).copied().unwrap_or(500);
tokio::time::sleep(Duration::from_millis(ms)).await;
}