From 491ce60d62b2b384e35818e0859331c083fc6789 Mon Sep 17 00:00:00 2001 From: Nimrod Shlagman Date: Mon, 26 Sep 2022 20:21:17 +0300 Subject: [PATCH] Add client-side support for datetime value of Retry-After header (#131) According to specifications, Retry-After header should be either seconds-delay or HTTP-date https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3 Resolves #119 --- internal/retryafter.go | 38 ++++++++++++++-- internal/retryafter_test.go | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 internal/retryafter_test.go diff --git a/internal/retryafter.go b/internal/retryafter.go index 70eb8314..784473bd 100644 --- a/internal/retryafter.go +++ b/internal/retryafter.go @@ -1,31 +1,61 @@ package internal import ( + "errors" "net/http" "strconv" - "strings" "time" ) const retryAfterHTTPHeader = "Retry-After" +var errCouldNotParseRetryAfterHeader = errors.New("could not parse" + retryAfterHTTPHeader + "header") + type OptionalDuration struct { Duration time.Duration // true if duration field is defined. Defined bool } +func parseDelaySeconds(s string) (time.Duration, error) { + n, err := strconv.Atoi(s) + + // Verify duration parsed properly and bigger than 0 + if err == nil && n > 0 { + duration := time.Duration(n) * time.Second + return duration, nil + } + return 0, errCouldNotParseRetryAfterHeader +} + +func parseHTTPDate(s string) (time.Duration, error) { + t, err := http.ParseTime(s) + + // Verify duration parsed properly and bigger than 0 + if err == nil { + if duration := time.Until(t); duration > 0 { + return duration, nil + } + } + return 0, errCouldNotParseRetryAfterHeader +} + // ExtractRetryAfterHeader extracts Retry-After response header if the status // is 503 or 429. Returns 0 duration if the header is not found or the status // is different. func ExtractRetryAfterHeader(resp *http.Response) OptionalDuration { if resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusTooManyRequests { - retryAfter := strings.TrimSpace(resp.Header.Get(retryAfterHTTPHeader)) + retryAfter := resp.Header.Get(retryAfterHTTPHeader) if retryAfter != "" { - retryIntervalSec, err := strconv.Atoi(retryAfter) + // Parse delay-seconds https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3 + retryInterval, err := parseDelaySeconds(retryAfter) + if err == nil { + return OptionalDuration{Defined: true, Duration: retryInterval} + } + // Parse HTTP-date https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3 + retryInterval, err = parseHTTPDate(retryAfter) if err == nil { - retryInterval := time.Duration(retryIntervalSec) * time.Second return OptionalDuration{Defined: true, Duration: retryInterval} } } diff --git a/internal/retryafter_test.go b/internal/retryafter_test.go new file mode 100644 index 00000000..20bc1997 --- /dev/null +++ b/internal/retryafter_test.go @@ -0,0 +1,88 @@ +package internal + +import ( + "github.com/stretchr/testify/assert" + "math/rand" + "net/http" + "strconv" + "testing" + "time" +) + +func response503() *http.Response { + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: map[string][]string{}, + } +} + +func assertUndefinedDuration(t *testing.T, d OptionalDuration) { + assert.NotNil(t, d) + assert.Equal(t, false, d.Defined) + assert.Equal(t, time.Duration(0), d.Duration) +} + +func assertDuration(t *testing.T, duration OptionalDuration, expected time.Duration) { + assert.NotNil(t, duration) + assert.Equal(t, true, duration.Defined) + + // LessOrEqual to consider the time passes during the tests (actual duration would decrease in HTTP-date tests) + assert.LessOrEqual(t, duration.Duration, expected) +} + +func TestExtractRetryAfterHeaderDelaySeconds(t *testing.T) { + // Generate random n > 0 int + rand.Seed(time.Now().UnixNano()) + retryIntervalSec := rand.Intn(9999) + + // Generate a 503 status code response with Retry-After = n header + resp := response503() + resp.Header.Add(retryAfterHTTPHeader, strconv.Itoa(retryIntervalSec)) + + expectedDuration := time.Second * time.Duration(retryIntervalSec) + assertDuration(t, ExtractRetryAfterHeader(resp), expectedDuration) + + // Verify status code 429 + resp.StatusCode = http.StatusTooManyRequests + assertDuration(t, ExtractRetryAfterHeader(resp), expectedDuration) + + // Verify different status code than {429, 503} + resp.StatusCode = http.StatusBadGateway + assertUndefinedDuration(t, ExtractRetryAfterHeader(resp)) + + // Verify no duration is created for n < 0 + resp.Header.Set(retryAfterHTTPHeader, strconv.Itoa(-1)) + assertUndefinedDuration(t, ExtractRetryAfterHeader(resp)) +} + +func TestExtractRetryAfterHeaderHttpDate(t *testing.T) { + // Generate a random n > 0 second duration + now := time.Now() + rand.Seed(now.UnixNano()) + retryIntervalSec := rand.Intn(9999) + expectedDuration := time.Second * time.Duration(retryIntervalSec) + + // Set a response with Retry-After header = random n > 0 int + resp := response503() + retryAfter := now.Add(time.Second * time.Duration(retryIntervalSec)).UTC() + + // Verify HTTP-date TimeFormat format is being parsed correctly + resp.Header.Set(retryAfterHTTPHeader, retryAfter.Format(http.TimeFormat)) + assertDuration(t, ExtractRetryAfterHeader(resp), expectedDuration) + + // Verify ANSI time format + resp.Header.Set(retryAfterHTTPHeader, retryAfter.Format(time.ANSIC)) + assertDuration(t, ExtractRetryAfterHeader(resp), expectedDuration) + + // Verify RFC850 time format + resp.Header.Set(retryAfterHTTPHeader, retryAfter.Format(time.RFC850)) + assertDuration(t, ExtractRetryAfterHeader(resp), expectedDuration) + + // Verify non HTTP-date RFC1123 format isn't being parsed + resp.Header.Set(retryAfterHTTPHeader, retryAfter.Format(time.RFC1123)) + assertUndefinedDuration(t, ExtractRetryAfterHeader(resp)) + + // Verify no duration is created for n < 0 + resp.Header.Set(retryAfterHTTPHeader, now.Add(-1*time.Second).UTC().Format(http.TimeFormat)) + assertUndefinedDuration(t, ExtractRetryAfterHeader(resp)) +}