diff --git a/resources/resource_factories/create/create_integration_test.go b/resources/resource_factories/create/create_integration_test.go index b9c46bbcf..e210915ef 100644 --- a/resources/resource_factories/create/create_integration_test.go +++ b/resources/resource_factories/create/create_integration_test.go @@ -220,3 +220,50 @@ mediaTypes = ['text/plain'] // The per-request timeout of 200ms should fire well before the global 30s timeout. b.AssertFileContent("public/index.html", "Err:") } + +// Issue 14611. +func TestGetRemotePerRequestTimeoutBodyRead(t *testing.T) { + t.Parallel() + + // Server sends headers immediately but streams body with a delay. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(200) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + time.Sleep(300 * time.Millisecond) + w.Write([]byte("Hello from remote.")) + })) + t.Cleanup(func() { srv.Close() }) + + files := ` +-- hugo.toml -- +timeout = "30s" +[security] +[security.http] +urls = ['.*'] +mediaTypes = ['text/plain'] +-- layouts/home.html -- +{{ $url := "URL" }} +{{ $opts := dict "timeout" "5s" }} +{{ with try (resources.GetRemote $url $opts) }} + {{ with .Err }} + Err: {{ . }} + {{ else with .Value }} + Content: {{ .Content }} + {{ end }} +{{ end }} +` + files = strings.ReplaceAll(files, "URL", srv.URL) + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + b.Build() + + b.AssertFileContent("public/index.html", "Content: Hello from remote.") +} diff --git a/resources/resource_factories/create/remote.go b/resources/resource_factories/create/remote.go index 22aaf3ce9..7c6104e9c 100644 --- a/resources/resource_factories/create/remote.go +++ b/resources/resource_factories/create/remote.go @@ -110,7 +110,7 @@ var temporaryHTTPStatusCodes = map[int]bool{ 504: true, } -func (c *Client) configurePollingIfEnabled(uri, optionsKey string, getRes func() (*http.Response, error)) { +func (c *Client) configurePollingIfEnabled(uri, optionsKey string, getRes func() (*http.Response, context.CancelFunc, error)) { if c.remoteResourceChecker == nil { return } @@ -137,7 +137,10 @@ func (c *Client) configurePollingIfEnabled(uri, optionsKey string, getRes func() c.rs.Logger.Debugf("Polled remote resource for changes in %13s. Interval: %4s (low: %4s high: %4s) resource: %q ", duration, interval, pollingConfig.Config.Low, pollingConfig.Config.High, uri) }() // TODO(bep) figure out a ways to remove unused tasks. - res, err := getRes() + res, cancel, err := getRes() + if cancel != nil { + defer cancel() + } if err != nil { return pollingConfig.Config.High, err } @@ -210,26 +213,38 @@ func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resou return nil, err } - getRes := func() (*http.Response, error) { + getRes := func() (*http.Response, context.CancelFunc, error) { ctx := context.Background() + var cancel context.CancelFunc if perRequestTimeout > 0 { - var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, perRequestTimeout) - defer cancel() } ctx = c.resourceIDDispatcher.Set(ctx, filecacheKey) req, err := options.NewRequest(uri) if err != nil { - return nil, fmt.Errorf("failed to create request for resource %s: %w", uri, err) + if cancel != nil { + cancel() + } + return nil, nil, fmt.Errorf("failed to create request for resource %s: %w", uri, err) } req = req.WithContext(ctx) - return c.httpClient.Do(req) + resp, err := c.httpClient.Do(req) + if err != nil { + if cancel != nil { + cancel() + } + return nil, nil, err + } + return resp, cancel, nil } - res, err := getRes() + res, cancel, err := getRes() + if cancel != nil { + defer cancel() + } if err != nil { return nil, err }