backoff/load: глобальный rate-limit + Retry-After + cooldown против 429 #70

Closed
opened 2026-09-17 13:36:04 +01:00 by eSlider · 2 comments
Owner

Цель

Устойчивость к 429 (openresty) на уровне библиотеки: глобальный rate-limit + Retry-After + экспоненциальный backoff + общий cooldown, чтобы берст листинга/создания папок/загрузки не приводил к фатальному 429 (get project 4).

Проблема (evidence)

extsync curasoft (office-assistant) абортит: onlyoffice: get project 4: GET /api/2.0/project/4.json: 429. Даже -rate 0.5/-retries 10 не помогает. Сейчас DoRetry — линейно 1–4с, 5 попыток, без Retry-After, без лимитера и без общего cooldown (retry.go).

Задача (библиотека, flat package onlyoffice)

  1. Глобальный лимитер (token bucket, stdlib, без новых зависимостей) на все HTTP-запросы (transport/retryRaw): env OO_RATE_LIMIT (req/s, default ~4–5, 0 = off) и OO_BURST (default 1). Пейсит листинг, создание папок, загрузку — всё.
  2. Retry-After: типизированная transient-ошибка с retryAfter; DoRetry уважает его (если больше backoff — ждёт его).
  3. Экспоненциальный backoff вместо линейного: base = env OO_RETRY_BASE (default 2s), cap = OO_RETRY_MAX (default 2m), attempts = OO_RETRY_ATTEMPTS (default 7). Детерминированно (без jitter — правило репо).
  4. Общий cooldown: после 429 гейт блокирует все вызовы на величину backoff (общий для процесса), чтобы параллельные/последовательные вызовы не били в стену.
  5. Дефолты сохраняют совместимость; документация в docs/.

Acceptance

  • при 429 с Retry-After: N ждём ≥N;
  • берст из >rate запросов/с пейсится лимитером (тест на счётчик+время);
  • OO_RATE_LIMIT=0 отключает лимитер;
  • экспонента по attempts, cap; детерминированно;
  • все HTTP-пути идут через лимитер+ретрай (включая get project);
  • go build ./..., go test ./... зелёные; README/docs обновлены.

Тесты

  • Юнит на DoRetry (Retry-After, экспонента, cap), на лимитер (burst), на cooldown. Фейковый сервер, отдающий 429 с заголовком.

Ревью/мерж PR — главный (RE). Реализация — SE.

## Цель Устойчивость к 429 (openresty) на уровне библиотеки: **глобальный rate-limit + `Retry-After` + экспоненциальный backoff + общий cooldown**, чтобы берст листинга/создания папок/загрузки не приводил к фатальному 429 (`get project 4`). ## Проблема (evidence) `extsync curasoft` (office-assistant) абортит: `onlyoffice: get project 4: GET /api/2.0/project/4.json: 429`. Даже `-rate 0.5`/`-retries 10` не помогает. Сейчас `DoRetry` — линейно 1–4с, 5 попыток, **без `Retry-After`**, **без лимитера** и **без общего cooldown** (`retry.go`). ## Задача (библиотека, flat package `onlyoffice`) 1. **Глобальный лимитер** (token bucket, stdlib, без новых зависимостей) на все HTTP-запросы (transport/`retryRaw`): env `OO_RATE_LIMIT` (req/s, default ~4–5, 0 = off) и `OO_BURST` (default 1). Пейсит листинг, создание папок, загрузку — всё. 2. **`Retry-After`**: типизированная transient-ошибка с `retryAfter`; `DoRetry` уважает его (если больше backoff — ждёт его). 3. **Экспоненциальный backoff** вместо линейного: base = env `OO_RETRY_BASE` (default 2s), cap = `OO_RETRY_MAX` (default 2m), attempts = `OO_RETRY_ATTEMPTS` (default 7). Детерминированно (без jitter — правило репо). 4. **Общий cooldown**: после 429 гейт блокирует все вызовы на величину backoff (общий для процесса), чтобы параллельные/последовательные вызовы не били в стену. 5. Дефолты сохраняют совместимость; документация в `docs/`. ## Acceptance - [ ] при 429 с `Retry-After: N` ждём ≥N; - [ ] берст из >rate запросов/с пейсится лимитером (тест на счётчик+время); - [ ] `OO_RATE_LIMIT=0` отключает лимитер; - [ ] экспонента по attempts, cap; детерминированно; - [ ] все HTTP-пути идут через лимитер+ретрай (включая `get project`); - [ ] `go build ./...`, `go test ./...` зелёные; README/docs обновлены. ## Тесты - Юнит на `DoRetry` (Retry-After, экспонента, cap), на лимитер (burst), на cooldown. Фейковый сервер, отдающий 429 с заголовком. Ревью/мерж PR — главный (RE). Реализация — SE.
Author
Owner

Реализация #70 (SE)

