Skip to content

Commit 0d9d07d

Browse files
localai-botmudler
andauthored
fix(downloader): distinguish read from write failures and retry transient ones (#10985)
Two independent defects in the download path, both surfaced by the same incident (#10982). A failed `io.Copy` was always reported as "failed to write file", because `io.Copy` folds read and write errors into a single return value. A peer-cancelled HTTP/2 stream therefore presented as a filesystem failure and sent an investigation after mount permissions while the disk was healthy. The source is now wrapped in a recorder so the error names the side that actually broke, and a write failure names the `.partial` it was writing rather than the final blob path. The plan runner returned on the first task error with no retry, so one transient stream cancel discarded every file already downloaded in a multi-file materialization. The `.partial` resume machinery already existed but was unreachable because nothing made a second attempt. Transient failures (dropped transport, mid-stream read failure, stall, 5xx, 429) are now retried with bounded exponential backoff and resume from the partial; permanent ones (4xx, checksum mismatch, local write failure, caller cancellation) fail immediately. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent f381844 commit 0d9d07d

4 files changed

Lines changed: 390 additions & 8 deletions

File tree

pkg/downloader/download_plan.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package downloader
22

3-
import "context"
3+
import (
4+
"context"
5+
6+
"github.com/mudler/xlog"
7+
)
48

59
// FileTask describes one download operation and an optional post-download
610
// hook that runs after the bytes are present on disk. Callers keep any
@@ -26,7 +30,7 @@ func DownloadFilesWithContext(ctx context.Context, tasks []FileTask, status func
2630
}
2731
taskOpts := append([]DownloadOption{}, opts...)
2832
taskOpts = append(taskOpts, task.Options...)
29-
if err := task.URI.DownloadFileWithContext(ctx, task.Destination, task.SHA256, task.FileIndex, task.TotalFiles, status, taskOpts...); err != nil {
33+
if err := downloadTaskWithRetry(ctx, task, status, taskOpts); err != nil {
3034
return err
3135
}
3236
if task.AfterDownload != nil {
@@ -37,3 +41,30 @@ func DownloadFilesWithContext(ctx context.Context, tasks []FileTask, status func
3741
}
3842
return nil
3943
}
44+
45+
// downloadTaskWithRetry fetches one file, retrying transient failures. Without
46+
// this, a single cancelled stream anywhere in a large multi-file repo threw
47+
// away every file already downloaded, and the .partial resume machinery was
48+
// unreachable because nothing ever made a second attempt.
49+
func downloadTaskWithRetry(ctx context.Context, task FileTask, status func(string, string, string, float64), opts []DownloadOption) error {
50+
var err error
51+
for attempt := 1; ; attempt++ {
52+
err = task.URI.DownloadFileWithContext(ctx, task.Destination, task.SHA256, task.FileIndex, task.TotalFiles, status, opts...)
53+
if err == nil {
54+
return nil
55+
}
56+
if attempt >= DownloadRetryAttempts || !IsRetryable(ctx, err) {
57+
return err
58+
}
59+
xlog.Warn("download failed, retrying",
60+
"uri", string(task.URI),
61+
"destination", task.Destination,
62+
"attempt", attempt,
63+
"maxAttempts", DownloadRetryAttempts,
64+
"error", err,
65+
)
66+
if waitErr := waitBeforeRetry(ctx, attempt); waitErr != nil {
67+
return waitErr
68+
}
69+
}
70+
}

pkg/downloader/retry.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package downloader
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"time"
8+
)
9+
10+
// ErrTransientDownload marks a download failure that a later attempt has a
11+
// real chance of getting past: the peer cancelled the stream, the connection
12+
// dropped, the transfer stalled, or the server answered 5xx. Everything else
13+
// is treated as permanent, because retrying it either loops on a condition
14+
// that will never change (404, checksum mismatch, a full disk) or burns
15+
// bandwidth re-fetching gigabytes for nothing.
16+
var ErrTransientDownload = errors.New("transient download failure")
17+
18+
// DownloadRetryAttempts bounds how many times a single file is attempted
19+
// before the whole plan gives up. These are multi-GB transfers, so the budget
20+
// is deliberately small: the resume path means a retry is cheap in bytes, but
21+
// an aggressive retry loop against a genuinely broken remote is its own
22+
// outage. Overridable so tests (and operators on very flaky links) can adjust.
23+
var DownloadRetryAttempts = 3
24+
25+
// DownloadRetryBaseDelay is the first backoff interval; each further retry
26+
// doubles it. Backoff exists to let a momentarily overloaded remote recover,
27+
// not to wait out a long outage, hence the small base and the low attempt cap.
28+
var DownloadRetryBaseDelay = 2 * time.Second
29+
30+
// transientError wraps an error while keeping its message intact, so the
31+
// caller-facing diagnostics stay exactly as informative as before and only the
32+
// retry classification changes.
33+
type transientError struct{ err error }
34+
35+
func (e *transientError) Error() string { return e.err.Error() }
36+
37+
func (e *transientError) Unwrap() error { return e.err }
38+
39+
func (e *transientError) Is(target error) bool { return target == ErrTransientDownload }
40+
41+
// asTransient marks err retryable. A nil error stays nil so call sites can
42+
// wrap unconditionally.
43+
func asTransient(err error) error {
44+
if err == nil {
45+
return nil
46+
}
47+
return &transientError{err: err}
48+
}
49+
50+
// IsRetryable reports whether another attempt is worth making. A cancelled
51+
// caller is never retried through, regardless of how the failure was
52+
// classified: the caller has already given up.
53+
func IsRetryable(ctx context.Context, err error) bool {
54+
if err == nil || ctx.Err() != nil {
55+
return false
56+
}
57+
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrUserCancelled) {
58+
return false
59+
}
60+
return errors.Is(err, ErrTransientDownload)
61+
}
62+
63+
// readErrorRecorder remembers the last non-EOF error the source returned.
64+
// io.Copy folds read and write failures into one return value; comparing the
65+
// copy error against the recorded one is what lets the caller say which side
66+
// of the transfer actually failed.
67+
type readErrorRecorder struct {
68+
r io.Reader
69+
err error
70+
}
71+
72+
func (t *readErrorRecorder) Read(p []byte) (int, error) {
73+
n, err := t.r.Read(p)
74+
if err != nil && !errors.Is(err, io.EOF) {
75+
t.err = err
76+
}
77+
return n, err
78+
}
79+
80+
// waitBeforeRetry sleeps for the backoff interval of the given attempt
81+
// (1-based), returning the context error if the caller gives up while waiting.
82+
func waitBeforeRetry(ctx context.Context, attempt int) error {
83+
delay := DownloadRetryBaseDelay << (attempt - 1)
84+
timer := time.NewTimer(delay)
85+
defer timer.Stop()
86+
select {
87+
case <-ctx.Done():
88+
return ctx.Err()
89+
case <-timer.C:
90+
return nil
91+
}
92+
}

pkg/downloader/retry_test.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package downloader_test
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/sha256"
7+
"fmt"
8+
"net/http"
9+
"net/http/httptest"
10+
"os"
11+
"path/filepath"
12+
"strconv"
13+
"strings"
14+
"sync"
15+
"time"
16+
17+
. "github.com/onsi/ginkgo/v2"
18+
. "github.com/onsi/gomega"
19+
20+
"github.com/mudler/LocalAI/pkg/downloader"
21+
)
22+
23+
// flakyRangeServer serves payload, honours Range requests, and aborts the
24+
// connection part-way through the body for the first failUntil GET requests.
25+
// Aborting mid-body is how a peer-cancelled HTTP/2 stream or a dropped
26+
// connection presents to the client: the read fails, the write never does.
27+
func flakyRangeServer(payload []byte, failUntil int) (*httptest.Server, func() []string) {
28+
var mu sync.Mutex
29+
var seenRanges []string
30+
gets := 0
31+
32+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+
if r.Method == http.MethodHead {
34+
w.Header().Set("Accept-Ranges", "bytes")
35+
w.WriteHeader(http.StatusOK)
36+
return
37+
}
38+
39+
rangeHeader := r.Header.Get("Range")
40+
mu.Lock()
41+
gets++
42+
attempt := gets
43+
seenRanges = append(seenRanges, rangeHeader)
44+
mu.Unlock()
45+
46+
start := 0
47+
if strings.HasPrefix(rangeHeader, "bytes=") {
48+
n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(rangeHeader, "bytes="), "-"))
49+
Expect(err).ToNot(HaveOccurred())
50+
start = n
51+
}
52+
body := payload[start:]
53+
54+
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
55+
if start > 0 {
56+
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(payload)-1, len(payload)))
57+
w.WriteHeader(http.StatusPartialContent)
58+
} else {
59+
w.WriteHeader(http.StatusOK)
60+
}
61+
62+
if attempt <= failUntil {
63+
// Send a prefix, then kill the connection: the client's Read fails
64+
// with an unexpected EOF while the local file write succeeded.
65+
_, _ = w.Write(body[:len(body)/2])
66+
if f, ok := w.(http.Flusher); ok {
67+
f.Flush()
68+
}
69+
panic(http.ErrAbortHandler)
70+
}
71+
_, _ = w.Write(body)
72+
}))
73+
74+
return srv, func() []string {
75+
mu.Lock()
76+
defer mu.Unlock()
77+
return append([]string(nil), seenRanges...)
78+
}
79+
}
80+
81+
var _ = Describe("download failure diagnostics", func() {
82+
var (
83+
payload []byte
84+
payloadSHA string
85+
destDir string
86+
destPath string
87+
noProgress = func(string, string, string, float64) {}
88+
)
89+
90+
BeforeEach(func() {
91+
payload = make([]byte, 65536)
92+
_, err := rand.Read(payload)
93+
Expect(err).ToNot(HaveOccurred())
94+
sum := sha256.Sum256(payload)
95+
payloadSHA = fmt.Sprintf("%x", sum[:])
96+
97+
destDir = GinkgoT().TempDir()
98+
destPath = filepath.Join(destDir, "model.gguf")
99+
})
100+
101+
// Defect 1: io.Copy folds read and write failures into a single error, and
102+
// the download path labelled every one of them "failed to write file".
103+
// A peer-cancelled stream then reads as a filesystem problem, which is
104+
// exactly the wrong place to look.
105+
It("does not report a failed response read as a write failure", func() {
106+
server, _ := flakyRangeServer(payload, 1)
107+
DeferCleanup(server.Close)
108+
109+
err := downloader.URI(server.URL).DownloadFile(destPath, payloadSHA, 1, 1, noProgress)
110+
Expect(err).To(HaveOccurred())
111+
Expect(err.Error()).ToNot(ContainSubstring("failed to write file"),
112+
"the local write succeeded; only the response body read failed")
113+
Expect(err.Error()).To(ContainSubstring(server.URL),
114+
"a read-side failure should name the source it was reading from")
115+
})
116+
117+
// The partial, not the final blob path, is the file actually being written.
118+
It("names the partial file when the local write is the side that failed", func() {
119+
// A directory in place of the partial makes every write fail while the
120+
// response body stays healthy.
121+
Expect(os.MkdirAll(destPath+downloader.PartialFileSuffix, 0750)).To(Succeed())
122+
123+
server, _ := flakyRangeServer(payload, 0)
124+
DeferCleanup(server.Close)
125+
126+
err := downloader.URI(server.URL).DownloadFile(destPath, payloadSHA, 1, 1, noProgress)
127+
Expect(err).To(HaveOccurred())
128+
Expect(err.Error()).To(ContainSubstring(destPath + downloader.PartialFileSuffix))
129+
})
130+
})
131+
132+
var _ = Describe("DownloadFilesWithContext retries", func() {
133+
var (
134+
payload []byte
135+
payloadSHA string
136+
destPath string
137+
)
138+
139+
BeforeEach(func() {
140+
payload = make([]byte, 65536)
141+
_, err := rand.Read(payload)
142+
Expect(err).ToNot(HaveOccurred())
143+
sum := sha256.Sum256(payload)
144+
payloadSHA = fmt.Sprintf("%x", sum[:])
145+
146+
destPath = filepath.Join(GinkgoT().TempDir(), "model.gguf")
147+
148+
original := downloader.DownloadRetryBaseDelay
149+
downloader.DownloadRetryBaseDelay = time.Millisecond
150+
DeferCleanup(func() { downloader.DownloadRetryBaseDelay = original })
151+
})
152+
153+
// Defect 2: one cancelled stream half-way through a multi-file plan threw
154+
// away every file already fetched. The .partial resume machinery existed
155+
// but nothing retried, so it was unreachable.
156+
It("retries a transient stream failure and resumes from the partial", func() {
157+
server, ranges := flakyRangeServer(payload, 2)
158+
DeferCleanup(server.Close)
159+
160+
err := downloader.DownloadFilesWithContext(context.Background(), []downloader.FileTask{{
161+
URI: downloader.URI(server.URL),
162+
Destination: destPath,
163+
SHA256: payloadSHA,
164+
FileIndex: 1,
165+
TotalFiles: 1,
166+
}}, nil)
167+
Expect(err).ToNot(HaveOccurred())
168+
169+
got, err := os.ReadFile(destPath)
170+
Expect(err).ToNot(HaveOccurred())
171+
Expect(got).To(Equal(payload))
172+
173+
seen := ranges()
174+
Expect(seen).To(HaveLen(3), "expected two failed attempts and one success, got %v", seen)
175+
Expect(seen[0]).To(BeEmpty())
176+
for _, r := range seen[1:] {
177+
Expect(r).To(HavePrefix("bytes="), "retries must resume, not restart: %v", seen)
178+
Expect(r).ToNot(Equal("bytes=0-"), "retries must resume, not restart: %v", seen)
179+
}
180+
})
181+
182+
It("does not retry a permanent failure", func() {
183+
attempts := 0
184+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
185+
attempts++
186+
w.WriteHeader(http.StatusNotFound)
187+
}))
188+
DeferCleanup(server.Close)
189+
190+
err := downloader.DownloadFilesWithContext(context.Background(), []downloader.FileTask{{
191+
URI: downloader.URI(server.URL),
192+
Destination: destPath,
193+
FileIndex: 1,
194+
TotalFiles: 1,
195+
}}, nil)
196+
Expect(err).To(HaveOccurred())
197+
Expect(attempts).To(Equal(1), "a 404 is permanent and must not be retried")
198+
})
199+
200+
It("does not retry once the caller's context is cancelled", func() {
201+
ctx, cancel := context.WithCancel(context.Background())
202+
attempts := 0
203+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
204+
if r.Method == http.MethodHead {
205+
w.Header().Set("Accept-Ranges", "bytes")
206+
w.WriteHeader(http.StatusOK)
207+
return
208+
}
209+
attempts++
210+
cancel()
211+
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
212+
w.WriteHeader(http.StatusOK)
213+
_, _ = w.Write(payload[:len(payload)/2])
214+
if f, ok := w.(http.Flusher); ok {
215+
f.Flush()
216+
}
217+
panic(http.ErrAbortHandler)
218+
}))
219+
DeferCleanup(server.Close)
220+
221+
err := downloader.DownloadFilesWithContext(ctx, []downloader.FileTask{{
222+
URI: downloader.URI(server.URL),
223+
Destination: destPath,
224+
SHA256: payloadSHA,
225+
FileIndex: 1,
226+
TotalFiles: 1,
227+
}}, nil)
228+
Expect(err).To(HaveOccurred())
229+
Expect(attempts).To(Equal(1), "a cancelled caller must not be retried through")
230+
})
231+
})

0 commit comments

Comments
 (0)