Files
LocalAI/pkg/downloader/retry.go
mudler's LocalAI [bot] 16033d562a fix(downloader): bound the wait for response headers so a wedged origin cannot hang an install forever (#11053)
A gallery model install hung for 94 minutes with zero bytes transferred, no
error, no retry and no abort, leaving a partial tree frozen at 18G. The last
log line was the download starting, then silence:

    14:06:19 INFO Downloading url=".../LongCat-Video-Avatar-1.5/resolve/<rev>/base_model/diffusion_pytorch_model-000..."

The retry machinery from #10985 was working (two retries fired at 14:05:01 and
14:06:14); the third attempt simply never returned. The install never
completed, the model config was never written, and nothing surfaced the
failure.

The stall watchdog added earlier wraps the response *body*, so it only starts
guarding once downloadClient.Do() has returned. The transport had no
ResponseHeaderTimeout, so a peer that completes the dial and TLS handshake,
reads the request, and then never sends a status line parks Do() for the
process lifetime. IdleConnTimeout governs pooled idle connections, not an
in-flight request. Both the body request and the HEAD that probes for Range
support were unguarded.

Bound the header wait at the transport, not the client: a client-level Timeout
would also bound the body and truncate multi-tens-of-GB downloads. The knob is
opt-in (WithResponseHeaderTimeout) rather than a default in HardenedTransport,
because a streaming endpoint may legitimately withhold headers until it has
something to say, and capping that would break the streaming clients that share
this constructor.

Also fix a classification trap this exposed: net/http reports a
ResponseHeaderTimeout as an error satisfying errors.Is(err,
context.DeadlineExceeded), which IsRetryable read as "the caller gave up" and
refused to retry. An explicit transient marking now outranks the cancellation
sentinels; a caller who genuinely gave up is still caught by the ctx.Err()
check. The resume probe's error is likewise marked transient, so a momentarily
wedged origin no longer turns a resumable download into a hard install failure.

Third defect found in this download path, after #10985 (read vs write errors
conflated) and #11026 (hash verification emitted no progress and an expired
deadline returned success).


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-22 18:28:51 +02:00

102 lines
3.5 KiB
Go

package downloader
import (
"context"
"errors"
"io"
"time"
)
// ErrTransientDownload marks a download failure that a later attempt has a
// real chance of getting past: the peer cancelled the stream, the connection
// dropped, the transfer stalled, or the server answered 5xx. Everything else
// is treated as permanent, because retrying it either loops on a condition
// that will never change (404, checksum mismatch, a full disk) or burns
// bandwidth re-fetching gigabytes for nothing.
var ErrTransientDownload = errors.New("transient download failure")
// DownloadRetryAttempts bounds how many times a single file is attempted
// before the whole plan gives up. These are multi-GB transfers, so the budget
// is deliberately small: the resume path means a retry is cheap in bytes, but
// an aggressive retry loop against a genuinely broken remote is its own
// outage. Overridable so tests (and operators on very flaky links) can adjust.
var DownloadRetryAttempts = 3
// DownloadRetryBaseDelay is the first backoff interval; each further retry
// doubles it. Backoff exists to let a momentarily overloaded remote recover,
// not to wait out a long outage, hence the small base and the low attempt cap.
var DownloadRetryBaseDelay = 2 * time.Second
// transientError wraps an error while keeping its message intact, so the
// caller-facing diagnostics stay exactly as informative as before and only the
// retry classification changes.
type transientError struct{ err error }
func (e *transientError) Error() string { return e.err.Error() }
func (e *transientError) Unwrap() error { return e.err }
func (e *transientError) Is(target error) bool { return target == ErrTransientDownload }
// asTransient marks err retryable. A nil error stays nil so call sites can
// wrap unconditionally.
func asTransient(err error) error {
if err == nil {
return nil
}
return &transientError{err: err}
}
// IsRetryable reports whether another attempt is worth making. A cancelled
// caller is never retried through, regardless of how the failure was
// classified: the caller has already given up.
func IsRetryable(ctx context.Context, err error) bool {
if err == nil || ctx.Err() != nil {
return false
}
if errors.Is(err, ErrUserCancelled) {
return false
}
// An explicit transient marking outranks the cancellation sentinels. A
// transport guard firing on a wedged peer (net/http reports a
// ResponseHeaderTimeout as context.DeadlineExceeded) is *our* abort, not
// the caller's, and must still be retried. A caller who genuinely gave up
// is already caught by the ctx.Err() check above, so nothing that should
// stop is let through here.
if errors.Is(err, ErrTransientDownload) {
return true
}
return false
}
// readErrorRecorder remembers the last non-EOF error the source returned.
// io.Copy folds read and write failures into one return value; comparing the
// copy error against the recorded one is what lets the caller say which side
// of the transfer actually failed.
type readErrorRecorder struct {
r io.Reader
err error
}
func (t *readErrorRecorder) Read(p []byte) (int, error) {
n, err := t.r.Read(p)
if err != nil && !errors.Is(err, io.EOF) {
t.err = err
}
return n, err
}
// waitBeforeRetry sleeps for the backoff interval of the given attempt
// (1-based), returning the context error if the caller gives up while waiting.
func waitBeforeRetry(ctx context.Context, attempt int) error {
delay := DownloadRetryBaseDelay << (attempt - 1)
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}