fix(retry): глобальный rate-limit + Retry-After + cooldown (#70)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 25s
Tests / Test (Go stable) (pull_request) Successful in 27s
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 25s
Tests / Test (Go stable) (pull_request) Successful in 27s
This commit is contained in:
+132
@@ -3,6 +3,8 @@ package onlyoffice
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -20,6 +22,8 @@ func TestTransient(t *testing.T) {
|
||||
{errors.New("PUT /x: 504 gateway timeout"), true},
|
||||
{errors.New("GET /x: 404 not found"), false},
|
||||
{errors.New("GET /x: 4290 not a code"), false},
|
||||
{&TransientError{Code: 429, Msg: "throttled"}, true},
|
||||
{&TransientError{Code: 404, Msg: "missing"}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Transient(c.err); got != c.want {
|
||||
@@ -86,3 +90,131 @@ func TestDoRetryHonoursContextCancellation(t *testing.T) {
|
||||
t.Fatalf("want context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffDelayExponentialAndCapped(t *testing.T) {
|
||||
p := RetryPolicy{Base: 100 * time.Millisecond, Max: 250 * time.Millisecond}
|
||||
want := []time.Duration{
|
||||
100 * time.Millisecond, // 2^0
|
||||
200 * time.Millisecond, // 2^1
|
||||
250 * time.Millisecond, // 2^2 capped
|
||||
250 * time.Millisecond,
|
||||
250 * time.Millisecond,
|
||||
}
|
||||
for i, w := range want {
|
||||
if got := backoffDelay(p, i+1); got != w {
|
||||
t.Errorf("backoffDelay(attempt=%d) = %v, want %v", i+1, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRetryWaitsRetryAfterLongerThanBackoff(t *testing.T) {
|
||||
const ra = 60 * time.Millisecond
|
||||
p := RetryPolicy{Attempts: 2, Base: time.Millisecond, Max: time.Millisecond}
|
||||
attempts := 0
|
||||
start := time.Now()
|
||||
err := DoRetry(context.Background(), p, func() error {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return &TransientError{Code: http.StatusTooManyRequests, RetryAfter: ra, Msg: "GET /x: 429"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DoRetry: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < ra {
|
||||
t.Fatalf("waited %v, want >= %v", elapsed, ra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRetryAfter(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
}{
|
||||
{"", 0},
|
||||
{" 0 ", 0},
|
||||
{"-3", 0},
|
||||
{"bogus", 0},
|
||||
{"120", 120 * time.Second},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseRetryAfter(c.in); got != c.want {
|
||||
t.Errorf("parseRetryAfter(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
future := time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat)
|
||||
if got := parseRetryAfter(future); got < 80*time.Second || got > 95*time.Second {
|
||||
t.Errorf("HTTP-date Retry-After = %v, want ~90s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetJSON429CarriesRetryAfter checks that the HTTP layer surfaces a 429 as
|
||||
// a typed transient error carrying the parsed Retry-After header.
|
||||
func TestGetJSON429CarriesRetryAfter(t *testing.T) {
|
||||
t.Setenv("OO_RETRY_ATTEMPTS", "1")
|
||||
resetPacingForTest()
|
||||
defer resetPacingForTest()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte("busy"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(Credentials{Url: srv.URL})
|
||||
c.token = &Token{Value: "tok", Expires: Time(time.Now().Add(time.Hour))}
|
||||
|
||||
_, err := c.getJSON(context.Background(), "/api/2.0/project/4.json")
|
||||
if err == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
var te *TransientError
|
||||
if !errors.As(err, &te) {
|
||||
t.Fatalf("want *TransientError, got %T: %v", err, err)
|
||||
}
|
||||
if te.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("code = %d, want 429", te.Code)
|
||||
}
|
||||
if te.RetryAfter != time.Second {
|
||||
t.Fatalf("RetryAfter = %v, want 1s", te.RetryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetJSONRetries429ThenSucceeds verifies the end-to-end path: a throttled
|
||||
// answer with Retry-After is retried and the eventual success is returned.
|
||||
func TestGetJSONRetries429ThenSucceeds(t *testing.T) {
|
||||
t.Setenv("OO_RETRY_ATTEMPTS", "3")
|
||||
t.Setenv("OO_RETRY_BASE", "10ms")
|
||||
t.Setenv("OO_RETRY_MAX", "50ms")
|
||||
resetPacingForTest()
|
||||
defer resetPacingForTest()
|
||||
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.Header().Set("Retry-After", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"response":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(Credentials{Url: srv.URL})
|
||||
c.token = &Token{Value: "tok", Expires: Time(time.Now().Add(time.Hour))}
|
||||
|
||||
raw, err := c.getJSON(context.Background(), "/api/2.0/project/4.json")
|
||||
if err != nil {
|
||||
t.Fatalf("getJSON: %v", err)
|
||||
}
|
||||
if string(raw) != `{"response":{}}` {
|
||||
t.Fatalf("raw = %s", raw)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user