Scheduling Competitor Rate Shops with Async httpx
Shopping a comp set means firing hundreds of requests at a handful of hosts, and the tension is immediate: too slow and the benchmark is stale before the pricing engine reads it, too fast and the source flags the property’s IP and starts returning 429s or CAPTCHAs. The answer is concurrency that is bounded and paced by construction — many requests in flight overall, but never a detectable burst against any single host. This page builds that scheduler with async httpx, as the collection layer of the competitor rate shopping ingestion workflow. It captures the raw response for every shop so a published benchmark can always be traced back to the exact bytes it came from.
The goal is a collector that saturates your polite budget without exceeding it: a global concurrency ceiling, a separate per-host minimum interval with jitter, retries that respect Retry-After, and an audit capture that is part of the fetch rather than bolted on after.
Prerequisites & environment
The collector sits between the shop matrix and the normalizer. Pin these so pacing behaves identically across workers:
- Python 3.11+ — for
asyncio.TaskGroupand modern typing. httpx0.27+ — oneAsyncClientwith HTTP/2 and connection pooling reused across all shops, never a client per request.tenacity8.x — the retry policy is a decorator so the backoff parameters live in one place (v2-era async support viaAsyncRetrying).structlog24.x — every fetch, retry, and throttle logged as a key=value event keyed bysourceandcompetitor_property_id.- A per-host limiter — a small async token/interval gate keyed by hostname so each source has its own pace independent of the global concurrency ceiling.
- API/collection access — outbound reachability to each source, with credentials or partner keys where a source offers them; pace within the same OTA API rate-limit budget the rest of the pipeline shares.
The ShopRequest shape and the benchmark store this feeds are defined on the parent guide; this page owns only the concurrent, polite fetch.
Step-by-step implementation
Build the collector in four layers: a per-host interval gate, a tenacity retry policy, a fetch that captures raw bytes, and a bounded scheduler that runs the whole matrix.
Step 1 — Gate each host to a minimum interval with jitter
The global semaphore caps total load, but politeness is per host: two hosts can each take four in-flight requests while neither sees a burst. A per-host gate enforces a minimum spacing between requests to the same host and adds jitter so the arrivals never form a detectable rhythm.
import asyncio, random, time
from collections import defaultdict
class HostRateGate:
"""Enforces a minimum interval between requests to the same host, with jitter."""
def __init__(self, min_interval: float = 1.5, jitter: float = 0.5):
self._min = min_interval
self._jitter = jitter
self._next_allowed: dict[str, float] = defaultdict(float)
self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
async def wait(self, host: str) -> None:
async with self._locks[host]: # serialize the gate check per host
now = time.monotonic()
wait_for = self._next_allowed[host] - now
if wait_for > 0:
await asyncio.sleep(wait_for)
# schedule the next slot: base interval plus a random jitter
spacing = self._min + random.uniform(0, self._jitter)
self._next_allowed[host] = time.monotonic() + spacing
Holding a per-host lock while computing and reserving the next slot is what makes the gate correct under concurrency: without it, two coroutines could read the same _next_allowed value and both fire immediately, collapsing the interval and producing exactly the burst the gate exists to prevent.
Step 2 — Define the retry policy once, honouring Retry-After
Wrap the transport call in a tenacity retry so backoff lives in one place. Retry only the transient statuses, cap the attempts, and prefer the source’s own Retry-After when it sends one.
import httpx
from tenacity import (retry, stop_after_attempt, wait_exponential_jitter,
retry_if_exception_type)
RETRYABLE = {429, 500, 502, 503, 504}
class TransientShopError(Exception):
"""Raised for a retryable HTTP status so tenacity can back off."""
@retry(reraise=True,
stop=stop_after_attempt(4),
wait=wait_exponential_jitter(initial=1.0, max=30.0),
retry=retry_if_exception_type(TransientShopError))
async def _get_with_retry(client: httpx.AsyncClient, url: str) -> httpx.Response:
resp = await client.get(url, timeout=15.0)
if resp.status_code in RETRYABLE:
# a Retry-After header, when present, overrides the backoff floor
retry_after = resp.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
await asyncio.sleep(int(retry_after))
raise TransientShopError(f"status {resp.status_code}")
return resp
Raising a custom TransientShopError only for the retryable set — rather than blanket-retrying every non-200 — keeps a terminal 403 or a 404 from being retried, so the client backs off on genuine throttling but fails fast on a block or a dead URL instead of burning the retry budget against a wall.
Step 3 — Fetch one shop and capture the raw response
Each fetch passes the host gate, calls the retrying transport, and persists the raw bytes to the audit sink before returning the extracted fields. Capture is inside the fetch so a response can never reach the normalizer without also reaching the audit trail.
import structlog
from urllib.parse import urlparse
log = structlog.get_logger()
async def shop_one(req, client: httpx.AsyncClient, gate: HostRateGate,
audit_sink: dict) -> dict | None:
url = build_source_url(req) # source adapter maps a ShopRequest to a URL
host = urlparse(url).netloc
await gate.wait(host) # politeness: block until this host's slot opens
try:
resp = await _get_with_retry(client, url)
except (TransientShopError, httpx.HTTPError) as exc:
log.warning("shop_failed", competitor=req.competitor_property_id, error=str(exc))
return None
if resp.status_code == 403: # flagged: terminal for this host, do not retry
log.error("shop_forbidden", competitor=req.competitor_property_id, host=host)
return None
audit_sink[f"{host}:{req.competitor_property_id}:{req.check_in}"] = resp.text
log.info("shop_ok", competitor=req.competitor_property_id, host=host,
status=resp.status_code, bytes=len(resp.content))
return extract_rate_fields(resp) # adapter pulls amount/currency/board out
Writing resp.text to the audit sink before extraction — not after — guarantees that even if extract_rate_fields later raises on a changed page layout, the raw bytes are already captured, so the failing response is available to debug the adapter rather than lost.
Step 4 — Run the matrix under a bounded scheduler
Drive the whole shop matrix through a global semaphore so total concurrency is capped regardless of how many hosts are in play, using a TaskGroup so a crash in one shop does not silently swallow the rest.
async def run_shops(matrix: list, client: httpx.AsyncClient,
gate: HostRateGate, audit_sink: dict,
max_in_flight: int = 8) -> list[dict]:
sem = asyncio.Semaphore(max_in_flight)
results: list[dict] = []
async def _bounded(req):
async with sem: # global ceiling across all hosts
out = await shop_one(req, client, gate, audit_sink)
if out is not None:
results.append(out)
async with asyncio.TaskGroup() as tg: # structured concurrency: all-or-surface
for req in matrix:
tg.create_task(_bounded(req))
log.info("shop_run_complete", planned=len(matrix), collected=len(results))
return results
Separating the global semaphore from the per-host gate is the key design choice: the semaphore protects your worker and the shared rate-limit budget from too many open sockets, while the gate protects each source from a burst — one ceiling cannot serve both jobs, because eight concurrent requests spread across four hosts is polite while eight against one host is an attack.
Gotchas & production notes
- Reuse one
AsyncClientfor the whole run. Creating a client per request throws away connection pooling and HTTP/2 multiplexing, and — worse for politeness — opens a fresh TCP+TLS handshake to the source every time, which is itself a load pattern that flags scrapers. Instantiate the client once in anasync withand pass it down. - A
429should widen the pace for the rest of the run, not just retry the one request. HonouringRetry-Afteron a single call fixes that call while the next request repeats the offence. When a host returns429, increase that host’smin_intervalfor the remainder of the run so the whole batch backs off, coordinated with the shared rate-limit budget so a shop storm never starves production syncs. - Jitter is not optional cosmetic noise. A perfectly regular 1.5-second cadence is a machine signature; sources fingerprint it. The
random.uniformjitter on each host slot breaks the rhythm so the traffic looks less like a bot and more like sporadic human browsing, which is what keeps a comp-set shop sustainable over weeks. - Timezone-align
check_into the source’s locale before building the URL. A shop for a date built in the worker’s timezone can request the wrong night against a source in another zone, quietly benchmarking the wrong day; resolve the date in the property’s or source’s timezone in the adapter, not in the scheduler.
Verification snippet
Confirm the two politeness guarantees — that per-host spacing actually holds and that the global ceiling is never exceeded — with a fake clock and counter before pointing the collector at live sources.
import asyncio, time
async def test_host_gate_enforces_spacing():
gate = HostRateGate(min_interval=0.2, jitter=0.0)
start = time.monotonic()
# three sequential waits on one host must span at least 2 * min_interval
for _ in range(3):
await gate.wait("booking.com")
elapsed = time.monotonic() - start
assert elapsed >= 0.4 # first is free, next two spaced 0.2s each
async def test_global_ceiling_never_exceeded():
peak = 0
live = 0
sem = asyncio.Semaphore(3)
async def worker():
nonlocal peak, live
async with sem:
live += 1
peak = max(peak, live)
await asyncio.sleep(0.01)
live -= 1
async with asyncio.TaskGroup() as tg:
for _ in range(20):
tg.create_task(worker())
assert peak <= 3 # semaphore held the ceiling under 20 tasks
asyncio.run(test_host_gate_enforces_spacing())
asyncio.run(test_global_ceiling_never_exceeded())
The first test proves the per-host gate actually spaces requests rather than merely intending to; the second proves the semaphore holds the global ceiling even when far more tasks are queued than slots exist. Together they are the two properties that keep a comp-set shop polite. In production, also assert that the audit sink holds one raw entry per successful fetch, so a gap between planned shops and captured responses surfaces a silent collection failure.
Related
- Competitor rate shopping ingestion — the parent workflow: the shop matrix, canonical contract, and guards this collector feeds.
- Normalizing competitor rates: gross vs net — the reconciliation applied to the observations this scheduler collects.
- Handling OTA API rate limits — the shared budget the per-host gate coordinates with.
- Implementing exponential backoff in Python — the backoff-with-jitter pattern behind the tenacity retry.
- Dynamic pricing rule engines — the eventual consumer of the benchmarks this collection run produces.
← Back to Competitor Rate Shopping Ingestion