mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
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>
183 lines
5.8 KiB
Go
183 lines
5.8 KiB
Go
package httpclient_test
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/pkg/httpclient"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
func TestHTTPClient(t *testing.T) {
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "httpclient suite")
|
|
}
|
|
|
|
var _ = Describe("httpclient", func() {
|
|
Describe("New (default)", func() {
|
|
It("refuses to follow redirects and never reaches the redirect target", func() {
|
|
sinkHit := make(chan string, 1)
|
|
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sinkHit <- r.Header.Get("X-Api-Key")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer sink.Close()
|
|
|
|
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, sink.URL, http.StatusFound)
|
|
}))
|
|
defer redirector.Close()
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, redirector.URL, nil)
|
|
req.Header.Set("X-Api-Key", "secret")
|
|
|
|
_, err := httpclient.New().Do(req)
|
|
Expect(err).To(HaveOccurred(), "redirect must surface as an error")
|
|
Expect(errors.Is(err, httpclient.ErrRedirectBlocked)).To(BeTrue(), "error should wrap ErrRedirectBlocked")
|
|
Expect(sinkHit).NotTo(Receive(), "the redirect target must never be contacted")
|
|
})
|
|
|
|
It("sets no overall timeout (streaming-safe) by default", func() {
|
|
Expect(httpclient.New().Timeout).To(BeZero())
|
|
})
|
|
|
|
It("sets a TLS 1.2 floor on the default transport", func() {
|
|
c := httpclient.New()
|
|
t, ok := c.Transport.(*http.Transport)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(t.TLSClientConfig).NotTo(BeNil())
|
|
Expect(t.TLSClientConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12)))
|
|
})
|
|
})
|
|
|
|
Describe("WithResponseHeaderTimeout", func() {
|
|
It("aborts a peer that accepts the request but never sends headers", func() {
|
|
release := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-release
|
|
}))
|
|
defer srv.Close()
|
|
defer close(release)
|
|
|
|
c := httpclient.New(httpclient.WithResponseHeaderTimeout(200 * time.Millisecond))
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := c.Get(srv.URL)
|
|
done <- err
|
|
}()
|
|
|
|
var err error
|
|
Eventually(done, "3s").Should(Receive(&err))
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("timeout awaiting response headers"))
|
|
})
|
|
|
|
It("does not bound the response body once headers have arrived", func() {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
_, _ = w.Write([]byte("late"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := httpclient.New(httpclient.WithResponseHeaderTimeout(200 * time.Millisecond))
|
|
resp, err := c.Get(srv.URL)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(body)).To(Equal("late"), "a slow body must not be truncated")
|
|
})
|
|
|
|
It("leaves the transport unbounded when not opted in, so streaming is unaffected", func() {
|
|
t, ok := httpclient.New().Transport.(*http.Transport)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(t.ResponseHeaderTimeout).To(BeZero())
|
|
})
|
|
})
|
|
|
|
Describe("NewWithTimeout", func() {
|
|
It("applies the overall timeout", func() {
|
|
Expect(httpclient.NewWithTimeout(5 * time.Second).Timeout).To(Equal(5 * time.Second))
|
|
})
|
|
})
|
|
|
|
Describe("WithFollowRedirects", func() {
|
|
It("follows same-host redirects keeping the credential header", func() {
|
|
got := make(chan string, 2)
|
|
var srv *httptest.Server
|
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/start" {
|
|
http.Redirect(w, r, srv.URL+"/end", http.StatusFound)
|
|
return
|
|
}
|
|
got <- r.Header.Get("X-Api-Key")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/start", nil)
|
|
req.Header.Set("X-Api-Key", "secret")
|
|
|
|
resp, err := httpclient.New(httpclient.WithFollowRedirects()).Do(req)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_ = resp.Body.Close()
|
|
Expect(<-got).To(Equal("secret"), "same-host redirect should preserve the header")
|
|
})
|
|
|
|
It("strips credential headers on a cross-host redirect", func() {
|
|
sinkKey := make(chan string, 1)
|
|
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sinkKey <- r.Header.Get("X-Api-Key")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer sink.Close()
|
|
|
|
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, sink.URL, http.StatusFound)
|
|
}))
|
|
defer redirector.Close()
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, redirector.URL, nil)
|
|
req.Header.Set("X-Api-Key", "secret")
|
|
|
|
resp, err := httpclient.New(httpclient.WithFollowRedirects()).Do(req)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_ = resp.Body.Close()
|
|
Expect(<-sinkKey).To(BeEmpty(), "x-api-key must be stripped crossing to a different host")
|
|
})
|
|
})
|
|
|
|
Describe("Harden", func() {
|
|
It("adds NoRedirect and a TLS floor to a bare client without clobbering existing config", func() {
|
|
c := httpclient.Harden(&http.Client{})
|
|
Expect(c.CheckRedirect).NotTo(BeNil())
|
|
t, ok := c.Transport.(*http.Transport)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(t.TLSClientConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12)))
|
|
})
|
|
|
|
It("returns nil for a nil client", func() {
|
|
Expect(httpclient.Harden(nil)).To(BeNil())
|
|
})
|
|
|
|
It("preserves a caller-supplied CheckRedirect", func() {
|
|
sentinel := errors.New("mine")
|
|
c := httpclient.Harden(&http.Client{
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return sentinel },
|
|
})
|
|
Expect(c.CheckRedirect(nil, nil)).To(Equal(sentinel))
|
|
})
|
|
})
|
|
})
|