Skip to main content

rusty_cat/dflt/
default_http_transfer.rs

1use async_trait::async_trait;
2use reqwest::header::{CONTENT_LENGTH, ETAG};
3use reqwest::{Client, Method};
4use std::sync::Arc;
5use std::time::Duration;
6use tokio::fs::OpenOptions;
7use tokio::time::sleep;
8
9use crate::chunk_outcome::ChunkOutcome;
10use crate::direction::Direction;
11use crate::error::{InnerErrorCode, MeowError};
12use crate::http_breakpoint::{
13    BreakpointDownload, BreakpointUpload, DefaultStyleUpload, DownloadHeadCtx,
14    StandardRangeDownload, UploadPrepareCtx,
15};
16use crate::prepare_outcome::PrepareOutcome;
17use crate::transfer_executor_trait::TransferTrait;
18use crate::transfer_task::TransferTask;
19
20use super::default_http_transfer_chunks::{
21    download_one_chunk, download_one_chunk_part_positioned, map_reqwest, upload_one_chunk,
22    upload_one_chunk_part,
23};
24
25/// Creates default breakpoint protocol instances.
26pub(crate) fn default_breakpoint_arcs() -> (
27    Arc<dyn BreakpointUpload + Send + Sync>,
28    Arc<dyn BreakpointDownload + Send + Sync>,
29) {
30    (
31        Arc::new(DefaultStyleUpload::default()),
32        Arc::new(StandardRangeDownload::default()),
33    )
34}
35
36/// Maximum idle connections kept alive per host in the internal pool.
37///
38/// Connection reuse removes a TCP+TLS handshake from every chunk that follows
39/// the first one on the same host, which dominates per-chunk overhead on
40/// high-latency links. The cap stays bounded so long-lived SDK hosts do not
41/// accumulate idle sockets; callers that need a different policy can inject
42/// their own `reqwest::Client` via `MeowConfig::http_client`.
43const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 16;
44
45/// How long an idle pooled connection is retained before eviction.
46///
47/// Sequential chunks on one task are issued back-to-back (the inter-chunk gap
48/// is a local file read, i.e. milliseconds), so this comfortably keeps the
49/// connection warm within a transfer while trimming sockets left idle across a
50/// pause. A connection the server silently closed and we still reuse surfaces
51/// as `HttpError`, which every transfer path already retries, so reuse never
52/// turns a recoverable stale socket into a terminal failure.
53const DEFAULT_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
54
55/// Upper bound applied to the connect phase when building internal clients.
56///
57/// The total request timeout (`http_timeout`) must stay large enough for a slow
58/// chunk body to finish, which would otherwise let a dead TCP/TLS handshake
59/// hang for that whole budget. Capping only the connect phase fails an
60/// unreachable peer fast without shortening a slow-but-alive transfer. The
61/// effective value is `min(http_timeout, cap)`, so small total timeouts are
62/// never exceeded and behavior is unchanged when `http_timeout <= cap`.
63const DEFAULT_CONNECT_TIMEOUT_CAP: Duration = Duration::from_secs(10);
64
65/// Builds an internal `reqwest::Client` with the library's shared transport
66/// policy: a total request timeout, a bounded connect timeout for fast failure
67/// on unreachable peers, TCP keepalive, and a bounded idle connection pool for
68/// handshake reuse across chunks.
69///
70/// Centralizing this keeps every internally created client (the transfer
71/// backend and [`crate::MeowClient::http_client`]) on the exact same policy, so
72/// they can never drift apart.
73pub(crate) fn build_internal_client(
74    http_timeout: Duration,
75    tcp_keepalive: Duration,
76) -> Result<reqwest::Client, reqwest::Error> {
77    Client::builder()
78        .timeout(http_timeout)
79        // Fail fast on an unreachable peer while leaving the total budget for
80        // slow chunk bodies; see `DEFAULT_CONNECT_TIMEOUT_CAP`.
81        .connect_timeout(http_timeout.min(DEFAULT_CONNECT_TIMEOUT_CAP))
82        .tcp_keepalive(tcp_keepalive)
83        // Reuse idle connections to drop a handshake from every subsequent
84        // chunk. A stale socket reused after a pause comes back as `HttpError`,
85        // which all transfer paths retry, so reuse is safe; the bounded cap and
86        // idle timeout only keep idle sockets in check.
87        .pool_max_idle_per_host(DEFAULT_POOL_MAX_IDLE_PER_HOST)
88        .pool_idle_timeout(Some(DEFAULT_POOL_IDLE_TIMEOUT))
89        .build()
90}
91
92/// Built-in HTTP transfer backend based on `reqwest` and async file I/O.
93pub struct DefaultHttpTransfer {
94    /// Default shared HTTP client.
95    client: reqwest::Client,
96    /// Fallback upload protocol when task does not provide one.
97    fallback_upload: Arc<dyn BreakpointUpload + Send + Sync>,
98    /// Fallback download protocol when task does not provide one.
99    fallback_download: Arc<dyn BreakpointDownload + Send + Sync>,
100}
101
102impl DefaultHttpTransfer {
103    /// Creates a backend with default HTTP timeout and keepalive values.
104    ///
105    /// # Examples
106    ///
107    /// ```no_run
108    /// use rusty_cat::DefaultHttpTransfer;
109    ///
110    /// let backend = DefaultHttpTransfer::new();
111    /// let _ = backend;
112    /// ```
113    pub fn new() -> Self {
114        Self::with_http_timeouts(Duration::from_secs(5), Duration::from_secs(30))
115    }
116
117    /// Creates built-in backend with explicit timeout and keepalive values.
118    ///
119    /// # Range guidance
120    ///
121    /// - `http_timeout`: recommended `1s..=120s`
122    /// - `tcp_keepalive`: recommended `10s..=300s`
123    ///
124    /// # Examples
125    ///
126    /// ```no_run
127    /// use std::time::Duration;
128    /// use rusty_cat::DefaultHttpTransfer;
129    ///
130    /// let backend = DefaultHttpTransfer::with_http_timeouts(
131    ///     Duration::from_secs(15),
132    ///     Duration::from_secs(60),
133    /// );
134    /// let _ = backend;
135    /// ```
136    pub fn with_http_timeouts(http_timeout: Duration, tcp_keepalive: Duration) -> Self {
137        // Keep non-fallible constructor for compatibility.
138        // Prefer `try_with_http_timeouts` in new code for explicit errors.
139        let client = match build_internal_client(http_timeout, tcp_keepalive) {
140            Ok(c) => c,
141            Err(e) => {
142                crate::meow_warn_log!(
143                    "http_client",
144                    "with_http_timeouts build failed, fallback to Client::new(): {}",
145                    crate::log::redact_secrets(&e.to_string())
146                );
147                Client::new()
148            }
149        };
150        Self {
151            client,
152            fallback_upload: Arc::new(DefaultStyleUpload::default()),
153            fallback_download: Arc::new(StandardRangeDownload::default()),
154        }
155    }
156
157    /// Preferred fallible constructor with explicit error propagation.
158    ///
159    /// # Errors
160    ///
161    /// Returns `HttpClientBuildFailed` when `reqwest::Client` cannot be
162    /// constructed with the provided timeout/keepalive values.
163    ///
164    /// # Examples
165    ///
166    /// ```no_run
167    /// use std::time::Duration;
168    /// use rusty_cat::DefaultHttpTransfer;
169    ///
170    /// let backend = DefaultHttpTransfer::try_with_http_timeouts(
171    ///     Duration::from_secs(10),
172    ///     Duration::from_secs(30),
173    /// )?;
174    /// let _ = backend;
175    /// # Ok::<(), rusty_cat::api::MeowError>(())
176    /// ```
177    pub fn try_with_http_timeouts(
178        http_timeout: Duration,
179        tcp_keepalive: Duration,
180    ) -> Result<Self, MeowError> {
181        let client = build_internal_client(http_timeout, tcp_keepalive)
182            .map_err(|e| {
183                MeowError::from_source(
184                    InnerErrorCode::HttpClientBuildFailed,
185                    format!(
186                        "build reqwest client failed (timeout={:?}, keepalive={:?})",
187                        http_timeout, tcp_keepalive
188                    ),
189                    e,
190                )
191            })?;
192        Ok(Self {
193            client,
194            fallback_upload: Arc::new(DefaultStyleUpload::default()),
195            fallback_download: Arc::new(StandardRangeDownload::default()),
196        })
197    }
198
199    /// Creates backend with an externally provided `reqwest::Client`.
200    ///
201    /// # Examples
202    ///
203    /// ```no_run
204    /// use rusty_cat::DefaultHttpTransfer;
205    ///
206    /// let reqwest_client = reqwest::Client::new();
207    /// let backend = DefaultHttpTransfer::with_client(reqwest_client);
208    /// let _ = backend;
209    /// ```
210    pub fn with_client(client: reqwest::Client) -> Self {
211        Self {
212            client,
213            fallback_upload: Arc::new(DefaultStyleUpload::default()),
214            fallback_download: Arc::new(StandardRangeDownload::default()),
215        }
216    }
217
218    /// Creates backend with explicit fallback upload/download protocol plugins.
219    ///
220    /// Task-level protocol instances still take precedence when present.
221    ///
222    /// # Examples
223    ///
224    /// ```no_run
225    /// use std::sync::Arc;
226    /// use rusty_cat::{DefaultHttpTransfer, DefaultStyleUpload, StandardRangeDownload};
227    ///
228    /// let backend = DefaultHttpTransfer::with_fallbacks(
229    ///     reqwest::Client::new(),
230    ///     Arc::new(DefaultStyleUpload::default()),
231    ///     Arc::new(StandardRangeDownload::default()),
232    /// );
233    /// let _ = backend;
234    /// ```
235    pub fn with_fallbacks(
236        client: reqwest::Client,
237        upload: Arc<dyn BreakpointUpload + Send + Sync>,
238        download: Arc<dyn BreakpointDownload + Send + Sync>,
239    ) -> Self {
240        Self {
241            client,
242            fallback_upload: upload,
243            fallback_download: download,
244        }
245    }
246
247    /// Selects HTTP client for a task.
248    fn client_for(&self, task: &TransferTask) -> reqwest::Client {
249        task.http_client_ref()
250            .cloned()
251            .unwrap_or_else(|| self.client.clone())
252    }
253
254    /// Selects upload protocol implementation for a task.
255    fn upload_arc(&self, task: &TransferTask) -> Arc<dyn BreakpointUpload + Send + Sync> {
256        match task.breakpoint_upload() {
257            Some(a) => a.clone(),
258            None => self.fallback_upload.clone(),
259        }
260    }
261
262    /// Selects download protocol implementation for a task.
263    fn download_arc(&self, task: &TransferTask) -> Arc<dyn BreakpointDownload + Send + Sync> {
264        match task.breakpoint_download() {
265            Some(a) => a.clone(),
266            None => self.fallback_download.clone(),
267        }
268    }
269}
270
271impl Default for DefaultHttpTransfer {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277async fn upload_prepare(
278    client: &reqwest::Client,
279    task: &TransferTask,
280    upload: Arc<dyn BreakpointUpload + Send + Sync>,
281    local_offset: u64,
282) -> Result<PrepareOutcome, MeowError> {
283    let max_retries = task.max_upload_prepare_retries();
284    let mut attempt: u32 = 0;
285    loop {
286        crate::meow_flow_log!(
287            "upload_prepare",
288            "start: file={} local_offset={} total={} attempt={} max_retries={}",
289            task.file_name(),
290            local_offset,
291            task.total_size(),
292            attempt,
293            max_retries
294        );
295        match upload_prepare_once(client, task, upload.clone(), local_offset).await {
296            Ok(outcome) => {
297                if attempt > 0 {
298                    crate::meow_key_log!(
299                        "upload_prepare",
300                        "prepare retry recovered: file={} attempts_used={}",
301                        task.file_name(),
302                        attempt
303                    );
304                }
305                return Ok(outcome);
306            }
307            Err(err) => {
308                let retryable = crate::inner::exec_impl::retry::is_transport_retryable(&err);
309                let reached_limit = attempt >= max_retries;
310                if !retryable || reached_limit {
311                    crate::log::emit_lazy(|| {
312                        let mut log = crate::log::Log::error(
313                            "upload_prepare",
314                            format!(
315                                "prepare give up: file={} attempt={} max_retries={} retryable={} err={}",
316                                task.file_name(),
317                                attempt,
318                                max_retries,
319                                retryable,
320                                crate::log::redact_secrets(&err.to_string())
321                            ),
322                        )
323                        .with_key(task.file_name())
324                        .with_offset(local_offset)
325                        .with_attempt(attempt)
326                        .with_max_retries(max_retries);
327                        if let Some(s) = err.http_status() {
328                            log = log.with_http_status(s);
329                        }
330                        log
331                    });
332                    return Err(err);
333                }
334                let delay_ms = crate::inner::exec_impl::retry::calc_backoff_with_jitter_ms(attempt);
335                crate::meow_warn_log!(
336                    "upload_prepare",
337                    "prepare retry scheduled: file={} next_attempt={} delay_ms={} err={}",
338                    task.file_name(),
339                    attempt + 1,
340                    delay_ms,
341                    crate::log::redact_secrets(&err.to_string())
342                );
343                sleep(Duration::from_millis(delay_ms)).await;
344                attempt += 1;
345            }
346        }
347    }
348}
349
350async fn upload_prepare_once(
351    client: &reqwest::Client,
352    task: &TransferTask,
353    upload: Arc<dyn BreakpointUpload + Send + Sync>,
354    local_offset: u64,
355) -> Result<PrepareOutcome, MeowError> {
356    let info = upload
357        .prepare(UploadPrepareCtx {
358            client,
359            task,
360            local_offset,
361        })
362        .await?;
363    crate::meow_key_log!(
364        "upload_prepare",
365        "prepare protocol completed: file={} local_offset={}",
366        task.file_name(),
367        local_offset
368    );
369    if info.completed_file_id.is_some() {
370        let total = task.total_size();
371        crate::meow_key_log!(
372            "upload_prepare",
373            "server indicates upload already complete: file={} total={}",
374            task.file_name(),
375            total
376        );
377        return Ok(PrepareOutcome {
378            next_offset: total,
379            total_size: total,
380        });
381    }
382    let server_off = info.next_byte.unwrap_or(0);
383    let next = local_offset.max(server_off).min(task.total_size());
384    crate::meow_flow_log!(
385        "upload_prepare",
386        "prepared: server_next={} local_offset={} final_next={}",
387        server_off,
388        local_offset,
389        next
390    );
391    Ok(PrepareOutcome {
392        next_offset: next,
393        total_size: task.total_size(),
394    })
395}
396
397/// Whether this download task should take the concurrent path (same condition
398/// as the executor gate, evaluable from a task snapshot).
399fn download_is_parallel(
400    task: &TransferTask,
401    download: &Arc<dyn BreakpointDownload + Send + Sync>,
402) -> bool {
403    task.direction() == Direction::Download
404        && task.max_parts_in_flight() > 1
405        && download.supports_parallel_parts()
406}
407
408/// Stable identity for a download target, binding a `.rcdl` sidecar to this URL
409/// so a same-path/different-URL re-download does not reuse stale bits. A cheap
410/// FNV-1a of the range URL is sufficient (no crypto needed).
411fn download_identity(url: &str) -> String {
412    let mut h: u64 = 0xcbf29ce484222325;
413    for b in url.as_bytes() {
414        h ^= *b as u64;
415        h = h.wrapping_mul(0x00000100000001B3);
416    }
417    format!("{h:016x}")
418}
419
420/// Shared tail of [`download_prepare`]: given a resolved remote `total`
421/// (`0` == unknown) and the local resume `start`, either drives the concurrent
422/// pre-size + `.rcdl` sidecar path, or the serial length-based path. Splitting
423/// this out lets every size source (hint / `with_total_size` / HEAD) run the
424/// exact same branch without duplicating it or re-indenting the HEAD block.
425async fn download_prepare_finish(
426    task: &TransferTask,
427    download: &Arc<dyn BreakpointDownload + Send + Sync>,
428    start: u64,
429    total: u64,
430) -> Result<PrepareOutcome, MeowError> {
431    let path = task.file_path();
432    if download_is_parallel(task, download) {
433        if total == 0 {
434            // Unknown size cannot be windowed; let the caller fall back to serial.
435            return Ok(PrepareOutcome {
436                next_offset: 0,
437                total_size: 0,
438            });
439        }
440        if let Some(parent) = path.parent() {
441            if !parent.as_os_str().is_empty() {
442                tokio::fs::create_dir_all(parent).await.map_err(|e| {
443                    MeowError::from_io(
444                        format!("create download dir failed: {}", parent.display()),
445                        e,
446                    )
447                })?;
448            }
449        }
450        // Load (or create) the sidecar BEFORE presizing the target file. The
451        // sidecar's own safety guard only invalidates a stale `.rcdl` when the
452        // target's on-disk length differs from `total`; if we presized first,
453        // the length would already equal `total` and the guard could never
454        // fire, letting a stale bitmap survive a deleted/truncated target.
455        let identity = download_identity(&download.range_url(task));
456        let progress = crate::dflt::download_progress::DownloadProgress::load_or_create(
457            path,
458            total,
459            task.chunk_size(),
460            task.max_parts_in_flight(),
461            &identity,
462        )
463        .map_err(|e| MeowError::from_io("load .rcdl sidecar failed".to_string(), e))?;
464        let watermark = progress.contiguous_watermark();
465
466        // Pre-size once so every part can positioned-write into its slot. Never
467        // truncate: on resume the file already holds partial bytes that the
468        // sidecar bitmap accounts for; `set_len(total)` only fixes the length.
469        let file = OpenOptions::new()
470            .write(true)
471            .create(true)
472            .truncate(false)
473            .open(path)
474            .await
475            .map_err(|e| {
476                MeowError::from_io(format!("open for presize failed: {}", path.display()), e)
477            })?;
478        file.set_len(total)
479            .await
480            .map_err(|e| MeowError::from_io("presize set_len failed".to_string(), e))?;
481        file.sync_all()
482            .await
483            .map_err(|e| MeowError::from_io("presize sync failed".to_string(), e))?;
484        drop(file);
485
486        if let Ok(mut slot) = task.download_progress().try_lock() {
487            *slot = Some(progress);
488        } else {
489            // The slot is task-owned and only touched here before dispatch; a
490            // contended lock is an internal invariant violation.
491            return Err(MeowError::from_code_str(
492                InnerErrorCode::InvalidTaskState,
493                "download progress slot unexpectedly locked during prepare",
494            ));
495        }
496        crate::meow_key_log!(
497            "download_prepare",
498            "prepared concurrent download: resume_watermark={} remote_total={}",
499            watermark,
500            total
501        );
502        return Ok(PrepareOutcome {
503            next_offset: watermark,
504            total_size: total,
505        });
506    }
507
508    // Serial path: guard against silently "completing" a pre-sized file left by
509    // a prior parallel run (its length == total would otherwise look finished).
510    if crate::dflt::download_progress::DownloadProgress::sidecar_exists(path) {
511        return Err(MeowError::from_code_str(
512            InnerErrorCode::InvalidTaskState,
513            "found an in-progress parallel download sidecar (.rcdl); resume with \
514             max_parts_in_flight > 1, or delete the sidecar and the partial file",
515        ));
516    }
517
518    // Existing serial length-based outcome (resume from the local length).
519    if start > total {
520        crate::log::emit_lazy(|| {
521            crate::log::Log::error(
522                "download_prepare",
523                format!(
524                    "invalid local length larger than remote: local={} remote={}",
525                    start, total
526                ),
527            )
528            .with_key(task.file_name())
529            .with_offset(start)
530        });
531        return Err(MeowError::from_code_str(
532            InnerErrorCode::InvalidRange,
533            "local file larger than remote total size",
534        ));
535    }
536    if start >= total {
537        crate::meow_key_log!(
538            "download_prepare",
539            "already complete by local length: local={} remote={}",
540            start,
541            total
542        );
543        return Ok(PrepareOutcome {
544            next_offset: total,
545            total_size: total,
546        });
547    }
548    crate::meow_key_log!(
549        "download_prepare",
550        "prepared resume offset: start={} remote_total={}",
551        start,
552        total
553    );
554    Ok(PrepareOutcome {
555        next_offset: start,
556        total_size: total,
557    })
558}
559
560/// Runs download prepare stage and computes resume offset/total size.
561async fn download_prepare(
562    client: &reqwest::Client,
563    task: &TransferTask,
564    download: Arc<dyn BreakpointDownload + Send + Sync>,
565    _local_offset: u64,
566) -> Result<PrepareOutcome, MeowError> {
567    crate::meow_flow_log!(
568        "download_prepare",
569        "start: file={} path={}",
570        task.file_name(),
571        task.file_path().display()
572    );
573    let path = task.file_path();
574    let local_len = match tokio::fs::metadata(path).await {
575        Ok(meta) => meta.len(),
576        // A missing file here simply means "no local progress yet"; treat it as
577        // a fresh download rather than a mid-transfer removal.
578        Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0u64,
579        Err(e) => {
580            crate::log::emit_lazy(|| {
581                crate::log::Log::error(
582                    "download_prepare",
583                    format!("stat failed: path={} err={}", path.display(), e),
584                )
585                .with_key(task.file_name())
586            });
587            return Err(MeowError::from_io(
588                format!("download_prepare stat failed: {}", path.display()),
589                e,
590            ));
591        }
592    };
593
594    // Use local persisted length as resume start to avoid sparse gaps.
595    let start = local_len;
596
597    // Resolve the remote total size. Order (first non-zero source wins):
598    //   1) protocol `total_size_hint` (e.g. presigned downloads),
599    //   2) builder-supplied `task.total_size()` (`with_total_size`),
600    //   3) a HEAD request (only when both hints are absent/zero).
601    // Whichever source resolves `total`, the same parallel/serial branch runs.
602    if let Some(hinted) = download.total_size_hint(task) {
603        crate::meow_key_log!(
604            "download_prepare",
605            "resolved total from total_size_hint: start={} remote_total={}",
606            start,
607            hinted
608        );
609        return download_prepare_finish(task, &download, start, hinted).await;
610    }
611    if task.total_size() > 0 {
612        // Builder supplied a known size via with_total_size(): skip HEAD.
613        let hinted = task.total_size();
614        crate::meow_key_log!(
615            "download_prepare",
616            "resolved total from with_total_size: start={} remote_total={}",
617            start,
618            hinted
619        );
620        return download_prepare_finish(task, &download, start, hinted).await;
621    }
622
623    let head_url = download.head_url(task);
624    let mut head_headers = task.headers().clone();
625    download
626        .merge_head_headers(DownloadHeadCtx {
627            task,
628            base: &mut head_headers,
629        })
630        .map_err(|e| {
631            crate::log::emit_lazy(|| {
632                crate::log::Log::warn(
633                    "head",
634                    format!("merge_head_headers failed: err={}", crate::log::redact_secrets(&e.to_string())),
635                )
636                .with_key(task.file_name())
637                .with_url(head_url.as_str())
638            });
639            e
640        })?;
641    let head_resp = client
642        .request(Method::HEAD, &head_url)
643        .headers(head_headers)
644        .send()
645        .await
646        .map_err(|e| {
647            crate::log::emit_lazy(|| {
648                crate::log::Log::error(
649                    "head",
650                    format!("HEAD send failed: err={}", crate::log::redact_secrets(&e.to_string())),
651                )
652                .with_key(task.file_name())
653                .with_url(head_url.as_str())
654            });
655            map_reqwest(e)
656        })?;
657    if !head_resp.status().is_success() {
658        let head_status = head_resp.status();
659        crate::log::emit_lazy(|| {
660            crate::log::Log::error(
661                "head",
662                format!("head failed: status={}", head_status),
663            )
664            .with_key(task.file_name())
665            .with_http_status(head_status.as_u16())
666            .with_url(head_url.as_str())
667        });
668        return Err(MeowError::from_code(
669            InnerErrorCode::ResponseStatusError,
670            format!("download_prepare HEAD failed: {}", head_resp.status()),
671        )
672        .with_http_status(head_resp.status().as_u16()));
673    }
674    let head_content_length = head_resp
675        .headers()
676        .get(CONTENT_LENGTH)
677        .and_then(|v| v.to_str().ok())
678        .unwrap_or("<missing>");
679    let head_etag = head_resp
680        .headers()
681        .get(ETAG)
682        .and_then(|v| v.to_str().ok())
683        .unwrap_or("<missing>");
684    crate::meow_flow_log!(
685        "download_prepare",
686        "head metadata: url={} content_length={} etag={}",
687        crate::log::sanitize_url(&head_url),
688        head_content_length,
689        head_etag
690    );
691    let total = download
692        .total_size_from_head(head_resp.headers())
693        .map_err(|e| {
694            crate::log::emit_lazy(|| {
695                crate::log::Log::error(
696                    "head",
697                    format!("total_size_from_head parse failed: err={}", crate::log::redact_secrets(&e.to_string())),
698                )
699                .with_key(task.file_name())
700                .with_url(head_url.as_str())
701            });
702            e
703        })?;
704    // HEAD resolved the size; run the shared parallel/serial branch.
705    download_prepare_finish(task, &download, start, total).await
706}
707
708#[async_trait]
709impl TransferTrait for DefaultHttpTransfer {
710    /// Prepares transfer execution according to task direction.
711    async fn prepare(
712        &self,
713        task: &TransferTask,
714        local_offset: u64,
715    ) -> Result<PrepareOutcome, MeowError> {
716        let client = self.client_for(task);
717        match task.direction() {
718            Direction::Upload => {
719                upload_prepare(&client, task, self.upload_arc(task), local_offset).await
720            }
721            Direction::Download => {
722                download_prepare(&client, task, self.download_arc(task), local_offset).await
723            }
724        }
725    }
726
727    /// Transfers one chunk according to task direction.
728    async fn transfer_chunk(
729        &self,
730        task: &TransferTask,
731        offset: u64,
732        chunk_size: u64,
733        remote_total_size: u64,
734    ) -> Result<ChunkOutcome, MeowError> {
735        let client = self.client_for(task);
736        match task.direction() {
737            Direction::Upload => {
738                upload_one_chunk(&client, task, self.upload_arc(task), offset, chunk_size).await
739            }
740            Direction::Download => {
741                download_one_chunk(
742                    &client,
743                    task,
744                    self.download_arc(task),
745                    offset,
746                    chunk_size,
747                    remote_total_size,
748                )
749                .await
750            }
751        }
752    }
753
754    /// Handles task cancel; upload direction may trigger protocol abort.
755    async fn cancel(&self, task: &TransferTask) -> Result<(), MeowError> {
756        if task.direction() != Direction::Upload {
757            return Ok(());
758        }
759        let client = self.client_for(task);
760        self.upload_arc(task).abort_upload(&client, task).await
761    }
762
763    /// Parallel parts are offered for uploads whose resolved protocol proves
764    /// out-of-order safety, and for downloads whose range protocol declares it.
765    fn supports_parallel_parts(&self, task: &TransferTask) -> bool {
766        match task.direction() {
767            Direction::Upload => self.upload_arc(task).supports_parallel_parts(),
768            Direction::Download => self.download_arc(task).supports_parallel_parts(),
769        }
770    }
771
772    /// Uploads one chunk without finalizing (parallel path). Completion is run
773    /// exactly once by the scheduler via [`Self::complete`].
774    async fn transfer_chunk_part(
775        &self,
776        task: &TransferTask,
777        offset: u64,
778        chunk_size: u64,
779        remote_total_size: u64,
780    ) -> Result<ChunkOutcome, MeowError> {
781        let client = self.client_for(task);
782        match task.direction() {
783            Direction::Upload => {
784                upload_one_chunk_part(&client, task, self.upload_arc(task), offset, chunk_size).await
785            }
786            Direction::Download => {
787                // Resume short-circuit: a part already recorded done in the
788                // sidecar needs no network I/O. Keep the lock scope short and
789                // never hold it across the network call below.
790                {
791                    let guard = task.download_progress().lock().await;
792                    if let Some(p) = guard.as_ref() {
793                        if p.is_done(offset) {
794                            return Ok(ChunkOutcome {
795                                next_offset: (offset + chunk_size).min(remote_total_size),
796                                total_size: remote_total_size,
797                                done: (offset + chunk_size) >= remote_total_size,
798                                completion_payload: None,
799                            });
800                        }
801                    }
802                }
803                let outcome = download_one_chunk_part_positioned(
804                    &client,
805                    task,
806                    self.download_arc(task),
807                    offset,
808                    chunk_size,
809                    remote_total_size,
810                )
811                .await?;
812                // Bytes are durably written (sync_data) — now record the bit.
813                {
814                    let mut guard = task.download_progress().lock().await;
815                    if let Some(p) = guard.as_mut() {
816                        p.mark_done_and_persist(offset).map_err(|e| {
817                            MeowError::from_io("persist .rcdl progress failed".to_string(), e)
818                        })?;
819                    }
820                }
821                Ok(outcome)
822            }
823        }
824    }
825
826    /// Finalizes a transfer after all parts have been transferred.
827    ///
828    /// Upload delegates to the protocol's `complete_upload`. Download validates
829    /// the concurrent path's result (pre-sized length matches `total` and every
830    /// part is recorded done) and then drops the `.rcdl` sidecar; serial
831    /// downloads finalize inline and never set up progress, so they no-op here.
832    async fn complete(&self, task: &TransferTask) -> Result<Option<String>, MeowError> {
833        match task.direction() {
834            Direction::Upload => {
835                let client = self.client_for(task);
836                self.upload_arc(task).complete_upload(&client, task).await
837            }
838            Direction::Download => {
839                // Only the concurrent path sets up progress; serial downloads
840                // never reach complete() with progress (they finalize inline).
841                let progress = {
842                    let mut guard = task.download_progress().lock().await;
843                    guard.take()
844                };
845                if let Some(p) = progress {
846                    let expected = p.total();
847                    let actual = tokio::fs::metadata(task.file_path())
848                        .await
849                        .map(|m| m.len())
850                        .map_err(|e| {
851                            MeowError::from_io("stat completed download failed".to_string(), e)
852                        })?;
853                    if actual != expected {
854                        return Err(MeowError::from_code(
855                            InnerErrorCode::InvalidRange,
856                            format!(
857                                "download length mismatch on complete: expected {expected}, got {actual}"
858                            ),
859                        ));
860                    }
861                    if !p.all_done() {
862                        return Err(MeowError::from_code_str(
863                            InnerErrorCode::InvalidRange,
864                            "download complete called before all parts recorded done",
865                        ));
866                    }
867                    // Success: drop the sidecar. Best effort; a leftover .rcdl is
868                    // re-validated (and ignored) on any future download.
869                    if let Err(e) = p.delete() {
870                        crate::meow_warn_log!("download_complete", "sidecar delete failed: {}", e);
871                    }
872                }
873                Ok(None)
874            }
875        }
876    }
877}