Skip to content

Commit

Permalink
Add retry with exponential backoff in resources.GetRemote for tempora…
Browse files Browse the repository at this point in the history
…ry HTTP errors

Fixes gohugoio#11312
  • Loading branch information
bep committed Aug 4, 2023
1 parent 8fa8ce3 commit bd10d05
Show file tree
Hide file tree
Showing 3 changed files with 129 additions and 24 deletions.
6 changes: 3 additions & 3 deletions hugolib/resource_chain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestResourceChainBasic(t *testing.T) {
failIfHandler := func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/fail.jpg" {
http.Error(w, "{ msg: failed }", 500)
http.Error(w, "{ msg: failed }", 501)
return
}
h.ServeHTTP(w, r)
Expand Down Expand Up @@ -116,8 +116,8 @@ FIT REMOTE: sunset_%[1]s.jpg|/sunset_%[1]s_hu59e56ffff1bc1d8d122b1403d34e039f_0_
REMOTE NOT FOUND: OK
LOCAL NOT FOUND: OK
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 }
|StatusCode: 500|ContentLength: 16|ContentType: text/plain; charset=utf-8|
FAILED REMOTE ERROR DETAILS CONTENT: |failed to fetch remote resource: Not Implemented|Body: { msg: failed }
|StatusCode: 501|ContentLength: 16|ContentType: text/plain; charset=utf-8|
`, identity.HashString(ts.URL+"/sunset.jpg", map[string]any{})))
Expand Down
68 changes: 67 additions & 1 deletion resources/resource_factories/create/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@
package create_test

import (
"fmt"
"math/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/gohugoio/hugo/hugolib"
)

func TestGetResourceHead(t *testing.T) {
func TestGetRemoteHead(t *testing.T) {

files := `
-- config.toml --
Expand Down Expand Up @@ -57,3 +62,64 @@ func TestGetResourceHead(t *testing.T) {
)

}

func TestGetRemoteRetry(t *testing.T) {
t.Parallel()

temporaryHTTPCodes := []int{408, 429, 500, 502, 503, 504}
numPages := 20

content := []byte(`Test Content 123`)
handler := func(w http.ResponseWriter, r *http.Request) {
if rand.Intn(3) == 0 {
w.WriteHeader(temporaryHTTPCodes[rand.Intn(len(temporaryHTTPCodes))])
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write(content)
}

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 := "URL"}}
{{ $opts := dict "key" .RelPermalink }}
{{ 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), "Content: Test Content 123.")
}

}
79 changes: 59 additions & 20 deletions resources/resource_factories/create/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import (
"bytes"
"fmt"
"io"
"math/rand"
"mime"
"net/http"
"net/http/httputil"
"net/url"
"path"
"path/filepath"
"strings"
"time"

"github.com/gohugoio/hugo/common/hugio"
"github.com/gohugoio/hugo/common/maps"
Expand Down Expand Up @@ -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
// 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) {
Expand All @@ -108,30 +119,58 @@ func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resou
return nil, err
}

req, err := options.NewRequest(uri)
if err != nil {
return nil, fmt.Errorf("failed to create request for resource %s: %w", uri, err)
}

res, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()

httpResponse, err := httputil.DumpResponse(res, true)
if err != nil {
return nil, toHTTPError(err, res, !isHeadMethod)
}
var (
start time.Time
nextSleep = time.Duration((rand.Intn(1000) + 100)) * time.Millisecond
)

for {
b, retry, err := func() ([]byte, bool, error) {
req, err := options.NewRequest(uri)
if err != nil {
return nil, false, fmt.Errorf("failed to create request for resource %s: %w", uri, err)
}

res, err := c.httpClient.Do(req)
if err != nil {
return nil, false, err
}
defer res.Body.Close()

if res.StatusCode != http.StatusNotFound {
if res.StatusCode < 200 || res.StatusCode > 299 {
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)
nextSleep *= 2
continue
}
return nil, err
}

if res.StatusCode != http.StatusNotFound {
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 hugio.ToReadCloser(bytes.NewReader(b)), nil

}
}

return hugio.ToReadCloser(bytes.NewReader(httpResponse)), nil
})
if err != nil {
return nil, err
Expand Down

0 comments on commit bd10d05

Please sign in to comment.