hotdata/uploads.rs
1//! Ergonomic, hand-written direct-to-storage (presigned) file uploads.
2//!
3//! This module is regeneration-immune: it is protected by `.openapi-generator-ignore`
4//! and is never emitted by the OpenAPI generator. It orchestrates the
5//! presigned-upload flow that the generated [`apis::uploads_api`](crate::apis::uploads_api)
6//! ops expose as raw building blocks:
7//!
8//! 1. `POST /v1/uploads` ([`create_upload_session_handler`]) opens a session.
9//! A small file declares its size and gets a single `url` (`mode == "single"`)
10//! or, for a known-size multipart upload, a full set of `part_urls` plus a
11//! `part_size` (`mode == "multipart"`). A large file omits its declared size
12//! to open a **streaming** session: `mode == "multipart"` with a `part_size`
13//! but NO `part_urls` — the client mints each part URL on demand from
14//! `POST /v1/uploads/{id}/parts` ([`mint_upload_parts_handler`]) just before
15//! uploading that part, so a URL can't expire mid-transfer on a slow upload.
16//! Every session also carries a one-time `finalize_token`.
17//! 2. The client `PUT`s the bytes **directly to object storage** — never back
18//! through the API. Single uploads stream the whole file to `url`; multipart
19//! uploads slice the file into `part_size`-byte chunks and `PUT` each chunk to
20//! its part URL (pre-issued for known-size, minted on demand for streaming),
21//! collecting the storage `ETag` per part.
22//! 3. `POST /v1/uploads/{upload_id}/finalize` ([`finalize_upload_handler`])
23//! confirms the upload with the finalize token in the `X-Upload-Finalize-Token`
24//! header (empty body for single; the ascending `{part_number, e_tag}` list
25//! for multipart) and returns a [`models::FinalizeUploadResponse`].
26//!
27//! # Storage PUT header isolation
28//!
29//! A presigned storage URL already carries its authorization in the query string
30//! (or in the server-provided `headers` map). Object stores (S3 and compatible)
31//! reject a `PUT` with `403 SignatureDoesNotMatch` if it carries extra
32//! signed-ish headers, so [`put_to_storage`] sends a *bare* request: NONE of the
33//! SDK's bearer / workspace headers, only an explicit `Content-Length`
34//! and whatever the server placed in `headers` (currently always empty). It also
35//! refuses to let reqwest auto-append a charset to a `Content-Type` — a type is
36//! sent only when the server's `headers` map asks for one.
37//!
38//! No S3/AWS SDK is involved: storage `PUT`s are plain `reqwest`.
39
40use std::collections::HashMap;
41use std::path::Path;
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::Arc;
44use std::time::Duration;
45
46use crate::apis::configuration::Configuration;
47use crate::apis::{self, Error};
48use crate::models;
49
50/// One mebibyte, the unit the storage part-size range is expressed in.
51const MIB: u64 = 1024 * 1024;
52
53/// Default cap on concurrent part `PUT`s when the caller doesn't set
54/// [`UploadOptions::max_concurrency`]. Matches the boto3 / AWS CLI default of 10.
55/// The effective in-flight count is the MIN of this and a memory budget (see
56/// [`effective_in_flight`]).
57pub const DEFAULT_MAX_CONCURRENCY: usize = 10;
58
59/// Default part-size hint, in bytes (8 MiB), sent when the caller doesn't set
60/// [`UploadOptions::part_size`]. The server clamps the hint to its own range and
61/// returns the actual size. See [`auto_part_size_hint`].
62pub const DEFAULT_PART_SIZE: u64 = 8 * MIB;
63
64/// Target ceiling on part count when auto-scaling the part-size hint for very
65/// large files, with headroom under S3's hard 10,000-part limit. See
66/// [`auto_part_size_hint`].
67pub const TARGET_MAX_PARTS: u64 = 9000;
68
69/// Minimum part size storage accepts (5 MiB). The hint is clamped to at least
70/// this; the server enforces it too.
71pub const MIN_PART_SIZE: u64 = 5 * MIB;
72
73/// Maximum part size storage accepts (5 GiB). The hint is clamped to at most
74/// this.
75pub const MAX_PART_SIZE: u64 = 5 * 1024 * MIB;
76
77/// File-size boundary between the two upload strategies. A file at or below this
78/// size takes the known-size path — a single quick `PUT` (or a short eager
79/// multipart) that completes well within a presigned URL's TTL, so there is no
80/// expiry risk. A larger file uses the streaming just-in-time path, minting each
81/// part URL only moments before it is uploaded. Set to [`DEFAULT_PART_SIZE`],
82/// the server's default single-vs-multipart boundary, so small uploads keep the
83/// single-`PUT` fast path unchanged.
84pub const STREAMING_THRESHOLD: u64 = DEFAULT_PART_SIZE;
85
86/// Target peak-memory budget for in-flight part buffers (256 MiB). Each
87/// in-flight part buffers up to `part_size` bytes, so [`effective_in_flight`]
88/// derives the in-flight count as `budget / part_size`.
89///
90/// This is a TARGET, not a hard ceiling: it holds while `part_size` is small
91/// relative to the budget (the normal case — 8 MiB parts stay well under it). It
92/// cannot bound memory below one in-flight part, so when the server returns a
93/// very large `part_size` (e.g. a 5 GiB part on a huge file), a single in-flight
94/// part already exceeds this budget and peak memory is `1 * part_size`. In other
95/// words the budget caps *concurrency*, not the size of one part.
96pub const UPLOAD_MEMORY_BUDGET: u64 = 256 * MIB;
97
98/// Compute the part-size HINT to send to the server in
99/// `CreateUploadRequest.part_size` when the caller did not specify one.
100///
101/// Starts from [`DEFAULT_PART_SIZE`] (8 MiB) and grows only for files large
102/// enough that 8 MiB parts would exceed [`TARGET_MAX_PARTS`] — so the common
103/// case is unchanged and only very large files (beyond ~72 GiB) get a larger
104/// hint to keep the part count bounded. The result is rounded UP to a whole MiB
105/// and clamped to `[MIN_PART_SIZE, MAX_PART_SIZE]`. The server still has the
106/// final say and clamps to its own range.
107///
108/// Pure and total: `declared_size == 0` yields [`DEFAULT_PART_SIZE`].
109pub fn auto_part_size_hint(declared_size: u64) -> u64 {
110 // Smallest part size that keeps the count at or under the target.
111 let by_count = declared_size.div_ceil(TARGET_MAX_PARTS);
112 let raw = DEFAULT_PART_SIZE.max(by_count);
113 // Round up to a whole MiB so the hint is a clean multiple.
114 let rounded = raw.div_ceil(MIB) * MIB;
115 rounded.clamp(MIN_PART_SIZE, MAX_PART_SIZE)
116}
117
118/// Compute how many part `PUT`s to keep in flight, given the caller's
119/// `max_concurrency` (already defaulted to [`DEFAULT_MAX_CONCURRENCY`]) and the
120/// SERVER's actual returned `part_size`.
121///
122/// Peak buffered memory is `in_flight * part_size`, so we cap in-flight at
123/// `UPLOAD_MEMORY_BUDGET / part_size`, then at `max_concurrency`. Normal 8 MiB
124/// parts give `256/8 = 32`, capped to `max_concurrency`; a 64 MiB part gives `4`.
125///
126/// `max_concurrency` is honored as an explicit floor: a caller asking for `1`
127/// (or `0`) gets serial uploads (`1`), so the budget never *raises* concurrency
128/// above what was requested. The budget-derived count itself has a floor of 1
129/// (you must keep at least one part in flight to make progress), so the overall
130/// result is always `>= 1`.
131///
132/// Pure and total: a zero `part_size` is treated as 1 to avoid division by zero.
133pub fn effective_in_flight(max_concurrency: usize, part_size: u64) -> usize {
134 // Honor an explicit low request down to serial (1); never below 1.
135 let cap = max_concurrency.max(1);
136 let by_budget = (UPLOAD_MEMORY_BUDGET / part_size.max(1)).max(1) as usize;
137 by_budget.min(cap)
138}
139
140/// Progress callback: invoked as bytes flow with `(bytes_done_total, total)`,
141/// where `total` is the full declared file size. `bytes_done_total` is
142/// monotonically non-decreasing and reaches exactly `total` when the transfer
143/// completes. Shared (`Arc`) so it can be cloned across concurrent part tasks;
144/// it must therefore be `Send + Sync`.
145pub type UploadProgress = Arc<dyn Fn(u64, u64) + Send + Sync>;
146
147/// Options for [`Client::upload_file`](crate::Client::upload_file).
148///
149/// All fields are optional. `content_type` / `content_encoding` / `filename`
150/// are recorded with the upload (advisory metadata; they do not change where the
151/// bytes are stored). `part_size` is a hint the server clamps to its allowed
152/// range and ignores for single-`PUT` uploads. `progress`, when set, is invoked
153/// as bytes flow.
154#[derive(Default, Clone)]
155pub struct UploadOptions {
156 /// Content type to record for the uploaded file (e.g. a Parquet/CSV/JSON
157 /// MIME type). Advisory.
158 pub content_type: Option<String>,
159 /// Content encoding to record for the uploaded file (e.g. `gzip`). Advisory.
160 pub content_encoding: Option<String>,
161 /// Original file name, recorded for bookkeeping. Advisory. Defaults to the
162 /// source path's file name when not set.
163 pub filename: Option<String>,
164 /// Preferred part size, in bytes, for a large (multipart) upload. A hint;
165 /// the server clamps it and ignores it for single-`PUT` uploads. When unset,
166 /// the SDK auto-scales a hint via [`auto_part_size_hint`] (8 MiB for normal
167 /// files, larger only for very large ones to bound the part count).
168 pub part_size: Option<u64>,
169 /// Maximum number of part `PUT`s to keep in flight for a multipart upload.
170 /// `None` uses [`DEFAULT_MAX_CONCURRENCY`]. The effective in-flight count is
171 /// the MIN of this and a peak-memory budget derived from the server's actual
172 /// part size (see [`effective_in_flight`]), so memory stays bounded.
173 pub max_concurrency: Option<usize>,
174 /// Optional progress callback invoked with `(bytes_done_total, total)`.
175 pub progress: Option<UploadProgress>,
176}
177
178impl std::fmt::Debug for UploadOptions {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.debug_struct("UploadOptions")
181 .field("content_type", &self.content_type)
182 .field("content_encoding", &self.content_encoding)
183 .field("filename", &self.filename)
184 .field("part_size", &self.part_size)
185 .field("max_concurrency", &self.max_concurrency)
186 .field("progress", &self.progress.as_ref().map(|_| "<callback>"))
187 .finish()
188 }
189}
190
191/// Error returned by [`Client::upload_file`](crate::Client::upload_file).
192///
193/// Marked `#[non_exhaustive]`: new variants may be added without a breaking
194/// change, so downstream `match`es should carry a wildcard arm.
195#[derive(Debug)]
196#[non_exhaustive]
197pub enum UploadError {
198 /// Opening or reading the local source file failed.
199 Io(std::io::Error),
200 /// Opening the upload session (`POST /v1/uploads`) failed. A `501`
201 /// `PRESIGN_UNSUPPORTED` lands here too — the presigned path is a hard
202 /// requirement, so a backend that cannot presign is a hard error.
203 CreateSession(Error<apis::uploads_api::CreateUploadSessionHandlerError>),
204 /// A storage `PUT` (or the request building / transport around it) failed.
205 Storage(reqwest::Error),
206 /// A storage `PUT` returned a non-2xx status. Carries the status and the
207 /// response body for diagnosis.
208 StorageStatus {
209 /// The HTTP status the storage endpoint returned.
210 status: reqwest::StatusCode,
211 /// The 1-based part number for a multipart `PUT`, or `None` for the
212 /// single-`PUT` path.
213 part_number: Option<i32>,
214 /// The storage response body (often XML for S3-style errors).
215 body: String,
216 },
217 /// Storage accepted a part `PUT` but returned no `ETag` header, so the part
218 /// cannot be finalized.
219 MissingETag {
220 /// The 1-based part number whose `PUT` response lacked an `ETag`.
221 part_number: i32,
222 },
223 /// The create-session response was internally inconsistent for its declared
224 /// `mode` (e.g. `single` without a `url`, or `multipart` without
225 /// `part_urls` / `part_size`).
226 MalformedSession(String),
227 /// A size (the file's declared size, or the part-size hint) did not fit the
228 /// wire's signed 64-bit field. Only reachable for pathological sizes beyond
229 /// `i64::MAX` bytes (~8 EiB).
230 SizeOverflow {
231 /// What overflowed (e.g. `"declared_size_bytes"`).
232 what: &'static str,
233 /// The offending value.
234 value: u64,
235 },
236 /// Finalizing the upload (`POST /v1/uploads/{id}/finalize`) failed.
237 Finalize(Error<apis::uploads_api::FinalizeUploadHandlerError>),
238 /// Minting part URLs on demand (`POST /v1/uploads/{id}/parts`) failed during
239 /// a streaming upload — either the initial batch or an on-403 re-mint.
240 MintParts(Error<apis::uploads_api::MintUploadPartsHandlerError>),
241}
242
243impl std::fmt::Display for UploadError {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 UploadError::Io(e) => write!(f, "reading the source file failed: {e}"),
247 UploadError::CreateSession(e) => write!(f, "opening the upload session failed: {e}"),
248 UploadError::Storage(e) => write!(f, "uploading to storage failed: {e}"),
249 UploadError::StorageStatus {
250 status,
251 part_number,
252 body,
253 } => match part_number {
254 Some(n) => write!(f, "storage rejected part {n} with status {status}: {body}"),
255 None => write!(
256 f,
257 "storage rejected the upload with status {status}: {body}"
258 ),
259 },
260 UploadError::MissingETag { part_number } => write!(
261 f,
262 "storage returned no ETag for part {part_number}; cannot finalize"
263 ),
264 UploadError::SizeOverflow { what, value } => {
265 write!(
266 f,
267 "{what} ({value} bytes) exceeds the maximum supported size"
268 )
269 }
270 UploadError::MalformedSession(msg) => {
271 write!(f, "malformed upload session response: {msg}")
272 }
273 UploadError::Finalize(e) => write!(f, "finalizing the upload failed: {e}"),
274 UploadError::MintParts(e) => write!(f, "minting upload part URLs failed: {e}"),
275 }
276 }
277}
278
279impl std::error::Error for UploadError {
280 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
281 match self {
282 UploadError::Io(e) => Some(e),
283 UploadError::CreateSession(e) => Some(e),
284 UploadError::Storage(e) => Some(e),
285 UploadError::Finalize(e) => Some(e),
286 UploadError::MintParts(e) => Some(e),
287 _ => None,
288 }
289 }
290}
291
292impl From<std::io::Error> for UploadError {
293 fn from(e: std::io::Error) -> Self {
294 UploadError::Io(e)
295 }
296}
297
298/// Upload a local file directly to object storage and finalize it.
299///
300/// This is the orchestration behind [`Client::upload_file`](crate::Client::upload_file);
301/// see that method for the public contract. It stats `path` for the declared
302/// size, opens a session, drives the single-`PUT` or multipart path, and
303/// finalizes.
304pub(crate) async fn upload_file(
305 configuration: &Configuration,
306 path: &Path,
307 opts: UploadOptions,
308) -> Result<models::FinalizeUploadResponse, UploadError> {
309 let metadata = tokio::fs::metadata(path).await?;
310 let total = metadata.len();
311
312 let filename = opts
313 .filename
314 .clone()
315 .or_else(|| path.file_name().map(|n| n.to_string_lossy().into_owned()));
316
317 // Part-size hint: honor an explicit caller value, else auto-scale from the
318 // declared size so the common case stays at 8 MiB and only very large files
319 // grow the hint (bounding the part count). The server clamps it regardless.
320 let part_size_hint = opts.part_size.unwrap_or_else(|| auto_part_size_hint(total));
321
322 // The wire models the part-size hint as a signed i64; reject (rather than
323 // silently wrap) a pathological hint beyond i64::MAX.
324 let part_size_hint_i64 =
325 i64::try_from(part_size_hint).map_err(|_| UploadError::SizeOverflow {
326 what: "part_size",
327 value: part_size_hint,
328 })?;
329
330 // Default a large file to a JUST-IN-TIME (streaming) session: omit
331 // `declared_size_bytes` so the server mints NO part URLs up front. The client
332 // then mints each part URL moments before it uploads that part (see
333 // `upload_multipart_streaming`), so a URL cannot expire mid-transfer no
334 // matter how long a slow upload runs — the failure mode of the eager
335 // known-size path, whose URLs share a ~30-minute TTL. A small file is a
336 // single quick `PUT` with no expiry risk, so it keeps the known-size path
337 // (and the server's single-`PUT` fast path) by declaring its size.
338 //
339 // `declared_size_bytes` is sent (and so range-checked against the wire's
340 // i64) ONLY on the known-size path; a streaming upload omits it entirely, so
341 // a size beyond i64::MAX is never an obstacle to a streamed file.
342 let declared_size_bytes = if total > STREAMING_THRESHOLD {
343 None
344 } else {
345 let size = i64::try_from(total).map_err(|_| UploadError::SizeOverflow {
346 what: "declared_size_bytes",
347 value: total,
348 })?;
349 Some(Some(size))
350 };
351 let create = models::CreateUploadRequest {
352 content_type: opts.content_type.clone().map(Some),
353 content_encoding: opts.content_encoding.clone().map(Some),
354 filename: filename.map(Some),
355 part_size: Some(Some(part_size_hint_i64)),
356 declared_size_bytes,
357 ..models::CreateUploadRequest::new()
358 };
359 let session = apis::uploads_api::create_upload_session_handler(configuration, create)
360 .await
361 .map_err(UploadError::CreateSession)?;
362
363 // Report initial progress so a 0-byte file (or an instant single PUT) still
364 // emits a terminal (0/0 or total/total) tick.
365 if let Some(ref progress) = opts.progress {
366 progress(0, total);
367 }
368
369 let parts = match session.mode.as_str() {
370 "single" => {
371 upload_single(&session, path, total, opts.progress.as_ref()).await?;
372 None
373 }
374 "multipart" => {
375 let max_concurrency = opts.max_concurrency.unwrap_or(DEFAULT_MAX_CONCURRENCY);
376 // A streaming (unknown-size) session returns NO part URLs up front
377 // (the `part_urls` key is absent or null) — mint them on demand. A
378 // known-size session returns the full `part_urls` list to PUT to
379 // directly. An explicitly present (even empty) list is a known-size
380 // response; `upload_multipart` validates it and rejects an empty one.
381 let parts = if matches!(session.part_urls, Some(Some(_))) {
382 upload_multipart(
383 configuration,
384 &session,
385 path,
386 total,
387 max_concurrency,
388 opts.progress.as_ref(),
389 )
390 .await?
391 } else {
392 upload_multipart_streaming(
393 configuration,
394 &session,
395 path,
396 total,
397 max_concurrency,
398 opts.progress.as_ref(),
399 )
400 .await?
401 };
402 Some(parts)
403 }
404 other => {
405 return Err(UploadError::MalformedSession(format!(
406 "unknown upload mode `{other}`"
407 )))
408 }
409 };
410
411 // Finalize: single sends an empty object `{}`; multipart sends
412 // `{"parts": [...]}` with the ascending, non-duplicate parts list. The token
413 // rides the X-Upload-Finalize-Token header (handled by the generated op).
414 //
415 // The body MUST be a JSON object, never `null`: the server rejects a `null`
416 // finalize body ("invalid type: null, expected struct FinalizeUploadRequest")
417 // even though the field is logically optional for single uploads. So we wrap
418 // in `Some(..)` for both modes — the generated op then serializes a struct,
419 // and `parts` (skip_serializing_if = Option::is_none) drops out for single,
420 // yielding `{}`.
421 let finalize_body = Some(
422 parts
423 .map(|parts| models::FinalizeUploadRequest {
424 parts: Some(Some(parts)),
425 })
426 .unwrap_or_default(),
427 );
428
429 // Finalize is exactly-once on the server: a second finalize of the same
430 // upload is rejected. The generated op routes through `execute_retrying`,
431 // which would retry an ambiguous failure (a lost response, or a 429 the
432 // server actually processed) — turning a finalize that SUCCEEDED into a
433 // spurious "already finalized" error on the retry. So we call it with retries
434 // disabled (a single attempt). Part PUTs stay retryable (idempotent: storage
435 // overwrites a part by number); only finalize is single-shot.
436 let mut finalize_config = configuration.clone();
437 finalize_config.retry.max_retries = 0;
438
439 apis::uploads_api::finalize_upload_handler(
440 &finalize_config,
441 &session.upload_id,
442 &session.finalize_token,
443 finalize_body,
444 )
445 .await
446 .map_err(UploadError::Finalize)
447}
448
449/// Single-`PUT` path: stream the whole file to `session.url`, invoking the
450/// progress callback incrementally as chunks are sent to storage.
451///
452/// The body is a [`progress_stream`] wrapping the file reader, so progress is
453/// byte-granular (a multi-GB upload reports smooth `done/total` ticks rather
454/// than jumping 0% -> 100%). A streaming body is not clonable, so this single
455/// `PUT` is sent once with no 429/reset retry — an intentional trade for smooth
456/// progress on the large, common single-`PUT` path; a presigned storage `PUT`
457/// is not expected to be admission-shed.
458async fn upload_single(
459 session: &models::UploadSessionResponse,
460 path: &Path,
461 total: u64,
462 progress: Option<&UploadProgress>,
463) -> Result<(), UploadError> {
464 let url =
465 session.url.clone().flatten().ok_or_else(|| {
466 UploadError::MalformedSession("single upload missing `url`".to_owned())
467 })?;
468
469 let file = tokio::fs::File::open(path).await?;
470 let body = progress_stream(file, total, progress.cloned());
471
472 put_stream_to_storage(&url, &session.headers, body, total).await?;
473
474 // Guarantee a terminal tick at exactly `total`, even if the stream's last
475 // chunk boundary or an empty file left the counter short. Monotonic: the
476 // streamed ticks never exceed `total`.
477 if let Some(progress) = progress {
478 progress(total, total);
479 }
480 Ok(())
481}
482
483/// Wrap a file reader in a byte-counting stream of `Bytes` chunks. Each chunk
484/// advances a running total and invokes `progress(done, total)` as it is yielded
485/// to the request body, so progress reflects bytes actually handed to the
486/// transport. Monotonic non-decreasing; the running total never exceeds `total`.
487fn progress_stream(
488 file: tokio::fs::File,
489 total: u64,
490 progress: Option<UploadProgress>,
491) -> ProgressStream {
492 use tokio_util::codec::{BytesCodec, FramedRead};
493
494 ProgressStream {
495 inner: FramedRead::new(file, BytesCodec::new()),
496 done: 0,
497 total,
498 progress,
499 }
500}
501
502/// A [`Stream`](futures_core::Stream) of `Bytes` chunks read from a file that
503/// reports cumulative byte progress as each chunk is yielded. Hand-rolled over
504/// `futures_core` (the crate's only direct futures dep) rather than pulling in
505/// `futures_util`, staying on `futures_core::Stream`.
506struct ProgressStream {
507 inner: tokio_util::codec::FramedRead<tokio::fs::File, tokio_util::codec::BytesCodec>,
508 done: u64,
509 total: u64,
510 progress: Option<UploadProgress>,
511}
512
513impl futures_core::Stream for ProgressStream {
514 type Item = std::io::Result<bytes::Bytes>;
515
516 fn poll_next(
517 self: std::pin::Pin<&mut Self>,
518 cx: &mut std::task::Context<'_>,
519 ) -> std::task::Poll<Option<Self::Item>> {
520 use std::task::Poll;
521 // `inner` (FramedRead) is Unpin, and our other fields are too, so a
522 // mutable projection through `get_mut` is sound without pin-project.
523 let this = self.get_mut();
524 match std::pin::Pin::new(&mut this.inner).poll_next(cx) {
525 Poll::Ready(Some(Ok(chunk))) => {
526 let chunk = chunk.freeze();
527 this.done = (this.done + chunk.len() as u64).min(this.total);
528 if let Some(ref progress) = this.progress {
529 progress(this.done, this.total);
530 }
531 Poll::Ready(Some(Ok(chunk)))
532 }
533 Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
534 Poll::Ready(None) => Poll::Ready(None),
535 Poll::Pending => Poll::Pending,
536 }
537 }
538}
539
540/// A single part's upload work, independent of how the bytes actually get to
541/// storage. The known-size path builds one of these per part and hands them to
542/// [`upload_parts_resilient`]; tests substitute a fake uploader.
543#[derive(Clone, Debug, PartialEq, Eq)]
544struct PartPlan {
545 /// 0-based position — indexes both the results vector and the URL list.
546 index: usize,
547 /// 1-based S3 part number.
548 part_number: i32,
549 /// Byte offset of this part within the source file.
550 offset: u64,
551 /// Byte length of this part (the last part is the remainder).
552 len: u64,
553}
554
555/// Outer-loop retry policy for whole-upload resilience, layered ON TOP of the
556/// per-part transport retries in [`crate::http::execute_retrying`].
557///
558/// The inner per-part retries all burn within a few seconds, so they only ride
559/// out a momentary blip *during one part*. A longer network interruption (or a
560/// flaky uplink that resets connections under load) needs a genuinely later
561/// attempt. Each extra round re-sweeps ONLY the parts still failing, after a
562/// backoff, and at reduced concurrency — fewer in-flight connections reset less
563/// on a saturated link. Crucially, parts that already succeeded keep their
564/// ETags across rounds, so a single bad part never discards the whole transfer.
565#[derive(Clone, Copy, Debug)]
566struct RetryRounds {
567 /// Re-sweeps after the initial pass. `0` reproduces the legacy behavior: a
568 /// single part exhausting its inner retries fails the entire upload.
569 max_extra_rounds: u32,
570 /// Backoff before the first re-sweep; doubles each subsequent round.
571 base_delay: Duration,
572}
573
574impl Default for RetryRounds {
575 fn default() -> Self {
576 Self {
577 max_extra_rounds: 3,
578 base_delay: Duration::from_secs(2),
579 }
580 }
581}
582
583impl RetryRounds {
584 /// Backoff before `round` (1-based for re-sweeps): capped exponential.
585 fn delay_for(self, round: u32) -> Duration {
586 let shift = round.saturating_sub(1).min(16);
587 self.base_delay.saturating_mul(1u32 << shift)
588 }
589}
590
591/// In-flight cap for a given 0-based round: halve per round, never below 1. A
592/// saturated, jittery uplink (the failure mode this whole loop targets) resets
593/// fewer connections when fewer are in flight, so later rounds back off.
594fn round_in_flight(base_in_flight: usize, round: u32) -> usize {
595 let shift = round.min(usize::BITS - 1);
596 (base_in_flight >> shift).max(1)
597}
598
599/// Whether an upload error is *terminal* — guaranteed to reproduce on a
600/// re-sweep because it reflects a server-contract or sizing violation rather
601/// than a transient network condition. Terminal errors fail the upload
602/// immediately; everything else (transport resets, timeouts, storage 4xx/5xx,
603/// mint failures) stays retryable, so a flaky link is never mistaken for a
604/// permanent fault. Kept deliberately narrow: only errors that are deterministic
605/// in the part's own inputs belong here.
606fn is_terminal(err: &UploadError) -> bool {
607 matches!(
608 err,
609 UploadError::MalformedSession(_) | UploadError::SizeOverflow { .. }
610 )
611}
612
613/// Upload every part, surviving transient per-part failures without discarding
614/// the parts that already succeeded.
615///
616/// A round runs the still-pending parts through a `JoinSet` bounded by the
617/// round's in-flight cap, recording each success and **collecting** (not
618/// propagating) each failure. If any parts remain, it waits per [`RetryRounds`]
619/// and re-sweeps just those, at reduced concurrency, until they all land or the
620/// rounds are exhausted. Completed parts' ETags persist across rounds, so the
621/// work already done is never thrown away — the bug this replaces aborted the
622/// whole upload the moment one part exhausted its inner retries.
623///
624/// `upload_part` performs one part's transfer (including its own inner transport
625/// retries) and MUST be idempotent: re-running a part overwrites it in storage
626/// (S3 `UploadPart` by number), so a re-swept part is safe.
627async fn upload_parts_resilient<F, Fut>(
628 plans: Vec<PartPlan>,
629 base_in_flight: usize,
630 rounds: RetryRounds,
631 upload_part: F,
632) -> Result<Vec<models::FinalizeUploadPart>, UploadError>
633where
634 F: Fn(PartPlan) -> Fut + Clone + Send + Sync + 'static,
635 Fut: std::future::Future<Output = Result<models::FinalizeUploadPart, UploadError>>
636 + Send
637 + 'static,
638{
639 let total_parts = plans.len();
640 // `results` is indexed by `plan.index`, so every plan's index must fall in
641 // `0..total_parts` (both callers build consecutive 0-based plans). Enforce
642 // it so a future caller passing a sparse/offset set fails loudly in tests
643 // rather than panicking or writing the wrong slot.
644 debug_assert!(
645 plans.iter().all(|p| p.index < total_parts),
646 "PartPlan.index must be within 0..plans.len()"
647 );
648 let mut results: Vec<Option<models::FinalizeUploadPart>> = vec![None; total_parts];
649 let mut remaining = plans;
650 let mut last_err: Option<UploadError> = None;
651
652 for round in 0..=rounds.max_extra_rounds {
653 if remaining.is_empty() {
654 break;
655 }
656 if round > 0 {
657 // A genuinely later attempt on a fresh window — the point of the
658 // outer loop, distinct from the inner retries that already ran.
659 tokio::time::sleep(rounds.delay_for(round)).await;
660 }
661
662 let in_flight = round_in_flight(base_in_flight, round);
663 let mut pending = std::mem::take(&mut remaining).into_iter();
664 let mut failed: Vec<PartPlan> = Vec::new();
665 let mut join_set: tokio::task::JoinSet<
666 Result<(usize, models::FinalizeUploadPart), (PartPlan, UploadError)>,
667 > = tokio::task::JoinSet::new();
668
669 loop {
670 while join_set.len() < in_flight {
671 let Some(plan) = pending.next() else { break };
672 let upload_part = upload_part.clone();
673 join_set.spawn(async move {
674 let index = plan.index;
675 match upload_part(plan.clone()).await {
676 Ok(part) => Ok((index, part)),
677 Err(e) => Err((plan, e)),
678 }
679 });
680 }
681 match join_set.join_next().await {
682 Some(Ok(Ok((index, part)))) => results[index] = Some(part),
683 Some(Ok(Err((plan, e)))) => {
684 // A clearly-terminal error (server-contract / sizing
685 // violation) reproduces identically on every re-sweep, so
686 // fail fast rather than burning the whole round budget on it.
687 // Anything network-ish stays retryable — we never regress
688 // resilience by mistaking a flaky link for a permanent fault.
689 if is_terminal(&e) {
690 join_set.abort_all();
691 return Err(e);
692 }
693 // Record the failure and keep draining the rest — do NOT
694 // abort the other in-flight parts. This part is re-swept in
695 // the next round.
696 failed.push(plan);
697 last_err = Some(e);
698 }
699 Some(Err(join_err)) => {
700 join_set.abort_all();
701 return Err(UploadError::Io(std::io::Error::other(format!(
702 "part upload task failed: {join_err}"
703 ))));
704 }
705 None => break,
706 }
707 }
708 remaining = failed;
709 }
710
711 if !remaining.is_empty() {
712 // Rounds exhausted with parts still failing — surface the last
713 // underlying error so the caller's normal error mapping applies.
714 return Err(last_err
715 .unwrap_or_else(|| UploadError::Io(std::io::Error::other("multipart upload failed"))));
716 }
717
718 Ok(results.into_iter().flatten().collect())
719}
720
721/// Multipart path: slice the file into `part_size`-byte chunks (the last is the
722/// remainder), `PUT` each chunk to its `part_urls[i - 1]` with bounded
723/// concurrency, and collect `(part_number, e_tag)` per part.
724///
725/// `max_concurrency` is the caller's ceiling on in-flight parts; the effective
726/// count also honors a peak-memory budget derived from the server's actual
727/// `part_size` (see [`effective_in_flight`]).
728///
729/// Returns the parts sorted ascending by part number, ready for finalize.
730async fn upload_multipart(
731 configuration: &Configuration,
732 session: &models::UploadSessionResponse,
733 path: &Path,
734 total: u64,
735 max_concurrency: usize,
736 progress: Option<&UploadProgress>,
737) -> Result<Vec<models::FinalizeUploadPart>, UploadError> {
738 let part_urls = session.part_urls.clone().flatten().ok_or_else(|| {
739 UploadError::MalformedSession("multipart upload missing `part_urls`".to_owned())
740 })?;
741 let part_size = session.part_size.flatten().ok_or_else(|| {
742 UploadError::MalformedSession("multipart upload missing `part_size`".to_owned())
743 })?;
744 if part_size <= 0 {
745 return Err(UploadError::MalformedSession(format!(
746 "multipart upload has non-positive `part_size` {part_size}"
747 )));
748 }
749 let part_size = part_size as u64;
750
751 if part_urls.is_empty() {
752 return Err(UploadError::MalformedSession(
753 "multipart upload has empty `part_urls`".to_owned(),
754 ));
755 }
756
757 // The URL count must match the number of `part_size`-byte chunks the file
758 // splits into (last is the remainder). Too many URLs and we'd PUT a
759 // zero-length trailing part; too few and we'd finalize an incomplete list.
760 // Both mean a session inconsistent with our declared size, so fail loudly.
761 let expected_parts = total.div_ceil(part_size).max(1);
762 if part_urls.len() as u64 != expected_parts {
763 return Err(UploadError::MalformedSession(format!(
764 "multipart upload returned {} part URLs but the file ({total} bytes) \
765 splits into {expected_parts} parts of {part_size} bytes",
766 part_urls.len()
767 )));
768 }
769
770 // Peak buffered memory is in_flight * part_size; bound in-flight by both the
771 // caller's max_concurrency and the memory budget, using the SERVER's actual
772 // part size (the same value we slice by below).
773 let in_flight_cap = effective_in_flight(max_concurrency, part_size);
774
775 // Aggregate progress across parts via a shared counter; each part adds its
776 // own byte count once it lands (on success only — a re-swept part that
777 // failed an earlier round did not count, so bytes are never double-counted).
778 let done = Arc::new(AtomicU64::new(0));
779
780 // One plan per part. The last part carries the remainder; earlier parts are
781 // exactly `part_size`. A part starting at/after EOF (only possible for a
782 // zero-length file) is skipped rather than PUT as a zero-length object.
783 let mut plans: Vec<PartPlan> = Vec::with_capacity(part_urls.len());
784 for index in 0..part_urls.len() {
785 let offset = index as u64 * part_size;
786 if offset >= total && total > 0 {
787 continue;
788 }
789 let len = part_size.min(total.saturating_sub(offset));
790 plans.push(PartPlan {
791 index,
792 part_number: (index + 1) as i32,
793 offset,
794 len,
795 });
796 }
797
798 // Per-part uploader: a positioned read of exactly this part's byte range (so
799 // a re-read on retry never shares a cursor) then a header-isolated `PUT`.
800 // Captures only `Arc`s and `Copy` values, so the closure is
801 // `Clone + Send + Sync + 'static` and `upload_parts_resilient` can re-run it
802 // across rounds and concurrent tasks.
803 let part_urls = Arc::new(part_urls);
804 let headers = Arc::new(session.headers.clone());
805 let path = Arc::new(path.to_path_buf());
806 let retry = configuration.retry; // RetryPolicy is Copy.
807 let progress = progress.cloned();
808
809 let uploader = move |plan: PartPlan| {
810 let part_urls = Arc::clone(&part_urls);
811 let headers = Arc::clone(&headers);
812 let path = Arc::clone(&path);
813 let done = Arc::clone(&done);
814 let progress = progress.clone();
815 async move {
816 let url = part_urls[plan.index].clone();
817 let chunk = read_range(&path, plan.offset, plan.len).await?;
818 let resp = put_to_storage(
819 &retry,
820 &url,
821 &headers,
822 chunk,
823 plan.len,
824 Some(plan.part_number),
825 )
826 .await?;
827 let e_tag = parse_etag(resp.headers(), plan.part_number)?;
828 if let Some(progress) = progress.as_ref() {
829 let now = done.fetch_add(plan.len, Ordering::SeqCst) + plan.len;
830 progress(now, total);
831 }
832 Ok(models::FinalizeUploadPart {
833 e_tag,
834 part_number: plan.part_number,
835 })
836 }
837 };
838
839 // Resilient outer loop: a single part's transient failure no longer aborts
840 // the whole upload — it is re-swept on a later round while completed parts
841 // keep their ETags. `upload_parts_resilient` returns the parts ascending by
842 // part number with no duplicates.
843 upload_parts_resilient(plans, in_flight_cap, RetryRounds::default(), uploader).await
844}
845
846/// Streaming (just-in-time) multipart path: the session was opened WITHOUT a
847/// declared size, so the server minted no part URLs up front. We still know the
848/// local file's size, so the part count is fixed by the server's echoed
849/// `part_size`.
850///
851/// Each part mints a FRESH presigned URL immediately before its `PUT` (via
852/// `POST /v1/uploads/{id}/parts`), so a URL can never expire mid-transfer on a
853/// slow upload — and a part re-swept by [`upload_parts_resilient`] simply
854/// re-mints. This replaces the earlier batched pre-mint pipeline and its
855/// one-shot on-`403` re-mint: per-part minting is simpler and fully resilient,
856/// and with bounded concurrency the extra mint round-trip overlaps other parts'
857/// in-flight `PUT`s rather than serializing.
858///
859/// The deliberate cost is mint *request volume*: one `POST /parts` per part
860/// (up to [`TARGET_MAX_PARTS`]) instead of the old batched ≤100-per-call. We
861/// accept it because pre-minting a batch ahead is what made slow uploads fail —
862/// buffered URLs age in the queue and can expire before their part's `PUT` is
863/// reached on a constrained link. Minting each URL immediately before use keeps
864/// its age minimal, which is the whole point on the slow links this hardens.
865///
866/// Returns the parts sorted ascending by part number, ready for finalize.
867async fn upload_multipart_streaming(
868 configuration: &Configuration,
869 session: &models::UploadSessionResponse,
870 path: &Path,
871 total: u64,
872 max_concurrency: usize,
873 progress: Option<&UploadProgress>,
874) -> Result<Vec<models::FinalizeUploadPart>, UploadError> {
875 let part_size = session.part_size.flatten().ok_or_else(|| {
876 UploadError::MalformedSession("streaming upload missing `part_size`".to_owned())
877 })?;
878 if part_size <= 0 {
879 return Err(UploadError::MalformedSession(format!(
880 "streaming upload has non-positive `part_size` {part_size}"
881 )));
882 }
883 let part_size = part_size as u64;
884
885 // Slice by the SERVER's echoed part size (never our hint); the last part is
886 // the remainder. We know the file size, so the part count is fixed up front
887 // even though the server does not.
888 let expected_parts = total.div_ceil(part_size).max(1) as usize;
889
890 // Peak buffered memory is in_flight * part_size; bound in-flight by both the
891 // caller's max_concurrency and the memory budget (same as the eager path).
892 let in_flight_cap = effective_in_flight(max_concurrency, part_size);
893
894 // One plan per part (same shape as the known-size path). A part starting
895 // at/after EOF (only possible for a zero-length file) is skipped rather than
896 // PUT as a zero-length object.
897 let mut plans: Vec<PartPlan> = Vec::with_capacity(expected_parts);
898 for index in 0..expected_parts {
899 let offset = index as u64 * part_size;
900 if offset >= total && total > 0 {
901 continue;
902 }
903 let len = part_size.min(total.saturating_sub(offset));
904 plans.push(PartPlan {
905 index,
906 part_number: (index + 1) as i32,
907 offset,
908 len,
909 });
910 }
911
912 // Per-part uploader: mint a fresh URL for THIS part immediately before
913 // uploading it, then PUT. Captures only `Arc`s and `Copy` values, so the
914 // closure is `Clone + Send + Sync + 'static` and `upload_parts_resilient`
915 // can re-run it across rounds and concurrent tasks; a re-swept part re-mints
916 // a fresh URL, so expiry is impossible.
917 let config = Arc::new(configuration.clone());
918 let upload_id = Arc::new(session.upload_id.clone());
919 let finalize_token = Arc::new(session.finalize_token.clone());
920 let headers = Arc::new(session.headers.clone());
921 let path = Arc::new(path.to_path_buf());
922 let retry = configuration.retry;
923 let done = Arc::new(AtomicU64::new(0));
924 let progress = progress.cloned();
925
926 let uploader = move |plan: PartPlan| {
927 let config = Arc::clone(&config);
928 let upload_id = Arc::clone(&upload_id);
929 let finalize_token = Arc::clone(&finalize_token);
930 let headers = Arc::clone(&headers);
931 let path = Arc::clone(&path);
932 let done = Arc::clone(&done);
933 let progress = progress.clone();
934 async move {
935 let minted = apis::uploads_api::mint_upload_parts_handler(
936 &config,
937 &upload_id,
938 &finalize_token,
939 models::MintUploadPartsRequest::new(vec![plan.part_number]),
940 )
941 .await
942 .map_err(UploadError::MintParts)?;
943 let url = minted
944 .parts
945 .into_iter()
946 .find(|p| p.part_number == plan.part_number)
947 .map(|p| p.url)
948 .ok_or_else(|| {
949 UploadError::MalformedSession(format!(
950 "mint returned no URL for part {}",
951 plan.part_number
952 ))
953 })?;
954
955 let chunk = read_range(&path, plan.offset, plan.len).await?;
956 let resp = put_to_storage(
957 &retry,
958 &url,
959 &headers,
960 chunk,
961 plan.len,
962 Some(plan.part_number),
963 )
964 .await?;
965 let e_tag = parse_etag(resp.headers(), plan.part_number)?;
966 if let Some(progress) = progress.as_ref() {
967 let now = done.fetch_add(plan.len, Ordering::SeqCst) + plan.len;
968 progress(now, total);
969 }
970 Ok(models::FinalizeUploadPart {
971 e_tag,
972 part_number: plan.part_number,
973 })
974 }
975 };
976
977 // Same resilient outer loop as the known-size path: a transient part failure
978 // is re-swept on a later round (re-minting a fresh URL) instead of aborting
979 // the whole upload; completed parts keep their ETags.
980 upload_parts_resilient(plans, in_flight_cap, RetryRounds::default(), uploader).await
981}
982
983/// Extract and validate the storage `ETag` from a part `PUT` response. Rejects a
984/// missing OR empty/whitespace-only header: finalize needs a real ETag per part,
985/// and an empty value would be carried into the completion request only to fail
986/// (or silently corrupt) it later. Treated as [`UploadError::MissingETag`], so a
987/// re-sweep can re-`PUT` the part and pick up a real ETag.
988fn parse_etag(
989 headers: &reqwest::header::HeaderMap,
990 part_number: i32,
991) -> Result<String, UploadError> {
992 let etag = headers
993 .get(reqwest::header::ETAG)
994 .and_then(|v| v.to_str().ok())
995 .map(|s| s.to_owned())
996 .ok_or(UploadError::MissingETag { part_number })?;
997 if etag.trim().is_empty() {
998 return Err(UploadError::MissingETag { part_number });
999 }
1000 Ok(etag)
1001}
1002
1003/// Read exactly `len` bytes starting at `offset` from `path`. A positioned read
1004/// (seek + read_exact) so multipart part tasks never share a cursor and a retry
1005/// re-reads the same range cleanly.
1006async fn read_range(path: &Path, offset: u64, len: u64) -> Result<bytes::Bytes, UploadError> {
1007 use tokio::io::{AsyncReadExt, AsyncSeekExt};
1008
1009 let mut file = tokio::fs::File::open(path).await?;
1010 file.seek(std::io::SeekFrom::Start(offset)).await?;
1011 let mut buf = vec![0u8; len as usize];
1012 file.read_exact(&mut buf).await?;
1013 Ok(bytes::Bytes::from(buf))
1014}
1015
1016/// Connect-phase timeout for storage `PUT`s. Bounds only TCP+TLS establishment
1017/// (not the transfer), so it is safe for both the bounded multipart parts and
1018/// the unbounded single-`PUT` whole-file path. Generous: a healthy connect is
1019/// sub-second, so 30 s only trips a genuinely dead/black-holed endpoint.
1020const STORAGE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
1021
1022/// Fixed slack added to every per-part timeout for connect/TLS, request
1023/// queueing, and the response round-trip, independent of part size.
1024const PART_TIMEOUT_BASE: Duration = Duration::from_secs(60);
1025
1026/// Throughput floor used to size the per-part timeout. A part is only aborted if
1027/// it cannot sustain even this rate — 64 KiB/s (≈512 kbit/s), well below any
1028/// link on which an upload is worth attempting — so a legitimately slow but
1029/// progressing transfer is never killed; only a true stall is.
1030const PART_TIMEOUT_MIN_BYTES_PER_SEC: u64 = 64 * 1024;
1031
1032/// Operational ceiling on the per-part timeout. Without it a huge part (e.g. a
1033/// 5 GiB part on a multi-TB upload) would compute a ~22-hour timeout, so a
1034/// stalled giant part would hang for the better part of a day before the outer
1035/// loop could re-sweep it. 30 minutes still comfortably covers a legitimately
1036/// slow large part while keeping stall recovery bounded.
1037const PART_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
1038
1039/// Generous per-part total `PUT` timeout, scaled to the part size: a fixed base
1040/// plus the time the part would take at the throughput floor, capped at
1041/// [`PART_TIMEOUT_MAX`]. Examples: an 8 MiB part → ~188 s; a 64 MiB part →
1042/// ~18 min; anything above ~111 MiB → the 30 min cap. The goal is to catch a
1043/// stalled connection (which would otherwise hang the upload forever) without
1044/// aborting a healthy slow link — the outer [`upload_parts_resilient`] loop then
1045/// re-sweeps the timed-out part.
1046fn part_put_timeout(content_length: u64) -> Duration {
1047 (PART_TIMEOUT_BASE + Duration::from_secs(content_length / PART_TIMEOUT_MIN_BYTES_PER_SEC))
1048 .min(PART_TIMEOUT_MAX)
1049}
1050
1051/// `PUT` a body to a presigned storage URL with strict header isolation.
1052///
1053/// Attaches NONE of the SDK's auth/workspace/user-agent headers — a
1054/// presigned URL already carries its authorization, and an extra signed-ish
1055/// header makes S3-compatible storage return `403`. Only an explicit
1056/// `Content-Length` and the server-provided `headers` map (replayed verbatim;
1057/// currently always empty) are sent. A `Content-Type` is set ONLY when the
1058/// `headers` map includes one, so reqwest never auto-appends a charset.
1059///
1060/// Sent on the dedicated, header-bare [`storage_client`] with a generous,
1061/// part-size-scaled request timeout (see [`part_put_timeout`]) so a stalled
1062/// connection fails — into the outer retry loop — instead of hanging forever,
1063/// while a legitimately slow but progressing part is never aborted. The body
1064/// buffers in memory so it clones cleanly across retries via
1065/// [`crate::http::execute_retrying`]. Part `PUT`s are retryable: storage
1066/// overwrites a part by number, so a retried part is idempotent. `retry` is the
1067/// SDK's retry policy (carried on `Configuration`), used only for the retry
1068/// timing here.
1069async fn put_to_storage(
1070 retry: &crate::query::RetryPolicy,
1071 url: &str,
1072 headers: &HashMap<String, String>,
1073 body: bytes::Bytes,
1074 content_length: u64,
1075 part_number: Option<i32>,
1076) -> Result<reqwest::Response, UploadError> {
1077 let client = storage_client();
1078
1079 let mut req_builder = client
1080 .request(reqwest::Method::PUT, url)
1081 .header(reqwest::header::CONTENT_LENGTH, content_length);
1082
1083 // Replay the server-provided headers verbatim. Currently always empty; this
1084 // is the only place a Content-Type may be set, so reqwest can't auto-append
1085 // a charset.
1086 for (name, value) in headers {
1087 req_builder = req_builder.header(name.as_str(), value.as_str());
1088 }
1089
1090 // A buffered Bytes body clones cleanly, so 429 / pre-response-reset retries
1091 // in `execute_retrying` can re-send it.
1092 req_builder = req_builder.body(reqwest::Body::from(body));
1093
1094 // Per-PART total timeout, scaled to the part size. Bounds a single part so a
1095 // silently black-holed connection (no RST, write just stalls — which a
1096 // read/idle timeout would not catch) fails instead of hanging the whole
1097 // upload forever. NOT applied to the single-`PUT` whole-file path, which is
1098 // legitimately unbounded. `try_clone` in `execute_retrying` preserves this
1099 // per-request timeout, so every inner attempt gets a fresh full budget.
1100 req_builder = req_builder.timeout(part_put_timeout(content_length));
1101
1102 let req = req_builder.build().map_err(UploadError::Storage)?;
1103 crate::http_log::log_request(&req);
1104 let resp = crate::http::execute_retrying_unauthenticated(&client, req, retry)
1105 .await
1106 .map_err(UploadError::Storage)?;
1107
1108 let status = resp.status();
1109 crate::http_log::log_response_status(status);
1110 if status.is_client_error() || status.is_server_error() {
1111 let body = resp.text().await.unwrap_or_default();
1112 crate::http_log::log_response_body(&body);
1113 return Err(UploadError::StorageStatus {
1114 status,
1115 part_number,
1116 body,
1117 });
1118 }
1119 Ok(resp)
1120}
1121
1122/// `PUT` a streaming body to a presigned storage URL with the same strict
1123/// header isolation as [`put_to_storage`] (no SDK auth/scope headers; explicit
1124/// `Content-Length`; `Content-Type` only from the server `headers` map).
1125///
1126/// Used by the single-`PUT` path so progress is byte-granular. A streamed body
1127/// is not clonable, so this is a SINGLE attempt with no 429/reset retry — unlike
1128/// the buffered, retryable [`put_to_storage`] used per multipart part.
1129async fn put_stream_to_storage<S>(
1130 url: &str,
1131 headers: &HashMap<String, String>,
1132 body: S,
1133 content_length: u64,
1134) -> Result<reqwest::Response, UploadError>
1135where
1136 S: futures_core::Stream<Item = std::io::Result<bytes::Bytes>> + Send + 'static,
1137{
1138 let client = storage_client();
1139
1140 let mut req_builder = client
1141 .request(reqwest::Method::PUT, url)
1142 // Explicit Content-Length so the body is sized (not chunked) — storage
1143 // can reject an oversized upload up front, and reqwest honors it as the
1144 // framing for a wrapped stream.
1145 .header(reqwest::header::CONTENT_LENGTH, content_length);
1146
1147 for (name, value) in headers {
1148 req_builder = req_builder.header(name.as_str(), value.as_str());
1149 }
1150
1151 req_builder = req_builder.body(reqwest::Body::wrap_stream(body));
1152
1153 let req = req_builder.build().map_err(UploadError::Storage)?;
1154 crate::http_log::log_request(&req);
1155 // A streamed body can't be cloned, so send once (no retry helper).
1156 let resp = client.execute(req).await.map_err(UploadError::Storage)?;
1157
1158 let status = resp.status();
1159 crate::http_log::log_response_status(status);
1160 if status.is_client_error() || status.is_server_error() {
1161 let body = resp.text().await.unwrap_or_default();
1162 crate::http_log::log_response_body(&body);
1163 return Err(UploadError::StorageStatus {
1164 status,
1165 part_number: None,
1166 body,
1167 });
1168 }
1169 Ok(resp)
1170}
1171
1172/// The dedicated, process-wide reqwest client used for storage `PUT`s.
1173///
1174/// Deliberately NOT `configuration.client`: a host app may have installed
1175/// default headers (auth / workspace / `User-Agent` / `Content-Type`) on the
1176/// SDK's main client, which reqwest would then apply to the storage `PUT` —
1177/// making S3-compatible storage return `403 SignatureDoesNotMatch`. This client
1178/// is built bare: no default headers, and no request timeout (a large upload
1179/// legitimately takes minutes). It is built once and reused.
1180///
1181/// Trade-off: TLS / proxy / connection-pool settings on the SDK's main client
1182/// do NOT apply to storage `PUT`s — they go through this independent client.
1183/// That is intentional; storage transfers must be header-isolated, and a
1184/// host-configured proxy for the API host is not assumed to front object
1185/// storage.
1186fn storage_client() -> reqwest::Client {
1187 static STORAGE_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
1188 STORAGE_CLIENT
1189 .get_or_init(|| {
1190 reqwest::Client::builder()
1191 // No `default_headers` and no client-wide request `timeout` (the
1192 // single-`PUT` whole-file path is legitimately unbounded; the
1193 // multipart path bounds each part per-request — see
1194 // `part_put_timeout`). A connect timeout is safe for both: it
1195 // bounds only connection establishment, not the transfer, so a
1196 // dead endpoint fails fast into the retry/outer loop.
1197 .connect_timeout(STORAGE_CONNECT_TIMEOUT)
1198 .build()
1199 // Falls back to a plain default client if the builder somehow
1200 // fails (e.g. no TLS backend); still header-bare.
1201 .unwrap_or_default()
1202 })
1203 .clone()
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209
1210 /// The part count a given hint would produce for a file of `size`.
1211 fn part_count(size: u64, part: u64) -> u64 {
1212 size.div_ceil(part)
1213 }
1214
1215 #[test]
1216 fn auto_part_size_keeps_8mib_for_normal_files() {
1217 // Empty and small files default to 8 MiB.
1218 assert_eq!(auto_part_size_hint(0), DEFAULT_PART_SIZE);
1219 assert_eq!(auto_part_size_hint(1), DEFAULT_PART_SIZE);
1220 assert_eq!(auto_part_size_hint(100 * MIB), DEFAULT_PART_SIZE);
1221 assert_eq!(auto_part_size_hint(1024 * MIB), DEFAULT_PART_SIZE); // 1 GiB
1222 // Right at the boundary: 8 MiB * 9000 parts = 72 GiB still fits 8 MiB.
1223 let boundary = DEFAULT_PART_SIZE * TARGET_MAX_PARTS;
1224 assert_eq!(auto_part_size_hint(boundary), DEFAULT_PART_SIZE);
1225 }
1226
1227 #[test]
1228 fn auto_part_size_scales_up_for_very_large_files_and_caps_parts() {
1229 // Beyond ~72 GiB the hint must grow above 8 MiB.
1230 let big = 200 * 1024 * MIB; // 200 GiB
1231 let hint = auto_part_size_hint(big);
1232 assert!(
1233 hint > DEFAULT_PART_SIZE,
1234 "hint should scale above 8 MiB for a 200 GiB file, got {hint}"
1235 );
1236 // Hint is a whole number of MiB.
1237 assert_eq!(hint % MIB, 0, "hint must be a whole MiB, got {hint}");
1238 // Part count stays at or under the target ceiling.
1239 assert!(
1240 part_count(big, hint) <= TARGET_MAX_PARTS,
1241 "part count {} must be <= {TARGET_MAX_PARTS}",
1242 part_count(big, hint)
1243 );
1244 // And always within storage's accepted range.
1245 assert!((MIN_PART_SIZE..=MAX_PART_SIZE).contains(&hint));
1246 }
1247
1248 #[test]
1249 fn auto_part_size_clamps_to_max_for_enormous_files() {
1250 // A file so large the count-driven size would exceed 5 GiB clamps to the
1251 // 5 GiB ceiling (the part count then necessarily exceeds the soft target,
1252 // which is fine — it's a hint and the server has the final say).
1253 let enormous = 100 * 1024 * 1024 * MIB; // 100 PiB
1254 assert_eq!(auto_part_size_hint(enormous), MAX_PART_SIZE);
1255 }
1256
1257 #[test]
1258 fn effective_in_flight_capped_by_max_concurrency_for_small_parts() {
1259 // 8 MiB parts: budget allows 256/8 = 32, so max_concurrency wins.
1260 assert_eq!(effective_in_flight(12, 8 * MIB), 12);
1261 assert_eq!(effective_in_flight(10, 8 * MIB), 10);
1262 // A tiny part size still can't exceed max_concurrency.
1263 assert_eq!(effective_in_flight(12, MIB), 12);
1264 }
1265
1266 #[test]
1267 fn effective_in_flight_reduced_by_memory_budget_for_large_parts() {
1268 // 64 MiB parts: budget allows 256/64 = 4, below max_concurrency.
1269 assert_eq!(effective_in_flight(12, 64 * MIB), 4);
1270 // 128 MiB parts: 256/128 = 2.
1271 assert_eq!(effective_in_flight(12, 128 * MIB), 2);
1272 }
1273
1274 #[test]
1275 fn effective_in_flight_honors_explicit_low_concurrency() {
1276 // An explicit max_concurrency of 1 means serial uploads — NOT raised to a
1277 // floor of 2. (Regression guard for the Codex finding.)
1278 assert_eq!(effective_in_flight(1, 8 * MIB), 1);
1279 // 0 is normalized to 1 (you can't run zero in flight), not to 2.
1280 assert_eq!(effective_in_flight(0, 8 * MIB), 1);
1281 // 2 stays 2.
1282 assert_eq!(effective_in_flight(2, 8 * MIB), 2);
1283 }
1284
1285 #[test]
1286 fn effective_in_flight_floors_at_1_for_huge_parts_and_handles_zero() {
1287 // A part larger than the whole budget still keeps at least 1 in flight
1288 // (the budget can't bound below a single part).
1289 assert_eq!(effective_in_flight(12, UPLOAD_MEMORY_BUDGET * 4), 1);
1290 // Zero part size doesn't divide-by-zero (treated as 1 byte): the budget
1291 // then allows a huge count, so max_concurrency wins.
1292 assert_eq!(effective_in_flight(12, 0), 12);
1293 }
1294}
1295
1296#[cfg(test)]
1297mod resilient_retry_tests {
1298 use super::*;
1299 use std::collections::HashMap;
1300 use std::sync::atomic::AtomicUsize;
1301 use std::sync::Mutex;
1302
1303 fn plan(n: i32) -> PartPlan {
1304 PartPlan {
1305 index: (n - 1) as usize,
1306 part_number: n,
1307 offset: (n as u64 - 1) * 16,
1308 len: 16,
1309 }
1310 }
1311 fn plans(count: i32) -> Vec<PartPlan> {
1312 (1..=count).map(plan).collect()
1313 }
1314 fn no_delay(max_extra_rounds: u32) -> RetryRounds {
1315 RetryRounds {
1316 max_extra_rounds,
1317 base_delay: Duration::ZERO,
1318 }
1319 }
1320
1321 /// A transport-free stand-in for the real per-part uploader. It records
1322 /// attempts per part and can be told to fail the first K attempts of
1323 /// specific parts (modelling a part whose inner transport retries were
1324 /// exhausted by a network blip) before succeeding. Also tracks peak
1325 /// in-flight concurrency to verify the cap is honored.
1326 #[derive(Clone)]
1327 struct FakeUploader {
1328 fail: Arc<Mutex<HashMap<i32, usize>>>, // part_number -> remaining forced failures
1329 attempts: Arc<Mutex<HashMap<i32, usize>>>,
1330 in_flight: Arc<AtomicUsize>,
1331 peak_in_flight: Arc<AtomicUsize>,
1332 }
1333
1334 impl FakeUploader {
1335 fn new(fail: HashMap<i32, usize>) -> Self {
1336 Self {
1337 fail: Arc::new(Mutex::new(fail)),
1338 attempts: Arc::new(Mutex::new(HashMap::new())),
1339 in_flight: Arc::new(AtomicUsize::new(0)),
1340 peak_in_flight: Arc::new(AtomicUsize::new(0)),
1341 }
1342 }
1343 fn attempts_for(&self, n: i32) -> usize {
1344 *self.attempts.lock().unwrap().get(&n).unwrap_or(&0)
1345 }
1346 fn peak(&self) -> usize {
1347 self.peak_in_flight.load(Ordering::SeqCst)
1348 }
1349
1350 fn call(
1351 &self,
1352 plan: PartPlan,
1353 ) -> impl std::future::Future<Output = Result<models::FinalizeUploadPart, UploadError>>
1354 + Send
1355 + 'static {
1356 let fail = Arc::clone(&self.fail);
1357 let attempts = Arc::clone(&self.attempts);
1358 let in_flight = Arc::clone(&self.in_flight);
1359 let peak = Arc::clone(&self.peak_in_flight);
1360 async move {
1361 let cur = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1362 peak.fetch_max(cur, Ordering::SeqCst);
1363 // Force overlap so peak-in-flight reflects real concurrency.
1364 tokio::task::yield_now().await;
1365 *attempts
1366 .lock()
1367 .unwrap()
1368 .entry(plan.part_number)
1369 .or_insert(0) += 1;
1370 let should_fail = {
1371 let mut f = fail.lock().unwrap();
1372 match f.get_mut(&plan.part_number) {
1373 Some(remaining) if *remaining > 0 => {
1374 *remaining -= 1;
1375 true
1376 }
1377 _ => false,
1378 }
1379 };
1380 in_flight.fetch_sub(1, Ordering::SeqCst);
1381 if should_fail {
1382 Err(UploadError::Io(std::io::Error::other(
1383 "simulated connection reset",
1384 )))
1385 } else {
1386 Ok(models::FinalizeUploadPart {
1387 e_tag: format!("etag-{}", plan.part_number),
1388 part_number: plan.part_number,
1389 })
1390 }
1391 }
1392 }
1393 }
1394
1395 // ---- pure policy ----
1396
1397 #[test]
1398 fn round_in_flight_halves_each_round_min_one() {
1399 assert_eq!(round_in_flight(8, 0), 8);
1400 assert_eq!(round_in_flight(8, 1), 4);
1401 assert_eq!(round_in_flight(8, 2), 2);
1402 assert_eq!(round_in_flight(8, 3), 1);
1403 assert_eq!(round_in_flight(8, 99), 1);
1404 assert_eq!(round_in_flight(1, 3), 1);
1405 }
1406
1407 #[test]
1408 fn delay_for_grows_exponentially() {
1409 let r = RetryRounds {
1410 max_extra_rounds: 3,
1411 base_delay: Duration::from_secs(2),
1412 };
1413 assert_eq!(r.delay_for(1), Duration::from_secs(2));
1414 assert_eq!(r.delay_for(2), Duration::from_secs(4));
1415 assert_eq!(r.delay_for(3), Duration::from_secs(8));
1416 }
1417
1418 #[test]
1419 fn part_put_timeout_is_generous_and_scales_with_part_size() {
1420 // 8 MiB part: 60s base + 8MiB / 64KiB/s = 60 + 128 = 188s. Comfortably
1421 // above the ~3s an 8 MiB part takes on a healthy link, so a legit slow
1422 // transfer is never aborted; only a true stall trips it.
1423 assert_eq!(part_put_timeout(8 * 1024 * 1024), Duration::from_secs(188));
1424 // 64 MiB part stays generous (~18 min).
1425 assert_eq!(
1426 part_put_timeout(64 * 1024 * 1024),
1427 Duration::from_secs(60 + 1024)
1428 );
1429 // A tiny/empty part still gets the full fixed base.
1430 assert_eq!(part_put_timeout(0), Duration::from_secs(60));
1431 // Monotonic in part size (below the cap).
1432 assert!(part_put_timeout(32 * 1024 * 1024) > part_put_timeout(8 * 1024 * 1024));
1433 // A huge part is capped at the 30 min operational ceiling rather than the
1434 // ~22.8 h the raw formula would yield, so stall recovery stays bounded.
1435 assert_eq!(part_put_timeout(5 * 1024 * 1024 * 1024), PART_TIMEOUT_MAX);
1436 assert_eq!(part_put_timeout(u64::MAX), PART_TIMEOUT_MAX);
1437 }
1438
1439 #[test]
1440 fn terminal_errors_are_only_contract_violations() {
1441 assert!(is_terminal(&UploadError::MalformedSession("bad".into())));
1442 assert!(is_terminal(&UploadError::SizeOverflow {
1443 what: "x",
1444 value: 1,
1445 }));
1446 // Network-ish failures must stay retryable so the outer loop re-sweeps.
1447 assert!(!is_terminal(&UploadError::Io(std::io::Error::other(
1448 "reset"
1449 ))));
1450 assert!(!is_terminal(&UploadError::MissingETag { part_number: 1 }));
1451 assert!(!is_terminal(&UploadError::StorageStatus {
1452 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1453 part_number: Some(1),
1454 body: String::new(),
1455 }));
1456 }
1457
1458 #[test]
1459 fn parse_etag_rejects_missing_and_blank() {
1460 use reqwest::header::{HeaderMap, HeaderValue, ETAG};
1461 let mut ok = HeaderMap::new();
1462 ok.insert(ETAG, HeaderValue::from_static("\"etag-7\""));
1463 assert_eq!(parse_etag(&ok, 7).unwrap(), "\"etag-7\"");
1464
1465 // Missing header.
1466 assert!(matches!(
1467 parse_etag(&HeaderMap::new(), 7),
1468 Err(UploadError::MissingETag { part_number: 7 })
1469 ));
1470 // Present but empty / whitespace-only — must be rejected, not finalized.
1471 for blank in ["", " "] {
1472 let mut h = HeaderMap::new();
1473 h.insert(ETAG, HeaderValue::from_str(blank).unwrap());
1474 assert!(
1475 matches!(parse_etag(&h, 7), Err(UploadError::MissingETag { .. })),
1476 "blank ETag {blank:?} must be rejected"
1477 );
1478 }
1479 }
1480
1481 // ---- REPRODUCE: legacy behavior = one pass, no outer rounds ----
1482
1483 #[tokio::test]
1484 async fn repro_single_part_blip_sinks_whole_upload_without_rounds() {
1485 // Part 3 fails once. With NO extra rounds (the legacy abort-on-first-
1486 // exhaustion behavior) that single transient failure fails the entire
1487 // upload, discarding the work done on parts 1, 2, 4, 5.
1488 let fake = FakeUploader::new(HashMap::from([(3, 1)]));
1489 let f = fake.clone();
1490 let res = upload_parts_resilient(plans(5), 4, no_delay(0), move |p| f.call(p)).await;
1491 assert!(
1492 res.is_err(),
1493 "a single transient part failure should sink the upload under legacy (0-round) semantics"
1494 );
1495 }
1496
1497 // ---- FIX: outer rounds re-sweep only the failed parts ----
1498
1499 #[tokio::test]
1500 async fn fix_single_part_blip_recovers_on_a_later_round() {
1501 let fake = FakeUploader::new(HashMap::from([(3, 1)]));
1502 let f = fake.clone();
1503 let res = upload_parts_resilient(plans(5), 4, no_delay(3), move |p| f.call(p))
1504 .await
1505 .expect("the flaky part should recover on a later round");
1506 // All five parts present, ascending, with the right ETags.
1507 let nums: Vec<i32> = res.iter().map(|p| p.part_number).collect();
1508 assert_eq!(nums, vec![1, 2, 3, 4, 5]);
1509 assert_eq!(res[2].e_tag, "etag-3");
1510 // The flaky part was attempted twice (round 0 fail, round 1 success);
1511 // every healthy part exactly once — completed work is never redone.
1512 assert_eq!(fake.attempts_for(3), 2);
1513 for n in [1, 2, 4, 5] {
1514 assert_eq!(fake.attempts_for(n), 1, "part {n} must not be re-uploaded");
1515 }
1516 }
1517
1518 #[tokio::test]
1519 async fn fix_multiple_flaky_parts_all_recover() {
1520 let fake = FakeUploader::new(HashMap::from([(2, 2), (5, 1), (7, 3)]));
1521 let f = fake.clone();
1522 let res = upload_parts_resilient(plans(8), 4, no_delay(3), move |p| f.call(p))
1523 .await
1524 .expect("all parts should recover within the round budget");
1525 assert_eq!(res.len(), 8);
1526 assert_eq!(fake.attempts_for(2), 3); // 2 fails + success
1527 assert_eq!(fake.attempts_for(7), 4); // 3 fails + success
1528 assert_eq!(fake.attempts_for(5), 2);
1529 }
1530
1531 #[tokio::test]
1532 async fn permanent_failure_surfaced_after_exhausting_rounds() {
1533 // Part 4 always fails (more failures than rounds). After the initial
1534 // pass plus `max_extra_rounds` re-sweeps the upload gives up — but only
1535 // after exactly 1 + max_extra_rounds attempts of that part, and without
1536 // ever re-uploading the healthy parts.
1537 let fake = FakeUploader::new(HashMap::from([(4, 99)]));
1538 let f = fake.clone();
1539 let res = upload_parts_resilient(plans(5), 4, no_delay(2), move |p| f.call(p)).await;
1540 assert!(res.is_err());
1541 assert_eq!(fake.attempts_for(4), 3, "1 initial pass + 2 re-sweeps");
1542 for n in [1, 2, 3, 5] {
1543 assert_eq!(fake.attempts_for(n), 1);
1544 }
1545 }
1546
1547 #[tokio::test]
1548 async fn happy_path_uploads_each_part_exactly_once() {
1549 let fake = FakeUploader::new(HashMap::new());
1550 let f = fake.clone();
1551 let res = upload_parts_resilient(plans(6), 4, no_delay(3), move |p| f.call(p))
1552 .await
1553 .unwrap();
1554 assert_eq!(res.len(), 6);
1555 for n in 1..=6 {
1556 assert_eq!(fake.attempts_for(n), 1);
1557 }
1558 }
1559
1560 #[tokio::test]
1561 async fn concurrency_never_exceeds_base_cap() {
1562 let fake = FakeUploader::new(HashMap::new());
1563 let f = fake.clone();
1564 upload_parts_resilient(plans(20), 3, no_delay(3), move |p| f.call(p))
1565 .await
1566 .unwrap();
1567 assert!(
1568 fake.peak() <= 3,
1569 "peak in-flight {} exceeded the cap of 3",
1570 fake.peak()
1571 );
1572 }
1573
1574 #[tokio::test]
1575 async fn terminal_error_fails_fast_without_resweeping() {
1576 // A terminal error (server-contract violation) reproduces on every
1577 // re-sweep, so it must fail the upload immediately — NOT be retried for
1578 // all rounds the way a transient failure is.
1579 let p2_attempts = Arc::new(AtomicUsize::new(0));
1580 let counter = Arc::clone(&p2_attempts);
1581 let res = upload_parts_resilient(plans(4), 4, no_delay(3), move |plan: PartPlan| {
1582 let counter = Arc::clone(&counter);
1583 async move {
1584 if plan.part_number == 2 {
1585 counter.fetch_add(1, Ordering::SeqCst);
1586 Err(UploadError::MalformedSession("contract violation".into()))
1587 } else {
1588 Ok(models::FinalizeUploadPart {
1589 e_tag: format!("etag-{}", plan.part_number),
1590 part_number: plan.part_number,
1591 })
1592 }
1593 }
1594 })
1595 .await;
1596 assert!(matches!(res, Err(UploadError::MalformedSession(_))));
1597 assert_eq!(
1598 p2_attempts.load(Ordering::SeqCst),
1599 1,
1600 "a terminal error must be attempted once, never re-swept across rounds"
1601 );
1602 }
1603}