Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit c4eba92

Browse files
committed
resources: Honor Retry-After header in resources.GetRemote retries
When the server returns a temporary HTTP error (e.g. 429 or 503) together with a Retry-After header, use that value as the next sleep duration instead of the default exponential backoff. The Retry-After value is also surfaced in the retry-timeout error message. Fixes #14828
1 parent 8b40a96 commit c4eba92

2 files changed

Lines changed: 121 additions & 2 deletions

File tree

resources/resource_factories/create/create_integration_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import (
1919
"net/http"
2020
"net/http/httptest"
2121
"strings"
22+
"sync"
2223
"testing"
2324
"time"
2425

26+
qt "github.com/frankban/quicktest"
2527
"github.com/gohugoio/hugo/htesting"
2628
"github.com/gohugoio/hugo/hugolib"
2729
)
@@ -189,6 +191,91 @@ mediaTypes = ['text/plain']
189191
b.AssertFileContent("public/index.html", "Err:")
190192
}
191193

194+
// Issue 14828.
195+
func TestGetRemoteRetryAfterIssue14828(t *testing.T) {
196+
t.Parallel()
197+
198+
t.Run("Honored", func(t *testing.T) {
199+
var (
200+
mu sync.Mutex
201+
reqTimes []time.Time
202+
)
203+
204+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
205+
mu.Lock()
206+
reqTimes = append(reqTimes, time.Now())
207+
n := len(reqTimes)
208+
mu.Unlock()
209+
210+
if n == 1 {
211+
w.Header().Set("Retry-After", "2")
212+
w.WriteHeader(http.StatusServiceUnavailable)
213+
return
214+
}
215+
w.Header().Add("Content-Type", "text/plain")
216+
w.Write([]byte("OK"))
217+
}))
218+
t.Cleanup(func() { srv.Close() })
219+
220+
files := `
221+
-- hugo.toml --
222+
timeout = "30s"
223+
[security]
224+
[security.http]
225+
urls = ['.*']
226+
mediaTypes = ['text/plain']
227+
-- layouts/home.html --
228+
{{ $url := "URL" }}
229+
{{ with try (resources.GetRemote $url) }}
230+
{{ with .Err }}
231+
{{ errorf "Got Err: %s" . }}
232+
{{ else with .Value }}
233+
Content: {{ .Content }}
234+
{{ end }}
235+
{{ end }}
236+
`
237+
files = strings.ReplaceAll(files, "URL", srv.URL)
238+
239+
b := hugolib.Test(t, files)
240+
b.AssertFileContent("public/index.html", "Content: OK")
241+
242+
mu.Lock()
243+
defer mu.Unlock()
244+
b.Assert(len(reqTimes) >= 2, qt.IsTrue, qt.Commentf("expected at least 2 requests, got %d", len(reqTimes)))
245+
// Default exponential backoff caps the initial sleep at 1100ms.
246+
// A Retry-After of 2s should produce a gap well above that.
247+
gap := reqTimes[1].Sub(reqTimes[0])
248+
b.Assert(gap >= 1500*time.Millisecond, qt.IsTrue, qt.Commentf("expected gap of at least 1500ms (Retry-After: 2), got %s", gap))
249+
})
250+
251+
t.Run("TimeoutMessage", func(t *testing.T) {
252+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
253+
w.Header().Set("Retry-After", "1")
254+
w.WriteHeader(http.StatusServiceUnavailable)
255+
}))
256+
t.Cleanup(func() { srv.Close() })
257+
258+
files := `
259+
-- hugo.toml --
260+
timeout = "200ms"
261+
[security]
262+
[security.http]
263+
urls = ['.*']
264+
-- layouts/home.html --
265+
{{ $url := "URL" }}
266+
{{ with try (resources.GetRemote $url) }}
267+
{{ with .Err }}
268+
{{ errorf "Got Err: %s" . }}
269+
{{ end }}
270+
{{ end }}
271+
`
272+
files = strings.ReplaceAll(files, "URL", srv.URL)
273+
274+
b, _ := hugolib.TestE(t, files)
275+
b.AssertLogContains("Retry-After: 1s")
276+
})
277+
}
278+
192279
// Issue 14611.
193280
func TestGetRemotePerRequestTimeoutBodyRead(t *testing.T) {
194281
t.Parallel()

resources/resource_factories/create/remote.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"net/http"
2525
"net/url"
2626
"path"
27+
"strconv"
2728
"strings"
2829
"time"
2930

@@ -110,6 +111,28 @@ var temporaryHTTPStatusCodes = map[int]bool{
110111
504: true,
111112
}
112113

114+
// parseRetryAfter returns the duration to wait per the Retry-After header in
115+
// resp, or 0 if the header is absent or unparseable. Per RFC 7231 the value
116+
// may be either delta-seconds or an HTTP-date.
117+
func parseRetryAfter(resp *http.Response) time.Duration {
118+
if resp == nil {
119+
return 0
120+
}
121+
h := strings.TrimSpace(resp.Header.Get("Retry-After"))
122+
if h == "" {
123+
return 0
124+
}
125+
if n, err := strconv.Atoi(h); err == nil && n >= 0 {
126+
return time.Duration(n) * time.Second
127+
}
128+
if t, err := http.ParseTime(h); err == nil {
129+
if d := time.Until(t); d > 0 {
130+
return d
131+
}
132+
}
133+
return 0
134+
}
135+
113136
func (c *Client) configurePollingIfEnabled(uri, optionsKey string, getRes func() (*http.Response, context.CancelFunc, error)) {
114137
if c.remoteResourceChecker == nil {
115138
return
@@ -473,17 +496,26 @@ func (t *transport) RoundTrip(req *http.Request) (resp *http.Response, err error
473496
}()
474497

475498
if retry {
499+
sleep := nextSleep
500+
retryAfter := parseRetryAfter(resp)
501+
if retryAfter > 0 {
502+
sleep = retryAfter
503+
}
476504
if start.IsZero() {
477505
start = time.Now()
478-
} else if d := time.Since(start) + nextSleep; d >= t.Cfg.Timeout() {
506+
}
507+
if d := time.Since(start) + sleep; d >= t.Cfg.Timeout() {
479508
msg := "<nil>"
480509
if resp != nil {
481510
msg = resp.Status
482511
}
512+
if retryAfter > 0 {
513+
msg = fmt.Sprintf("%s (server requested Retry-After: %s)", msg, retryAfter)
514+
}
483515
err := toHTTPError(fmt.Errorf("retry timeout (configured to %s) fetching remote resource: %s", t.Cfg.Timeout(), msg), resp, req.Method != "HEAD", nil)
484516
return resp, err
485517
}
486-
time.Sleep(nextSleep)
518+
time.Sleep(sleep)
487519
if nextSleep < nextSleepLimit {
488520
nextSleep *= 2
489521
}

0 commit comments

Comments
 (0)