Backpressure and Rate Limiting for Supplier Feed Ingestion Permalink to this section
↑ Part of Async Batch Processing for High-Volume Feeds.
A bounded asyncio.Semaphore stops an ingester from opening more sockets than the connection pool can hold, but it does not stop the ingester from opening those sockets faster than a supplier’s API quota allows, and it does nothing at all if the process producing records outruns the coroutines consuming them. Overwhelming downstream is rarely one failure — it is three related ones: too many concurrent requests, too many requests per second against a single trading partner, and an unbounded buffer of not-yet-processed records sitting in memory while the broker, database, or matching engine falls behind. This page is narrowly about the mechanics that prevent all three: capping in-flight work with a semaphore, capping per-supplier throughput with a token bucket, and using a bounded queue that blocks the producer rather than growing forever, plus the decision to shed load instead of buffering it when even blocking is not enough.
Operational Trigger Signals Permalink to this section
Add explicit backpressure and rate limiting when the pipeline’s own telemetry shows one or more of these conditions, not preemptively on every ingester regardless of load:
- Consumer lag rising faster than producer throughput: if the reader task is enqueuing records faster than the worker pool drains them, queue depth climbs monotonically across a run instead of oscillating around a steady state. A queue that never returns to its baseline depth between polling cycles is the first sign the consumer side needs more concurrency, fewer producers, or an explicit cap.
- Worker OOM-kills mid-run: an unbounded
asyncio.gather()over every record in a feed, or an unboundedlistaccumulating parsed payloads before a single bulk write, holds the entire feed in memory at once. A process that dies withOSError: [Errno 12] Cannot allocate memoryor is OOM-killed by the kernel partway through a large feed is buffering instead of streaming. 429 Too Many Requestsor503 Service Unavailablefrom a specific supplier: this is the clearest signal of all — the remote API is telling you its quota, and continuing to retry at the same rate only compounds the problem. A per-supplier 429 rate above a few percent of that supplier’s requests means the token bucket for that supplier is undersized or missing entirely.- Database connection pool saturation downstream of the ingester: if the matcher or staging writer reports
TimeoutError: connection pool exhaustedwhile the ingester itself reports healthy throughput, the ingester is producing faster than the database can absorb — a queue-depth or semaphore problem one hop downstream of where it appears.
These four signals point at three different bottlenecks — concurrent load, per-supplier rate, and buffer growth — so the fix is never a single knob. The Async Batch Processing for High-Volume Feeds cluster covers connector and semaphore sizing broadly; this page implements the three specific primitives that keep a concurrent ingester from tipping any of them over.
The token bucket and the semaphore answer different questions and both must pass before a request is dispatched. The bucket governs the sustained rate allowed against one supplier’s endpoint; the semaphore governs how many requests, across every supplier, are open on the system at once:
where is the bucket’s token level at time , is the bucket capacity (burst allowance), is the refill rate in requests per second, is the token cost of the pending request (usually 1), and is the semaphore’s max_inflight bound. A record is only dispatched when the supplier-specific bucket has enough tokens and fewer than requests are in flight system-wide — passing one gate without the other is not sufficient.
Step-by-Step Implementation Permalink to this section
Build the gate as a small composable unit so every producer, regardless of feed format, goes through the same admission control:
- Size the semaphore to the tightest downstream resource — the smaller of the connection pool, the database pool, or the matcher’s worker count — never to an arbitrary “feels fast” number.
- Give every supplier its own token bucket — a shared bucket lets one high-volume supplier starve a low-volume one, and a single global rate cannot represent ten different quota agreements.
- Bound the queue and choose a policy for “full” — decide up front whether a full queue blocks the producer (safe, adds latency) or sheds new work (fast, drops data unless the caller retries).
- Wire acquisition order — pull from the queue, acquire a token from the record’s supplier bucket, then acquire a semaphore slot, so a request never holds a semaphore slot while waiting on a rate limit.
- Log every gate decision — queue-full events, token waits, and semaphore waits are the signals that later tell you which resource is actually constrained.
- Fail closed on rate-limit responses — a
429from the supplier should shrink that supplier’s effective rate immediately, not just trigger a retry at the same speed.
The implementation below composes a TokenBucket, an asyncio.Semaphore, and a bounded asyncio.Queue into a single SupplierIngestGate. The queue’s put() is the backpressure point: once it is full, the producer’s await suspends until a worker frees a slot, which is what stops an unbounded buildup in memory:
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Awaitable, Callable, Dict
logger = logging.getLogger("supply_chain.ingest.backpressure")
FeedRecord = Dict[str, str]
DownstreamFn = Callable[[FeedRecord], Awaitable[None]]
@dataclass
class TokenBucket:
"""Per-supplier rate limiter: bounds sustained throughput, allows short bursts."""
capacity: float
refill_rate: float # tokens (requests) per second
tokens: float = field(init=False)
last_refill: float = field(init=False)
def __post_init__(self) -> None:
self.tokens = self.capacity
self.last_refill = time.monotonic()
def _refill(self) -> None:
now = time.monotonic()
elapsed = now - self.last_refill
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
self.last_refill = now
async def acquire(self, supplier_id: str, cost: float = 1.0) -> None:
"""Block until `cost` tokens are available for this supplier's bucket."""
while True:
self._refill()
if self.tokens >= cost:
self.tokens -= cost
return
wait_s = (cost - self.tokens) / self.refill_rate
logger.debug(
"token_bucket_wait supplier=%s cost=%.1f wait_s=%.3f",
supplier_id, cost, wait_s,
)
await asyncio.sleep(min(wait_s, 0.25))
def penalize(self, factor: float = 0.5) -> None:
"""Shrink the effective rate after a 429; call from the caller's error handler."""
self.refill_rate *= factor
self.tokens = min(self.tokens, self.capacity)
class SupplierIngestGate:
"""Bounds in-flight concurrency, per-supplier rate, and buffered queue depth."""
def __init__(
self,
max_inflight: int,
supplier_buckets: Dict[str, TokenBucket],
queue_maxsize: int = 200,
shed_when_full: bool = False,
) -> None:
self.semaphore = asyncio.Semaphore(max_inflight)
self.supplier_buckets = supplier_buckets
self.queue: asyncio.Queue[FeedRecord] = asyncio.Queue(maxsize=queue_maxsize)
self.shed_when_full = shed_when_full
self.shed_count = 0
async def submit(self, record: FeedRecord) -> bool:
"""Producer entrypoint. Returns False if the record was shed, not enqueued."""
if self.shed_when_full and self.queue.full():
self.shed_count += 1
logger.warning(
"record_shed supplier=%s qsize=%d maxsize=%d",
record["supplier_id"], self.queue.qsize(), self.queue.maxsize,
)
return False
if self.queue.full():
logger.info("queue_full_blocking supplier=%s", record["supplier_id"])
await self.queue.put(record) # blocks the producer instead of buffering unbounded
return True
async def _worker(self, worker_id: int, downstream: DownstreamFn) -> None:
while True:
record = await self.queue.get()
supplier_id = record["supplier_id"]
bucket = self.supplier_buckets.get(supplier_id)
try:
if bucket is not None:
await bucket.acquire(supplier_id)
async with self.semaphore:
await downstream(record)
except Exception as exc:
logger.error(
"record_failed worker=%d supplier=%s err=%s", worker_id, supplier_id, exc
)
finally:
self.queue.task_done()
async def run(self, downstream: DownstreamFn, worker_count: int = 8) -> None:
"""Start a bounded worker pool that drains the queue under both gates."""
workers = [asyncio.create_task(self._worker(i, downstream)) for i in range(worker_count)]
await self.queue.join()
for w in workers:
w.cancel()
logger.info("gate_run_complete workers=%d shed_count=%d", worker_count, self.shed_count)
submit() is the whole backpressure story: when shed_when_full is False (the default), a full queue means await self.queue.put(record) simply does not return until a worker calls task_done() and frees a slot, so the producer’s own coroutine stalls rather than the process growing without bound. Acquiring the token bucket before the semaphore also matters — a request that is waiting on a rate limit should never hold a scarce concurrency slot idle, which is why bucket.acquire() runs outside async with self.semaphore. For the underlying event-loop and task-scheduling mechanics this builds on, see Implementing Asyncio for Concurrent Batch Ingestion.
Configuration Reference Permalink to this section
| Parameter | Accepted values | Default | Notes |
|---|---|---|---|
max_inflight (N) |
5–200 | 20 | Semaphore bound; set to the smallest downstream pool, not a guess. |
TokenBucket.capacity © |
5–500 | 50 | Burst allowance per supplier above the sustained rate. |
TokenBucket.refill_rate ® |
0.5–100 req/s | supplier-tier specific | Match the supplier’s published or observed quota, not a shared default. |
queue_maxsize (Q) |
50–2000 | 200 | Bounds worst-case buffered memory; size to chunk_bytes × Q under budget. |
shed_when_full |
True / False |
False |
False blocks producers (safe); True rejects new work (fast, lossy unless retried upstream). |
worker_count |
4–64 | 8 | Consumers draining the queue; independent of max_inflight. |
penalize factor |
0.3–0.7 | 0.5 | Multiplier applied to refill_rate after a 429 from that supplier. |
Set max_inflight first, from the tightest downstream constraint, then size each supplier’s refill_rate from its documented or observed quota — never the reverse, or the semaphore ends up compensating for a rate problem it cannot see. queue_maxsize should be small enough that a full queue represents seconds, not minutes, of backlog; a queue sized to “never fill” defeats the purpose of bounding it.
Debugging & Recovery Permalink to this section
- Producer coroutine appears hung. If
submit()never returns, the queue is full andshed_when_fullisFalse— this is the backpressure working as designed, not a bug. Confirm by loggingqueue.qsize()on an interval; if it is pinned atmaxsize, the fix is more workers, a largermax_inflight, or addressing whatever is slow downstream, not raisingqueue_maxsize, which only delays the same stall. - Rising
token_bucket_waitdurations for one supplier. The bucket is correctly throttling, but if wait times keep growing, eitherrefill_rateis set below what the pipeline actually needs, or a previouspenalize()call shrank it after a429and it was never restored. Log the bucket’s currentrefill_ratealongside the wait so you can distinguish a permanent misconfiguration from a temporary penalty. 429responses continue after adding a token bucket. The bucket caps your outbound rate, but a burst at the start of a run —capacitytokens all spent in the first second — can still exceed a strict supplier quota. Lowercapacitycloser torefill_rateto flatten the burst, and confirm the supplier’s quota window (per-second vs. per-minute) matches howrefill_rateis expressed.- Shed records disappearing silently.
shed_when_full=TruereturnsFalsefromsubmit()and incrementsshed_count, but if the caller does not check that return value, records vanish without a trace. Route every shed record to the same recovery path as a processing failure — the dead-letter queues and retry orchestration pattern applies equally to “rejected before processing” and “failed during processing.” - You can see the queue is full but not why. Queue depth, per-supplier token wait time, semaphore wait time, and shed count are the four numbers that localize the bottleneck; expose them as gauges rather than only log lines. The monitoring and alerting for reconciliation pipelines cluster covers wiring these into dashboards and alert thresholds so a growing queue pages someone before it becomes an OOM.
FAQ Permalink to this section
Should I block the producer or shed requests when the queue is full? Permalink to this section
Block by default. A blocked producer only adds latency, and latency is recoverable; a shed record is data loss unless the upstream caller retries, and most supplier feed sources are not built to retry a shed batch cleanly. Reach for shed_when_full=True only on a path with a cheap, correct retry story upstream — for example, an HTTP endpoint that can return 503 and let the caller’s own retry logic re-send the payload — never for a one-shot poll of a supplier feed with no re-fetch mechanism.
Why do I need both a semaphore and a token bucket instead of just one? Permalink to this section
They bound different things. The semaphore caps how many requests are open on the system at any instant, protecting shared resources like the connection pool and database. The token bucket caps how fast requests go out to one specific supplier over time, protecting that supplier’s API quota. A semaphore alone lets you burst against a single slow supplier as fast as the pool allows; a token bucket alone lets you open unlimited concurrent connections as long as the aggregate rate looks fine. Overwhelming downstream usually requires both failure modes to be closed at once.
How do I choose queue_maxsize without just guessing? Permalink to this section
Work backward from an acceptable backlog window, not memory alone. Decide how many seconds of buffered work you are willing to tolerate before backpressure kicks in — a few seconds for a latency-sensitive feed, longer for an overnight batch — then multiply by your steady-state processing rate to get queue_maxsize. Cross-check the memory implication (queue_maxsize × average_record_bytes) against the worker’s memory budget; if the two disagree, the backlog-window number should win, since a queue that is memory-safe but represents ten minutes of invisible lag is still an operational problem.
Related Permalink to this section
- Async Batch Processing for High-Volume Feeds — the parent cluster on bounded concurrency and connection-pool sizing
- Implementing Asyncio for Concurrent Batch Ingestion — event-loop and task-scheduling mechanics this gate builds on
- Dead-Letter Queues and Retry Orchestration — where shed and failed records are recovered
- Monitoring and Alerting for Reconciliation Pipelines — wiring queue depth and rate metrics into dashboards and alerts
- ↑ Parent: Async Batch Processing for High-Volume Feeds