Что сделано

  • Глобальный лимитер — ratelimit.go, token bucket на stdlib. pacedTransport ставится в NewClient и пейсит все HTTP-запросы клиента (листинг, папки, upload, get project, auth, WebDAV). Новых зависимостей нет.
  • Retry-After — типизированная *TransientError{Code, RetryAfter, Msg}; HTTP-слой отдаёт её из всех statusError-точек (http.go, request.go, auth.go, files_webdav.go, mails.go, storage_fallback.go). Парсится delay-seconds и HTTP-date.
  • Экспонента вместо линейной — backoffDelay = Base*2^(N-1), cap Max. Детерминированно, без jitter.
  • Общий cooldown — процесс-гейт cooldownGate: DoRetry открывает его на окно ожидания, pacedTransport ждёт перед каждым запросом; 429 с Retry-After открывает гейт и для здоровых эндпоинтов.
  • Документация: docs/rate-limiting.md (new), README (таблица API + env), AGENTS.md, CHANGELOG.

Env / дефолты

Переменная Default Смысл
OO_RATE_LIMIT 4 req/s на процесс; 0 = лимитер off
OO_BURST 1 запас token bucket
OO_RETRY_ATTEMPTS 7 всего попыток
OO_RETRY_BASE 2s база экспоненты
OO_RETRY_MAX 2m потолок ожидания

Тесты (TDD)

Фейковый httptest сервер отдаёт 429 с Retry-After; проверены: Retry-After honoured, экспонента/cap, burst pacing (счётчик+время), OO_RATE_LIMIT=0 off, cooldown после 429, context cancellation, typed error из заголовка.

gofmt -w ; go build ./... ; go test ./...
ok github.com/eslider/go-onlyoffice 1.2s
ok ... (все пакеты)

PR

#71 — fix/oo-backoff#70 → main.

Блокеры

Нет. Мерж — за RE.

## Реализация #70 (SE) ### Что сделано - **Глобальный лимитер** — `ratelimit.go`, token bucket на stdlib. `pacedTransport` ставится в `NewClient` и пейсит **все** HTTP-запросы клиента (листинг, папки, upload, `get project`, auth, WebDAV). Новых зависимостей нет. - **`Retry-After`** — типизированная `*TransientError{Code, RetryAfter, Msg}`; HTTP-слой отдаёт её из всех `statusError`-точек (http.go, request.go, auth.go, files_webdav.go, mails.go, storage_fallback.go). Парсится delay-seconds и HTTP-date. - **Экспонента вместо линейной** — `backoffDelay = Base*2^(N-1)`, cap `Max`. Детерминированно, без jitter. - **Общий cooldown** — процесс-гейт `cooldownGate`: `DoRetry` открывает его на окно ожидания, `pacedTransport` ждёт перед каждым запросом; 429 с `Retry-After` открывает гейт и для здоровых эндпоинтов. - Документация: `docs/rate-limiting.md` (new), README (таблица API + env), AGENTS.md, CHANGELOG. ### Env / дефолты | Переменная | Default | Смысл | |---|---|---| | `OO_RATE_LIMIT` | `4` | req/s на процесс; `0` = лимитер off | | `OO_BURST` | `1` | запас token bucket | | `OO_RETRY_ATTEMPTS` | `7` | всего попыток | | `OO_RETRY_BASE` | `2s` | база экспоненты | | `OO_RETRY_MAX` | `2m` | потолок ожидания | ### Тесты (TDD) Фейковый `httptest` сервер отдаёт 429 с `Retry-After`; проверены: Retry-After honoured, экспонента/cap, burst pacing (счётчик+время), `OO_RATE_LIMIT=0` off, cooldown после 429, context cancellation, typed error из заголовка. ``` gofmt -w ; go build ./... ; go test ./... ok github.com/eslider/go-onlyoffice 1.2s ok ... (все пакеты) ``` ### PR https://git.produktor.io/eSlider/go-onlyoffice/pulls/71 — `fix/oo-backoff#70` → `main`. ### Блокеры Нет. Мерж — за RE.
Author
Owner

Исправлено в main: commit c9e16c7 (PR #71) — ratelimit.go (token bucket OO_RATE_LIMIT/OO_BURST + общий cooldown), retry.go (типизированный TransientError с Retry-After, экспонента, OO_RETRY_*), все HTTP-пути через retryRaw; тесты ratelimit_test.go/retry_test.go зелёные. Выпущено в v0.21.0 (тег на Gitea). Закрываю.

Исправлено в main: commit c9e16c7 (PR #71) — `ratelimit.go` (token bucket OO_RATE_LIMIT/OO_BURST + общий cooldown), `retry.go` (типизированный TransientError с Retry-After, экспонента, OO_RETRY_*), все HTTP-пути через `retryRaw`; тесты `ratelimit_test.go`/`retry_test.go` зелёные. Выпущено в **v0.21.0** (тег на Gitea). Закрываю.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: eSlider/go-onlyoffice#70