Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add client-side support for datetime value of Retry-After header #131

Merged
merged 4 commits into from
Sep 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions internal/retryafter.go
Original file line number Diff line number Diff line change
@@ -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}
}
}
Expand Down
88 changes: 88 additions & 0 deletions internal/retryafter_test.go
Original file line number Diff line number Diff line change
@@ -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))
}