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

This commit is contained in:
2026-09-17 12:39:25 +00:00
parent 46661f51f8
commit c9e16c7169
16 changed files with 729 additions and 33 deletions
+128 -12
View File
@@ -3,38 +3,149 @@ package onlyoffice
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
// RetryPolicy controls deterministic retries against OnlyOffice: fixed linear
// RetryPolicy controls deterministic retries against OnlyOffice: exponential
// backoff without jitter, so repeated runs wait exactly the same schedule.
// OnlyOffice throttles bulk reads/writes with 429 (and occasional 502/503/504
// from openresty), so every bulk tool routes API calls through DoRetry.
type RetryPolicy struct {
Attempts int // total attempts, including the first try
Base time.Duration // wait before retry N is N*Base
Max time.Duration // per-wait cap
Base time.Duration // wait before retry N is Base*2^(N-1)
Max time.Duration // per-wait cap; <=0 means no cap
}
// DefaultRetryPolicy retries up to 5 times with 1s, 2s, 3s, 4s waits.
// DefaultRetryPolicy builds the policy from the environment, falling back to
// 7 attempts, a 2s base and a 2m cap:
//
// - OO_RETRY_ATTEMPTS (default 7)
// - OO_RETRY_BASE (duration, default 2s)
// - OO_RETRY_MAX (duration, default 2m)
func DefaultRetryPolicy() RetryPolicy {
return RetryPolicy{Attempts: 5, Base: time.Second, Max: 30 * time.Second}
return RetryPolicy{
Attempts: envInt("OO_RETRY_ATTEMPTS", 7),
Base: envDuration("OO_RETRY_BASE", 2*time.Second),
Max: envDuration("OO_RETRY_MAX", 2*time.Minute),
}
}
var transientRe = regexp.MustCompile(`:\s*(429|502|503|504)\b`)
// Transient reports whether err looks like a transient OnlyOffice answer
// (an HTTP 429/502/503/504 surfaced as "...: <code> ...").
// isTransientStatus reports whether an HTTP status is retriable at the edge.
func isTransientStatus(status int) bool {
switch status {
case http.StatusTooManyRequests, http.StatusBadGateway,
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
}
return false
}
// TransientError is a typed transient answer from the HTTP layer. It carries
// the status code and, when present, the server's Retry-After delay so DoRetry
// can wait at least that long.
type TransientError struct {
Code int
RetryAfter time.Duration
Msg string
}
func (e *TransientError) Error() string { return e.Msg }
// Transient reports whether err looks like a transient OnlyOffice answer: a
// *TransientError with a retriable code, or an error whose text carries an
// HTTP 429/502/503/504.
func Transient(err error) bool {
if err == nil {
return false
}
var te *TransientError
if errors.As(err, &te) {
return isTransientStatus(te.Code)
}
return transientRe.MatchString(err.Error())
}
// statusError wraps a non-2xx answer, tagging transient statuses so DoRetry
// recognises them and honours Retry-After.
func statusError(status int, retryAfter time.Duration, format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
if isTransientStatus(status) {
return &TransientError{Code: status, RetryAfter: retryAfter, Msg: msg}
}
return errors.New(msg)
}
// retryAfterOf parses the Retry-After header of a response (integer seconds or
// an HTTP-date). Returns 0 when absent or malformed.
func retryAfterOf(resp *http.Response) time.Duration {
if resp == nil {
return 0
}
return parseRetryAfter(resp.Header.Get("Retry-After"))
}
// parseRetryAfter parses a Retry-After value: delay-seconds (RFC 9110) or an
// HTTP-date. Zero, negative and malformed values yield 0.
func parseRetryAfter(v string) time.Duration {
v = strings.TrimSpace(v)
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil {
if secs <= 0 {
return 0
}
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}
// retryAfterOfError extracts Retry-After from a typed transient error.
func retryAfterOfError(err error) time.Duration {
var te *TransientError
if errors.As(err, &te) {
return te.RetryAfter
}
return 0
}
// backoffDelay returns the deterministic wait before retry `attempt`
// (counting from 1): Base*2^(attempt-1), capped at Max.
func backoffDelay(p RetryPolicy, attempt int) time.Duration {
if p.Base <= 0 {
return 0
}
wait := p.Base
for i := 1; i < attempt; i++ {
if p.Max > 0 && wait >= p.Max {
return p.Max
}
wait *= 2
}
if p.Max > 0 && wait > p.Max {
wait = p.Max
}
return wait
}
// DoRetry runs fn until it succeeds, fails non-transiently, or attempts run
// out. Waits are deterministic: N*Base capped at Max, no jitter.
// out. Waits are deterministic: Base*2^(N-1) capped at Max, no jitter. A
// transient error's Retry-After wins when it is longer than the backoff, and
// every wait arms the process-wide cooldown gate so concurrent and sequential
// callers back off too.
func DoRetry(ctx context.Context, p RetryPolicy, fn func() error) error {
if p.Attempts < 1 {
p.Attempts = 1
@@ -44,13 +155,18 @@ func DoRetry(ctx context.Context, p RetryPolicy, fn func() error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err = fn(); err == nil || !Transient(err) || attempt == p.Attempts {
err = fn()
if err == nil || !Transient(err) || attempt == p.Attempts {
return err
}
wait := time.Duration(attempt) * p.Base
if wait > p.Max {
wait = p.Max
wait := backoffDelay(p, attempt)
if ra := retryAfterOfError(err); ra > wait {
wait = ra
}
if wait <= 0 {
continue
}
globalCooldown.note(wait)
select {
case <-ctx.Done():
return ctx.Err()