Один лимит на несколько воркеров: проверяем общий token bucket API
На детерминированной модели сравниваем локальные и общий token bucket: два воркера получают четыре ответа 429, а единый лимитер распределяет восемь запросов без превышения.

В этой статье
- What HTTP Guarantees and What the Client Chooses
- Conditions for reproducible verification
- Code for the training model
- Observed result
- How to define the scope of a shared limiter
- Here is a catch: a shared bucket does not know the limit better than the server.
- Retries after a pause must be safe
- Acceptance Criteria
Если несколько воркеров отправляют запросы по одному общему лимиту внешнего API, отдельный ограничитель в каждом процессе умножает разрешённый всплеск. Лимитер должен быть общим для того же контура, по которому считает сервер: например, для одного токена, арендатора или метода. Иначе восемь аккуратных очередей вместе ведут себя как одна очень уверенная лавина.
На маленьком примере два воркера одновременно получают восемь заданий. Серверная учебная модель принимает всплеск из трёх запросов и восстанавливает по два разрешения в секунду. Два локальных token bucket дают четыре ответа 429. Один общий bucket распределяет те же восемь запросов от 0 до 2,5 секунды и не получает ни одного 429.
Схема клиента | Отправлено в t=0 | Ответы 200 | Ответы 429 |
|---|---|---|---|
Отдельный bucket у A и B | 6 | 4 | 4 |
Один общий bucket | 3 | 8 | 0 |
Что гарантирует HTTP, а что выбирает клиент
Код 429 означает, что клиент отправил слишком много запросов за некоторый интервал. В RFC 6585 прямо сказано: стандарт не определяет, как сервер считает запросы и кого считает одним отправителем. Ограничение может действовать на ресурс, весь сервис или учётные данные.
Token bucket — инженерный выбор для учебного клиента, а не обязательный алгоритм HTTP. Он хранит ёмкость всплеска, текущие токены и скорость пополнения. Один запрос забирает один токен; если токенов нет, клиент вычисляет ближайшее допустимое время. Реальный API может использовать фиксированное окно, скользящее окно или внутреннюю комбинацию правил.
Условия воспроизводимой проверки
Проверка выполнена 1 ноября 2026 года в CPython 3.12.14. Модель работает только в памяти: не открывает сеть, не пишет файлы и не подключается к базе. Виртуальное время задаётся числами, поэтому результат не зависит от скорости компьютера.
два воркера A и B получают по четыре задания в момент t=0;
сервер и клиентская модель имеют ёмкость 3 и скорость пополнения 2 токена в секунду;
в первом сценарии у каждого воркера собственный полный bucket;
во втором сценарии оба воркера резервируют время в одном bucket;
после последнего запроса дополнительно моделируется общий Retry-After на 1,5 секунды;
это проверка механизма, а не измерение лимита конкретного API или редакции 1С-Битрикс.
Код учебной модели
from __future__ import annotations
from dataclasses import dataclass
EPSILON = 1e-9
@dataclass
class TokenBucket:
capacity: float
refill_per_second: float
tokens: float
updated_at: float = 0.0
blocked_until: float = 0.0
@classmethod
def full(cls, capacity: int, refill_per_second: float) -> "TokenBucket":
return cls(float(capacity), refill_per_second, float(capacity))
def _refill(self, now: float) -> None:
if now < self.updated_at:
raise ValueError("time_must_be_monotonic")
elapsed = now - self.updated_at
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.refill_per_second,
)
self.updated_at = now
def reserve(self, requested_at: float) -> float:
now = max(requested_at, self.blocked_until, self.updated_at)
self._refill(now)
if self.tokens + EPSILON < 1.0:
wait = (1.0 - self.tokens) / self.refill_per_second
now += wait
self._refill(now)
self.tokens -= 1.0
return round(now, 6)
def allow(self, now: float) -> bool:
self._refill(now)
if self.tokens + EPSILON < 1.0:
return False
self.tokens -= 1.0
return True
def pause(self, now: float, seconds: float) -> None:
if seconds < 0:
raise ValueError("negative_pause")
self.blocked_until = max(self.blocked_until, now + seconds)
def run(schedule: list[tuple[str, float]]) -> tuple[int, int]:
server = TokenBucket.full(capacity=3, refill_per_second=2.0)
accepted = 0
rejected = 0
for worker, sent_at in sorted(schedule, key=lambda item: item[1]):
ok = server.allow(sent_at)
print(f"{worker} t={sent_at:.1f}: {'200' if ok else '429'}")
accepted += int(ok)
rejected += int(not ok)
return accepted, rejected
jobs = ["A", "B"] * 4
local = {name: TokenBucket.full(3, 2.0) for name in {"A", "B"}}
naive_schedule = [(name, local[name].reserve(0.0)) for name in jobs]
naive_result = run(naive_schedule)
print(f"separate: accepted={naive_result[0]} rejected={naive_result[1]}")
assert naive_result == (4, 4)
shared = TokenBucket.full(3, 2.0)
shared_schedule = [(name, shared.reserve(0.0)) for name in jobs]
shared_result = run(shared_schedule)
print(f"shared: accepted={shared_result[0]} rejected={shared_result[1]}")
assert shared_result == (8, 0)
assert [sent_at for _, sent_at in shared_schedule] == [
0.0, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5,
]
shared.pause(now=2.5, seconds=1.5)
after_429 = [shared.reserve(2.6), shared.reserve(2.7)]
print(f"after Retry-After: {after_429}")
assert after_429 == [4.0, 4.0]
Наблюдаемый результат
A t=0.0: 200
B t=0.0: 200
A t=0.0: 200
B t=0.0: 429
A t=0.0: 429
B t=0.0: 429
A t=0.5: 200
B t=0.5: 429
separate: accepted=4 rejected=4
A t=0.0: 200
B t=0.0: 200
A t=0.0: 200
B t=0.5: 200
A t=1.0: 200
B t=1.5: 200
A t=2.0: 200
B t=2.5: 200
shared: accepted=8 rejected=0
after Retry-After: [4.0, 4.0]
Процесс завершился с кодом 0, все утверждения прошли. В раздельной схеме оба воркера считают, что вправе немедленно отправить по три запроса: в t=0 на сервер приходит шесть вызовов, хотя глобальная ёмкость равна трём. В t=0,5 оба локальных bucket снова выдают по токену, а сервер успевает восстановить только один. Отсюда четыре принятых запроса и четыре ответа 429.
Общий bucket видит совокупную очередь. Он разрешает три запроса в t=0, затем выдаёт по одному разрешению через 0,5 секунды. Серверная модель с теми же параметрами принимает все восемь. После имитации Retry-After оба следующих задания получают время 4,0: пауза действует на общий контур, а не только на воркер, который увидел ошибку.
Как определить область общего лимитера
Сначала выясните, по какому признаку внешний сервис объединяет запросы. Если квота привязана к токену, все процессы с этим токеном должны координироваться. Если лимиты различаются по методу или магазину, один глобальный замок способен зря остановить независимые потоки. Область ключа лимитера должна повторять документированный контур квоты настолько точно, насколько это позволяет API.
Зафиксируйте идентификатор контура без секрета: например, хеш идентификатора учётных данных, арендатора и группы методов.
Резервируйте токен атомарно. Два процесса не должны одновременно увидеть один и тот же остаток.
Для нескольких процессов храните состояние в общей службе или используйте отдельный координатор. Внутрипроцессный объект из примера распределённую задачу не решает.
Используйте монотонное время для интервалов. Перевод системных часов не должен мгновенно наполнить bucket или отправить его в прошлое.
После 429 применяйте паузу ко всему совпавшему контуру и отдельно учитывайте общий бюджет попыток.
Формы Retry-After и их безопасный разбор уже проверены в материале «429 Retry-After: секунды или дата — проверяем паузу клиента API». Здесь результат другой: мы проверяем координацию параллельных отправителей до и после общей паузы.
Тут есть подвох: общий bucket не знает лимит лучше сервера
Даже правильно разделённый token bucket не гарантирует отсутствие 429. Параллельно может работать другой клиент, сервер способен изменить квоту, а его окно может не совпадать с моделью. Ответ 429 остаётся рабочим сигналом обратной связи: клиент замедляется, соблюдает Retry-After при наличии и не увеличивает число одновременных повторов.
Если общая служба координации недоступна, опасно молча создавать полный локальный bucket в каждом воркере: именно так ограничение внезапно умножается. Для конкретной интеграции заранее выбирают режим отказа — остановить выдачу, поставить задания в ограниченную очередь или перейти на документированную уменьшенную скорость. Решение зависит от допустимой задержки и последствий запроса.
Повтор после паузы должен быть безопасным
Ограничитель отвечает на вопрос «когда отправить», но не меняет бизнес-смысл повторной команды. Если соединение оборвалось после обработки запроса, повтор может снова создать движение товара. Для дельт и абсолютных значений этот риск отдельно разобран в статье «Дельта в API: как исключить повторное прибавление количества товара».
В обмене с 1С и 1С-Битрикс нужно дополнительно проверить направление, торговые предложения, склады, права технического пользователя и реальный контракт внешнего API. Названия методов, квоты и доступность функций зависят от редакции, версии, модуля и сервиса. Учебный bucket не подменяет эту проверку.
Критерии приёмки
Два и более воркера с одним контуром квоты резервируют разрешения в одном состоянии.
При ёмкости 3 и скорости 2 запроса в секунду стартовый всплеск не превышает трёх запросов.
Пауза после 429 блокирует все воркеры того же контура до вычисленного момента.
Сбой координатора не создаёт новый полный bucket в каждом процессе без явной политики.
Метрики различают ожидание локального лимитера, ответ 429 сервера и исчерпание бюджета повторов.
Повторяемая бизнес-операция защищена от дубля независимо от скорости отправки.
Приёмку промышленной интеграции завершайте на изолированном стенде с фактической квотой и несколькими реальными процессами. Нужны наблюдаемые время отправки, идентификатор контура без секрета, статусы ответов и критерий остановки. Модель показывает причину всплеска и механизм координации; притворяться паспортом чужого API ей рано.
If multiple workers send requests against a single shared external API limit, a separate limiter in each process multiplies the allowed burst. The limiter must be shared for the same circuit the server counts: for example, for a single token, tenant, or method. Otherwise, eight neat queues behave like one very confident avalanche.
In a small example, two workers simultaneously receive eight tasks. The server simulation model accepts a burst of three requests and restores two permissions per second. Two local token buckets yield four 429 responses. One shared bucket distributes the same eight requests over 0 to 2.5 seconds and receives no 429 responses.
Client Scheme | Sent at t=0 | 200 Responses | 429 Responses |
|---|---|---|---|
Separate bucket for A and B | 6 | 4 | 4 |
One shared bucket | 3 | 8 | 0 |
What HTTP Guarantees and What the Client Chooses
Error code 429 means the client sent too many requests within a certain interval. RFC 6585 explicitly states: the standard does not define how a server counts requests or who counts as a single sender. The limit may apply to a resource, the entire service, or credentials.
Token bucket is an engineering choice for a training client, not a mandatory HTTP algorithm. It stores burst capacity, current tokens, and the refill rate. One request consumes one token; if no tokens are available, the client calculates the next allowable time. A real API may use a fixed window, a sliding window, or an internal combination of rules.
Conditions for reproducible verification
Verification performed on November 1, 2026, in CPython 3.12.14. The model operates solely in memory: it does not open network connections, write files, or connect to a database. Virtual time is set via numbers, so the result is independent of computer speed.
two workers A and B receive four tasks each at t=0;
the server and client model have a capacity of 3 and a token replenishment rate of 2 tokens per second;
in the first scenario, each worker has its own full bucket;
in the second scenario, both workers reserve time in a single bucket;
after the last request, a shared Retry-After of 1.5 seconds is additionally modeled;
this is a check of the mechanism, not a measurement of the limit for a specific API or 1C-Bitrix edition.
Code for the training model
from __future__ import annotations
from dataclasses import dataclass
EPSILON = 1e-9
@dataclass
class TokenBucket:
capacity: float
refill_per_second: float
tokens: float
updated_at: float = 0.0
blocked_until: float = 0.0
@classmethod
def full(cls, capacity: int, refill_per_second: float) -> "TokenBucket":
return cls(float(capacity), refill_per_second, float(capacity))
def _refill(self, now: float) -> None:
if now < self.updated_at:
raise ValueError("time_must_be_monotonic")
elapsed = now - self.updated_at
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.refill_per_second,
)
self.updated_at = now
def reserve(self, requested_at: float) -> float:
now = max(requested_at, self.blocked_until, self.updated_at)
self._refill(now)
if self.tokens + EPSILON < 1.0:
wait = (1.0 - self.tokens) / self.refill_per_second
now += wait
self._refill(now)
self.tokens -= 1.0
return round(now, 6)
def allow(self, now: float) -> bool:
self._refill(now)
if self.tokens + EPSILON < 1.0:
return False
self.tokens -= 1.0
return True
def pause(self, now: float, seconds: float) -> None:
if seconds < 0:
raise ValueError("negative_pause")
self.blocked_until = max(self.blocked_until, now + seconds)
def run(schedule: list[tuple[str, float]]) -> tuple[int, int]:
server = TokenBucket.full(capacity=3, refill_per_second=2.0)
accepted = 0
rejected = 0
for worker, sent_at in sorted(schedule, key=lambda item: item[1]):
ok = server.allow(sent_at)
print(f"{worker} t={sent_at:.1f}: {'200' if ok else '429'}")
accepted += int(ok)
rejected += int(not ok)
return accepted, rejected
jobs = ["A", "B"] * 4
local = {name: TokenBucket.full(3, 2.0) for name in {"A", "B"}}
naive_schedule = [(name, local[name].reserve(0.0)) for name in jobs]
naive_result = run(naive_schedule)
print(f"separate: accepted={naive_result[0]} rejected={naive_result[1]}")
assert naive_result == (4, 4)
shared = TokenBucket.full(3, 2.0)
shared_schedule = [(name, shared.reserve(0.0)) for name in jobs]
shared_result = run(shared_schedule)
print(f"shared: accepted={shared_result[0]} rejected={shared_result[1]}")
assert shared_result == (8, 0)
assert [sent_at for _, sent_at in shared_schedule] == [
0.0, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5,
]
shared.pause(now=2.5, seconds=1.5)
after_429 = [shared.reserve(2.6), shared.reserve(2.7)]
print(f"after Retry-After: {after_429}")
assert after_429 == [4.0, 4.0]
Observed result
A t=0.0: 200
B t=0.0: 200
A t=0.0: 200
B t=0.0: 429
A t=0.0: 429
B t=0.0: 429
A t=0.5: 200
B t=0.5: 429
separate: accepted=4 rejected=4
A t=0.0: 200
B t=0.0: 200
A t=0.0: 200
B t=0.5: 200
A t=1.0: 200
B t=1.5: 200
A t=2.0: 200
B t=2.5: 200
shared: accepted=8 rejected=0
after Retry-After: [4.0, 4.0]
The process completed with code 0; all assertions passed. In the separate scheme, both workers believe they can immediately send three requests each: at t=0, six calls arrive at the server, although the global capacity is three. At t=0.5, both local buckets issue one token each, but the server manages to recover only one. This results in four accepted requests and four 429 responses.
A shared bucket sees the combined queue. It allows three requests at t=0, then issues one allowance every 0.5 seconds. A server model with the same parameters accepts all eight requests. After simulating Retry-After, both subsequent jobs receive a time of 4.0: the pause applies to the shared circuit, not just the worker that encountered the error.
How to define the scope of a shared limiter
First, determine the criterion by which the external service groups requests. If the quota is tied to a token, all processes using that token must coordinate. If limits differ by method or store, a single global lock can unnecessarily halt independent streams. The scope of the limiter's key must mirror the documented quota contour as precisely as the API allows.
Record the contour identifier without a secret: for example, a hash of the credentials, tenant, and method group identifiers.
Reserve tokens atomically. Two processes must not simultaneously see the same remaining balance.
For multiple processes, store state in a shared service or use a separate coordinator. The in-process object from the example does not solve a distributed task.
Use monotonic time for intervals. Adjusting system clocks must not instantly fill a bucket or send it back in time.
After receiving a 429 response, apply a pause to the entire matching circuit and separately account for the total attempt budget.
The Retry-After header formats and their safe parsing have already been verified in the article «429 Retry-After: seconds or date — checking the client API pause». Here the result differs: we are checking the coordination of parallel senders before and after a shared pause.
Here is a catch: a shared bucket does not know the limit better than the server.
Even a correctly partitioned token bucket does not guarantee the absence of 429 responses. Another client may be running in parallel, the server may change the quota, and its window may not align with the model. A 429 response remains a valid feedback signal: the client slows down, respects Retry-After if present, and does not increase the number of concurrent retries.
If the central coordination service is unavailable, it is dangerous to silently create a full local bucket in each worker: this is exactly how limits suddenly multiply. For a specific integration, a failure mode must be chosen in advance—stop issuing requests, queue tasks in a limited queue, or switch to a documented reduced rate. The decision depends on acceptable latency and the consequences of the request.
Retries after a pause must be safe
The limiter answers the question of when to send, but does not change the business meaning of the repeated command. If a connection drops after request processing, a retry may recreate product movement. This risk for deltas and absolute values is analyzed separately in the article «Delta in API: how to exclude repeated quantity additions».
When integrating with 1C and 1C-Bitrix, you must additionally verify the direction, product variants, warehouses, technical user permissions, and the actual external API contract. Method names, quotas, and feature availability depend on the edition, version, module, and service. A training bucket does not replace this verification.
Acceptance Criteria
Two or more workers sharing a single quota circuit reserve permissions in a single state.
With a capacity of 3 and a rate of 2 requests per second, the initial burst does not exceed three requests.
A pause after a 429 response blocks all workers in the same contour until the calculated point.
A coordinator failure does not create a new full bucket in each process without an explicit policy.
Metrics distinguish between waiting for a local limiter, a server 429 response, and exhausting the retry budget.
A repeatable business operation is protected against duplicates regardless of the sending speed.
Complete acceptance testing for industrial integration on an isolated staging environment with the actual quota and several real processes. You need observable send times, a contour identifier without secrets, response statuses, and a stop criterion. The model shows the cause of the spike and the coordination mechanism; it is too early for it to pretend to be a passport for someone else's API.





Обсуждение 0
Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.
Пока никто не написал. Начни обсуждение.