Files
eSlider c9e16c7169
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
fix(retry): глобальный rate-limit + Retry-After + cooldown (#70)
2026-09-17 12:39:25 +00:00

221 lines
6.1 KiB
Go

package onlyoffice
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestTransient(t *testing.T) {
cases := []struct {
err error
want bool
}{
{nil, false},
{errors.New("boom"), false},
{errors.New("GET /api/2.0/files/1.json: 429 Too Many Requests"), true},
{errors.New("auth: 502 bad gateway"), true},
{errors.New("POST /x: 503 service unavailable"), true},
{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 {
t.Errorf("Transient(%v) = %v, want %v", c.err, got, c.want)
}
}
}
func TestDoRetryRetriesTransientThenSucceeds(t *testing.T) {
p := RetryPolicy{Attempts: 5, Base: time.Millisecond, Max: 5 * time.Millisecond}
attempts := 0
err := DoRetry(context.Background(), p, func() error {
attempts++
if attempts < 3 {
return errors.New("GET /x: 429 Too Many Requests")
}
return nil
})
if err != nil {
t.Fatalf("DoRetry: %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestDoRetryStopsOnPermanentError(t *testing.T) {
p := RetryPolicy{Attempts: 5, Base: time.Millisecond, Max: time.Millisecond}
attempts := 0
err := DoRetry(context.Background(), p, func() error {
attempts++
return errors.New("GET /x: 404 not found")
})
if err == nil {
t.Fatal("want error")
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
}
func TestDoRetryExhaustsAttemptsOnPersistentTransient(t *testing.T) {
p := RetryPolicy{Attempts: 3, Base: time.Millisecond, Max: time.Millisecond}
attempts := 0
err := DoRetry(context.Background(), p, func() error {
attempts++
return errors.New("GET /x: 429 Too Many Requests")
})
if !Transient(err) {
t.Fatalf("want transient error, got %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestDoRetryHonoursContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := DoRetry(ctx, RetryPolicy{Attempts: 3, Base: time.Millisecond, Max: time.Millisecond}, func() error {
return errors.New("GET /x: 429 Too Many Requests")
})
if !errors.Is(err, context.Canceled) {
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)
}
}