Files
go-onlyoffice/ratelimit.go
T
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

211 lines
4.9 KiB
Go

package onlyoffice
// Client-side pacing and the process-wide 429 gate. A token bucket applies to
// every HTTP request a *Client makes (via pacedTransport), so listing, folder
// creation, uploads and even `get project` share one budget and cannot burst
// past openresty. A typed transient answer carrying Retry-After opens the
// cooldown gate for the whole process.
import (
"context"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
// Default pacing: ~4 requests/second with a single-request burst. This keeps
// bulk syncs under openresty's threshold without noticeably slowing one-shot
// tools.
const (
defaultRateLimit = 4.0
defaultBurst = 1
)
// rateLimiter is a deterministic token bucket. A nil *rateLimiter is a no-op,
// which is how OO_RATE_LIMIT=0 disables pacing.
type rateLimiter struct {
mu sync.Mutex
rate float64
burst float64
tokens float64
last time.Time
}
// newRateLimiter returns a limiter at rate requests/second with the given
// burst. rate <= 0 disables pacing and returns nil.
func newRateLimiter(rate float64, burst int) *rateLimiter {
if rate <= 0 {
return nil
}
if burst < 1 {
burst = 1
}
return &rateLimiter{
rate: rate,
burst: float64(burst),
tokens: float64(burst),
last: time.Now(),
}
}
// wait consumes one token, blocking until one is available or ctx is done.
func (l *rateLimiter) wait(ctx context.Context) error {
if l == nil {
return nil
}
for {
l.mu.Lock()
now := time.Now()
l.tokens += now.Sub(l.last).Seconds() * l.rate
if l.tokens > l.burst {
l.tokens = l.burst
}
l.last = now
if l.tokens >= 1 {
l.tokens--
l.mu.Unlock()
return nil
}
need := time.Duration((1 - l.tokens) / l.rate * float64(time.Second))
l.mu.Unlock()
if need < time.Millisecond {
need = time.Millisecond
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(need):
}
}
}
// cooldownGate is the process-wide 429 gate: after a throttled answer every
// request waits until the gate opens, so concurrent and sequential callers do
// not hammer the server.
type cooldownGate struct {
mu sync.Mutex
until time.Time
}
// note extends the gate to at least now+d.
func (g *cooldownGate) note(d time.Duration) {
if d <= 0 {
return
}
g.mu.Lock()
if u := time.Now().Add(d); u.After(g.until) {
g.until = u
}
g.mu.Unlock()
}
// wait blocks until the gate opens or ctx is done.
func (g *cooldownGate) wait(ctx context.Context) error {
g.mu.Lock()
d := time.Until(g.until)
g.mu.Unlock()
if d <= 0 {
return nil
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
var (
globalLimiter *rateLimiter
globalLimiterOn bool
globalLimiterMu sync.Mutex
globalCooldown = &cooldownGate{}
)
// limiter returns the process-wide rate limiter, initialising it from the
// environment on first use. OO_RATE_LIMIT=0 yields a nil (disabled) limiter.
func limiter() *rateLimiter {
globalLimiterMu.Lock()
defer globalLimiterMu.Unlock()
if !globalLimiterOn {
globalLimiter = newRateLimiterFromEnv()
globalLimiterOn = true
}
return globalLimiter
}
// newRateLimiterFromEnv builds a limiter from OO_RATE_LIMIT (requests/second,
// default 4; 0 disables) and OO_BURST (default 1). Malformed values fall back
// to the defaults.
func newRateLimiterFromEnv() *rateLimiter {
rate := defaultRateLimit
if v, ok := os.LookupEnv("OO_RATE_LIMIT"); ok {
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
rate = f
}
}
burst := defaultBurst
if v, ok := os.LookupEnv("OO_BURST"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
burst = n
}
}
return newRateLimiter(rate, burst)
}
// pacedTransport applies the process-wide cooldown gate and rate limiter to
// every request, and arms the gate on a 429. Installed by NewClient, so all
// HTTP paths — typed Query, the http.go helpers, auth and WebDAV — are paced.
type pacedTransport struct {
base http.RoundTripper
}
func (t *pacedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if err := globalCooldown.wait(req.Context()); err != nil {
return nil, err
}
if err := limiter().wait(req.Context()); err != nil {
return nil, err
}
resp, err := t.base.RoundTrip(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusTooManyRequests {
globalCooldown.note(retryAfterOf(resp))
}
return resp, nil
}
// envInt reads a positive integer env var, falling back to def.
func envInt(name string, def int) int {
v, ok := os.LookupEnv(name)
if !ok {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n <= 0 {
return def
}
return n
}
// envDuration reads a duration env var (Go syntax, e.g. "2s", "2m"),
// falling back to def.
func envDuration(name string, def time.Duration) time.Duration {
v, ok := os.LookupEnv(name)
if !ok {
return def
}
d, err := time.ParseDuration(strings.TrimSpace(v))
if err != nil || d <= 0 {
return def
}
return d
}