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:
@@ -0,0 +1,160 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMain disables the process-wide rate limiter for the suite so existing
|
||||
// fake-server tests stay fast; the pacing tests configure their own limiters.
|
||||
func TestMain(m *testing.M) {
|
||||
_ = os.Setenv("OO_RATE_LIMIT", "0")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// resetPacingForTest restores the package-level limiter and cooldown gate to a
|
||||
// clean state. White-box helper for tests that arm the global 429 gate.
|
||||
func resetPacingForTest() {
|
||||
globalLimiterMu.Lock()
|
||||
globalLimiter = nil
|
||||
globalLimiterOn = false
|
||||
globalLimiterMu.Unlock()
|
||||
globalCooldown = &cooldownGate{}
|
||||
}
|
||||
|
||||
func TestNewClientUsesPacedTransport(t *testing.T) {
|
||||
c := NewClient(Credentials{Url: "https://example.test"})
|
||||
if _, ok := c.client.Transport.(*pacedTransport); !ok {
|
||||
t.Fatalf("transport = %T, want *pacedTransport", c.client.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRateLimiterFromEnv(t *testing.T) {
|
||||
t.Setenv("OO_RATE_LIMIT", "")
|
||||
t.Setenv("OO_BURST", "")
|
||||
if l := newRateLimiterFromEnv(); l == nil || l.rate != defaultRateLimit || l.burst != defaultBurst {
|
||||
t.Fatalf("defaults: got %+v, want rate=%v burst=%d", l, defaultRateLimit, defaultBurst)
|
||||
}
|
||||
|
||||
t.Setenv("OO_RATE_LIMIT", "0")
|
||||
if l := newRateLimiterFromEnv(); l != nil {
|
||||
t.Fatalf("OO_RATE_LIMIT=0: got %+v, want nil (off)", l)
|
||||
}
|
||||
|
||||
t.Setenv("OO_RATE_LIMIT", "10")
|
||||
t.Setenv("OO_BURST", "3")
|
||||
if l := newRateLimiterFromEnv(); l == nil || l.rate != 10 || l.burst != 3 {
|
||||
t.Fatalf("explicit: got %+v, want rate=10 burst=3", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilRateLimiterIsNoOp(t *testing.T) {
|
||||
var l *rateLimiter
|
||||
start := time.Now()
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatalf("nil limiter wait: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 20*time.Millisecond {
|
||||
t.Fatalf("nil limiter blocked for %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterPacesBurst(t *testing.T) {
|
||||
l := newRateLimiter(50, 1) // 20ms per token, no burst headroom
|
||||
start := time.Now()
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatalf("wait %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// 5 calls with burst 1 -> 4 gaps * 20ms = 80ms.
|
||||
if elapsed := time.Since(start); elapsed < 60*time.Millisecond {
|
||||
t.Fatalf("elapsed %v, want >= 60ms (not paced)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterHonoursContext(t *testing.T) {
|
||||
l := newRateLimiter(1, 1)
|
||||
if err := l.wait(context.Background()); err != nil {
|
||||
t.Fatalf("first wait: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := l.wait(ctx); err == nil {
|
||||
t.Fatal("want context error while waiting for a token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCooldownGateBlocksAndExpires(t *testing.T) {
|
||||
g := &cooldownGate{}
|
||||
g.note(50 * time.Millisecond)
|
||||
start := time.Now()
|
||||
if err := g.wait(context.Background()); err != nil {
|
||||
t.Fatalf("wait: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 45*time.Millisecond {
|
||||
t.Fatalf("gate did not block: %v", elapsed)
|
||||
}
|
||||
start = time.Now()
|
||||
if err := g.wait(context.Background()); err != nil {
|
||||
t.Fatalf("second wait: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 20*time.Millisecond {
|
||||
t.Fatalf("expired gate still blocked: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCooldownGateNoteExtendsNotShortens(t *testing.T) {
|
||||
g := &cooldownGate{}
|
||||
g.note(80 * time.Millisecond)
|
||||
g.note(10 * time.Millisecond) // must not shorten the window
|
||||
if d := time.Until(g.until); d < 70*time.Millisecond {
|
||||
t.Fatalf("note shortened gate: %v left", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPacedTransportArmsCooldownOn429 verifies that a throttled answer opens the
|
||||
// process-wide gate and that the next request is blocked for the Retry-After
|
||||
// window even though it targets a healthy endpoint.
|
||||
func TestPacedTransportArmsCooldownOn429(t *testing.T) {
|
||||
resetPacingForTest()
|
||||
defer resetPacingForTest()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/throttle" {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr := &pacedTransport{base: http.DefaultTransport}
|
||||
|
||||
req1, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/throttle", nil)
|
||||
resp1, err := tr.RoundTrip(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("throttled round trip: %v", err)
|
||||
}
|
||||
resp1.Body.Close()
|
||||
if resp1.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want 429", resp1.StatusCode)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
req2, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/ok", nil)
|
||||
resp2, err := tr.RoundTrip(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("second round trip: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if elapsed := time.Since(start); elapsed < 900*time.Millisecond {
|
||||
t.Fatalf("cooldown did not block next call: %v", elapsed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user