Skip to content

Commit

Permalink
fix(godaddy): Handle missing Retry-After header gracefully
Browse files Browse the repository at this point in the history
  • Loading branch information
alexstojda committed Jan 20, 2025
1 parent d8d74ca commit 9963362
Show file tree
Hide file tree
Showing 2 changed files with 74 additions and 2 deletions.
20 changes: 18 additions & 2 deletions provider/godaddy/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"strconv"
"time"

log "github.com/sirupsen/logrus"
"golang.org/x/time/rate"

"sigs.k8s.io/external-dns/pkg/apis/externaldns"
Expand All @@ -41,6 +42,11 @@ var (
ErrAPIDown = errors.New("godaddy: the GoDaddy API is down")
)

// error codes
const (
ErrCodeQuotaExceeded = "QUOTA_EXCEEDED"
)

// APIError error
type APIError struct {
Code string
Expand Down Expand Up @@ -129,7 +135,13 @@ func NewClient(useOTE bool, apiKey, apiSecret string) (*Client, error) {

// Get and check the configuration
if err := client.validate(); err != nil {
return nil, err
var apiErr *APIError
// Quota Exceeded errors are limited to the endpoint being called. Other endpoints are not affected when we hit
// the quota limit on the endpoint used for validation. We can safely ignore this error.
// Quota limits on other endpoints will be logged by their respective calls.
if ok := errors.As(err, &apiErr); ok && apiErr.Code != ErrCodeQuotaExceeded {
return nil, err
}
}
return &client, nil
}
Expand Down Expand Up @@ -230,7 +242,11 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
resp, err := c.Client.Do(req)
// In case of several clients behind NAT we still can hit rate limit
for i := 1; i < 3 && err == nil && resp.StatusCode == 429; i++ {
retryAfter, _ := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 0)
retryAfter, err := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 0)
if err != nil {
log.Error("Rate-limited response did not contain a valid Retry-After header, quota likely exceeded")
break
}

jitter := rand.Int63n(retryAfter)
retryAfterSec := retryAfter + jitter/2
Expand Down
56 changes: 56 additions & 0 deletions provider/godaddy/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package godaddy

import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
"golang.org/x/time/rate"
)

// Tests that
func TestClient_DoWhenQuotaExceeded(t *testing.T) {
assert := assert.New(t)

// Mock server to return 429 with a JSON payload
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, err := w.Write([]byte(`{"code": "QUOTA_EXCEEDED", "message": "rate limit exceeded"}`))
if err != nil {
t.Fatalf("Failed to write response: %v", err)
}
}))
defer mockServer.Close()

client := Client{
APIKey: "",
APISecret: "",
APIEndPoint: mockServer.URL,
Client: &http.Client{},
// Add one token every second
Ratelimiter: rate.NewLimiter(rate.Every(time.Second), 60),
Timeout: DefaultTimeout,
}

req, err := client.NewRequest("GET", "/v1/domains/example.net/records", nil, false)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}

resp, err := client.Do(req)
assert.Nil(err, "A CODE_EXCEEDED response should not return an error")
assert.Equal(http.StatusTooManyRequests, resp.StatusCode, "Expected a 429 response")

respContents := GDErrorResponse{}
err = client.UnmarshalResponse(resp, &respContents)
if assert.NotNil(err) {
var apiErr *APIError
errors.As(err, &apiErr)
assert.Equal("QUOTA_EXCEEDED", apiErr.Code)
assert.Equal("rate limit exceeded", apiErr.Message)
}
}

0 comments on commit 9963362

Please sign in to comment.