Skip to content

Commit

Permalink
Add client-side support for datetime value of Retry-After header (#131)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
nemoshlag authored Sep 26, 2022
1 parent 83a6659 commit 491ce60
Show file tree
Hide file tree
Showing 2 changed files with 122 additions and 4 deletions.
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))
}

0 comments on commit 491ce60

Please sign in to comment.