From 2d691c7e47323f9480bbf53849dfa7d17b1c4860 Mon Sep 17 00:00:00 2001 From: Paul van Brouwershaven Date: Mon, 23 Feb 2026 16:38:13 +0100 Subject: [PATCH] Add per-request timeout option to `resources.GetRemote` --- .../en/functions/resources/GetRemote.md | 22 +++++++-- .../create/create_integration_test.go | 45 +++++++++++++++++++ resources/resource_factories/create/remote.go | 20 +++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/docs/content/en/functions/resources/GetRemote.md b/docs/content/en/functions/resources/GetRemote.md index 29768eedf..2a3270e88 100644 --- a/docs/content/en/functions/resources/GetRemote.md +++ b/docs/content/en/functions/resources/GetRemote.md @@ -46,11 +46,12 @@ key method : (`string`) The action to perform on the requested resource, typically one of `GET`, `POST`, or `HEAD`. +timeout +: (`string`) Cancels the request if it does not complete within this duration (e.g. "30s"). + responseHeaders : {{< new-in 0.143.0 />}} -: (`[]string`) The headers to extract from the server's response, accessible through the resource's [`Data.Headers`] method. Header name matching is case-insensitive. - -[`Data.Headers`]: /methods/resource/data/#headers +: (`[]string`) The headers to extract from the server's response, accessible through the resource's [`Data.Headers`] method. Header name matching is case-insensitive.[`Data.Headers`]: /methods/resource/data/#headers ## Options examples @@ -110,6 +111,20 @@ To extract specific headers from the server's response: {{ $resource := resources.GetRemote $url $opts }} ``` +To set a per-request timeout (e.g. when fetching many feeds where a few slow ones should not stall the build): + +```go-html-template +{{ $url := "https://example.org/feed.rss" }} +{{ $opts := dict "timeout" "10s" }} +{{ with try (resources.GetRemote $url $opts) }} + {{ with .Err }} + {{ warnf "Failed to fetch feed: %s" . }} + {{ else with .Value }} + {{ $data = . | transform.Unmarshal }} + {{ end }} +{{ end }} +``` + ## Remote data When retrieving remote data, use the [`transform.Unmarshal`] function to [unmarshal](g) the response. @@ -224,4 +239,5 @@ Note that the entry above is: [`try`]: /functions/go-template/try [configure file caches]: /configuration/caches/ + [error handling]: #error-handling diff --git a/resources/resource_factories/create/create_integration_test.go b/resources/resource_factories/create/create_integration_test.go index acb511d6d..b9c46bbcf 100644 --- a/resources/resource_factories/create/create_integration_test.go +++ b/resources/resource_factories/create/create_integration_test.go @@ -20,6 +20,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/gohugoio/hugo/htesting" "github.com/gohugoio/hugo/hugolib" @@ -175,3 +176,47 @@ mediaTypes = ['text/plain'] } }) } + +func TestGetRemotePerRequestTimeout(t *testing.T) { + t.Parallel() + htesting.SkipSlowTestUnlessCI(t) + + // A server that always sleeps longer than the per-request timeout. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + w.Header().Add("Content-Type", "text/plain") + w.Write([]byte("too late")) + })) + 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" "200ms" }} +{{ 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() + + // The per-request timeout of 200ms should fire well before the global 30s timeout. + b.AssertFileContent("public/index.html", "Err:") +} diff --git a/resources/resource_factories/create/remote.go b/resources/resource_factories/create/remote.go index 899ac8330..22aaf3ce9 100644 --- a/resources/resource_factories/create/remote.go +++ b/resources/resource_factories/create/remote.go @@ -27,6 +27,8 @@ import ( "strings" "time" + "github.com/spf13/cast" + "github.com/gohugoio/httpcache" "github.com/gohugoio/hugo/common/hashing" "github.com/gohugoio/hugo/common/hmaps" @@ -177,6 +179,19 @@ func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resou isHeadMethod := method == "HEAD" optionsm = maps.Clone(optionsm) + + // Extract timeout before computing cache keys: it only affects fetch behaviour, + // not the cached content, so it must not influence the cache key. + var perRequestTimeout time.Duration + if v, k, ok := hmaps.LookupEqualFold(optionsm, "timeout"); ok { + d, err := cast.ToDurationE(v) + if err != nil { + return nil, fmt.Errorf("invalid timeout for resource %s: %w", uri, err) + } + perRequestTimeout = d + delete(optionsm, k) + } + userKey, optionsKey := remoteResourceKeys(uri, optionsm) // A common pattern is to use the key in the options map as @@ -197,6 +212,11 @@ func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resou getRes := func() (*http.Response, error) { ctx := context.Background() + 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) -- 2.39.5