Add retry in resources.GetRemote for temporary HTTP errors

Fixes #11312
This commit is contained in:
Bjørn Erik Pedersen 2023-08-03 17:52:17 +02:00
parent 2c20fd557a
commit a3d42a277d
3 changed files with 128 additions and 21 deletions

View file

@ -36,7 +36,7 @@ func TestResourceChainBasic(t *testing.T) {
failIfHandler := func(h http.Handler) http.Handler { failIfHandler := func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/fail.jpg" { if r.URL.Path == "/fail.jpg" {
http.Error(w, "{ msg: failed }", 500) http.Error(w, "{ msg: failed }", 501)
return return
} }
h.ServeHTTP(w, r) h.ServeHTTP(w, r)
@ -116,8 +116,8 @@ FIT REMOTE: sunset_%[1]s.jpg|/sunset_%[1]s_hu59e56ffff1bc1d8d122b1403d34e039f_0_
REMOTE NOT FOUND: OK REMOTE NOT FOUND: OK
LOCAL NOT FOUND: OK LOCAL NOT FOUND: OK
PRINT PROTOCOL ERROR DETAILS: Err: error calling resources.GetRemote: Get "gopher://example.org": unsupported protocol scheme "gopher"|| PRINT PROTOCOL ERROR DETAILS: Err: error calling resources.GetRemote: Get "gopher://example.org": unsupported protocol scheme "gopher"||
FAILED REMOTE ERROR DETAILS CONTENT: |failed to fetch remote resource: Internal Server Error|Body: { msg: failed } FAILED REMOTE ERROR DETAILS CONTENT: |failed to fetch remote resource: Not Implemented|Body: { msg: failed }
|StatusCode: 500|ContentLength: 16|ContentType: text/plain; charset=utf-8| |StatusCode: 501|ContentLength: 16|ContentType: text/plain; charset=utf-8|
`, identity.HashString(ts.URL+"/sunset.jpg", map[string]any{}))) `, identity.HashString(ts.URL+"/sunset.jpg", map[string]any{})))

View file

@ -14,12 +14,17 @@
package create_test package create_test
import ( import (
"fmt"
"math/rand"
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"github.com/gohugoio/hugo/hugolib" "github.com/gohugoio/hugo/hugolib"
) )
func TestGetResourceHead(t *testing.T) { func TestGetRemoteHead(t *testing.T) {
files := ` files := `
-- config.toml -- -- config.toml --
@ -57,3 +62,63 @@ func TestGetResourceHead(t *testing.T) {
) )
} }
func TestGetRemoteRetry(t *testing.T) {
t.Parallel()
temporaryHTTPCodes := []int{408, 429, 500, 502, 503, 504}
numPages := 30
handler := func(w http.ResponseWriter, r *http.Request) {
if rand.Intn(4) == 0 {
w.WriteHeader(temporaryHTTPCodes[rand.Intn(len(temporaryHTTPCodes))])
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("Response for " + r.URL.Path + "."))
}
srv := httptest.NewServer(http.HandlerFunc(handler))
t.Cleanup(func() { srv.Close() })
files := `
-- hugo.toml --
disableKinds = ["home", "taxonomy", "term"]
[security]
[security.http]
urls = ['.*']
mediaTypes = ['text/plain']
-- layouts/_default/single.html --
{{ $url := printf "%s%s" "URL" .RelPermalink}}
{{ $opts := dict }}
{{ with resources.GetRemote $url $opts }}
{{ with .Err }}
{{ errorf "Unable to get remote resource: %s" . }}
{{ else }}
Content: {{ .Content }}
{{ end }}
{{ else }}
{{ errorf "Unable to get remote resource: %s" $url }}
{{ end }}
`
for i := 0; i < numPages; i++ {
files += fmt.Sprintf("-- content/post/p%d.md --\n", i)
}
files = strings.ReplaceAll(files, "URL", srv.URL)
b := hugolib.NewIntegrationTestBuilder(
hugolib.IntegrationTestConfig{
T: t,
TxtarString: files,
},
)
b.Build()
for i := 0; i < numPages; i++ {
b.AssertFileContent(fmt.Sprintf("public/post/p%d/index.html", i), fmt.Sprintf("Content: Response for /post/p%d/.", i))
}
}

View file

@ -18,6 +18,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"math/rand"
"mime" "mime"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
@ -25,6 +26,7 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/hugio"
"github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/common/maps"
@ -83,6 +85,15 @@ func toHTTPError(err error, res *http.Response, readBody bool) *HTTPError {
} }
} }
var temporaryHTTPStatusCodes = map[int]bool{
408: true,
429: true,
500: true,
502: true,
503: true,
504: true,
}
// FromRemote expects one or n-parts of a URL to a resource // FromRemote expects one or n-parts of a URL to a resource
// If you provide multiple parts they will be joined together to the final URL. // If you provide multiple parts they will be joined together to the final URL.
func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resource, error) { func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resource, error) {
@ -108,30 +119,61 @@ func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resou
return nil, err return nil, err
} }
req, err := options.NewRequest(uri) var (
if err != nil { start time.Time
return nil, fmt.Errorf("failed to create request for resource %s: %w", uri, err) nextSleep = time.Duration((rand.Intn(1000) + 100)) * time.Millisecond
} nextSleepLimit = time.Duration(5) * time.Second
)
res, err := c.httpClient.Do(req) for {
if err != nil { b, retry, err := func() ([]byte, bool, error) {
return nil, err req, err := options.NewRequest(uri)
} if err != nil {
defer res.Body.Close() return nil, false, fmt.Errorf("failed to create request for resource %s: %w", uri, err)
}
httpResponse, err := httputil.DumpResponse(res, true) res, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, toHTTPError(err, res, !isHeadMethod) return nil, false, err
} }
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound { if res.StatusCode != http.StatusNotFound {
if res.StatusCode < 200 || res.StatusCode > 299 { if res.StatusCode < 200 || res.StatusCode > 299 {
return nil, toHTTPError(fmt.Errorf("failed to fetch remote resource: %s", http.StatusText(res.StatusCode)), res, !isHeadMethod) return nil, temporaryHTTPStatusCodes[res.StatusCode], toHTTPError(fmt.Errorf("failed to fetch remote resource: %s", http.StatusText(res.StatusCode)), res, !isHeadMethod)
}
}
b, err := httputil.DumpResponse(res, true)
if err != nil {
return nil, false, toHTTPError(err, res, !isHeadMethod)
}
return b, false, nil
}()
if err != nil {
if retry {
if start.IsZero() {
start = time.Now()
} else if d := time.Since(start) + nextSleep; d >= c.rs.Cfg.Timeout() {
return nil, fmt.Errorf("timeout (configured to %s) fetching remote resource %s: last error: %w", c.rs.Cfg.Timeout(), uri, err)
}
time.Sleep(nextSleep)
if nextSleep < nextSleepLimit {
nextSleep *= 2
}
continue
}
return nil, err
} }
return hugio.ToReadCloser(bytes.NewReader(b)), nil
} }
return hugio.ToReadCloser(bytes.NewReader(httpResponse)), nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err