]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add per-request timeout option to `resources.GetRemote`
authorPaul van Brouwershaven <vanbroup@users.noreply.github.com>
Mon, 23 Feb 2026 15:38:13 +0000 (16:38 +0100)
committerGitHub <noreply@github.com>
Mon, 23 Feb 2026 15:38:13 +0000 (16:38 +0100)
docs/content/en/functions/resources/GetRemote.md
resources/resource_factories/create/create_integration_test.go
resources/resource_factories/create/remote.go

index 29768eedff9cdf40917ef5a5bc1426543faee408..2a3270e88e655fcc8ec8601cc779387cfcd3a86e 100644 (file)
@@ -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
index acb511d6d0b25333a3d722797a4ecb0b8933b6bc..b9c46bbcf268bf0d664c08895a306f4b5d72c95e 100644 (file)
@@ -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:")
+}
index 899ac8330dfcebe48c5dff761b587e369ac5eb67..22aaf3ce94ac57ec9df55fee2a58715b9e59d216 100644 (file)
@@ -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)