Payments are experiencing issues due to temporary restrictions in Russia. If your payment does not go through, please submit a support request.Our support team is available 24/7 — we are always here to help with hosting and server issues.We are now accepting requests for dedicated server rental and colocation services in our data center.Reminder: we recommend enabling backups for additional data protection.A new VPS/VDS lineup with NVMe storage and improved performance is now available.Maintenance work on some servers has been completed. All services are operating normally.
Article8 min readViews0

One Limit for Multiple Workers: Testing the Shared Token Bucket API

On a deterministic model, we compare local and shared token buckets: two workers receive four 429 responses with local limits, while a single shared limiter distributes eight requests without exceeding the limit.

Comments 0

A fictional coordinator regulates the total flow of shipping units through a single mechanical turnstile
In this article

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

  1. Two or more workers sharing a single quota circuit reserve permissions in a single state.

  2. With a capacity of 3 and a rate of 2 requests per second, the initial burst does not exceed three requests.

  3. A pause after a 429 response blocks all workers in the same contour until the calculated point.

  4. A coordinator failure does not create a new full bucket in each process without an explicit policy.

  5. Metrics distinguish between waiting for a local limiter, a server 429 response, and exhausting the retry budget.

  6. 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.

Discussion 0

Share your experience and ask questions. Comments without links appear after editorial review.

No comments yet. Start the discussion.