Handling OTA API Rate Limits in Rate Parity Automation

OTA rate limits are the operational boundary that governs how fast a property management system can safely push prices, availability, and restrictions into a distribution channel. When automation ignores that boundary, the failure is not a clean rejection — it is a slow, compounding one: Booking.com starts returning 429 Too Many Requests, the sync worker retries in lockstep, the daily request budget burns out by mid-morning, and the last rate a channel actually accepted is now hours stale. A revenue manager sees a room selling below floor on Expedia; an operations lead fields an overbooking dispute; the Python engineer on call discovers the worker fired 4,000 identical calls into a throttled endpoint. Within the broader API Sync & Data Ingestion Workflows pipeline, a rate-limit governor is the pacing layer that turns a burst-prone batch job into a self-regulating stream that stays inside every channel’s budget while still landing high-impact parity changes first. This page defines that layer end to end: the token-bucket architecture, the dispatcher implementation, the payload contract, the 429/Retry-After retry policy, and the verification and troubleshooting practices that keep it honest in production.

Rate-limit governor data flow Validated rate and availability payloads enter a priority queue and drain through a per-OTA token-bucket governor whose tokens refill at each channel's published budget. Paced requests dispatch to Booking.com, Expedia, and Agoda. A 429 or Retry-After response feeds a backoff loop that re-queues the payload; an accepted response is recorded as committed with its confirmation ID; a payload that exhausts its retry budget falls to a dead-letter queue. 429 / Retry-After — defer & re-queue enqueue drain paced POST accepted exhausted Validated payloads rate / availability Priority queue high-impact first Token-bucket governor one bucket per OTA OTA channels Booking.com · Expedia · Agoda Committed confirmation ID stored Dead-letter queue retry_budget_exhausted
Payloads drain from a priority queue through a per-OTA token bucket that paces every dispatch; a 429 re-queues the mutation via the Retry-After loop, an accepted push commits with its confirmation ID, and a payload that exhausts its retries is dead-lettered for reconciliation.

Architecture & Prerequisites

The governor sits between the sync worker and the channel manager transport. Its inputs are validated outbound payloads — rate, availability, and restriction mutations — produced upstream; its outputs are one of three terminal states for every request: committed (the OTA accepted the mutation), deferred (throttled, re-queued for a paced retry), or dead-lettered (retry budget exhausted, parked for reconciliation). The core design principle is that generation is decoupled from dispatch: the worker can enqueue 5,000 rate changes in a burst, but the governor releases them only as fast as each channel’s token bucket allows.

Most OTAs enforce a sliding-window or token-bucket limit that caps requests per endpoint, per property, and per source IP — a typical channel allows roughly 60–120 requests per minute for rate updates, with tighter ceilings on availability and restriction overrides. Because the limit is per channel, the governor keeps a separate bucket per OTA slug (booking_com, expedia, agoda) rather than one global throttle.

The reference implementation assumes the following environment. Pin these versions — tenacity changed its wait-composition API across major releases, and Pydantic v2 syntax differs sharply from v1:

Two adjacent concerns are explicitly out of scope for this layer. Credential validity is owned by the OAuth2 token refresh strategies workflow — the governor reacts to a 401 by pausing and requesting a refresh, but it never holds the token cache. And status-by-status fault classification belongs to error categorization & retry logic; here we own only the throttle-specific 429/Retry-After path. Payloads arriving at the governor should already carry rate-plan identifiers resolved against the rate plan taxonomy and conform to the shape defined in data schema standardization.

Rate-limit governor component architecture A sync worker enqueues bursts of mutations into an asyncio priority queue. A drain loop pulls each payload into a token-bucket gate holding one bucket per channel slug — booking_com at 90 per minute, expedia at 60, agoda at 120 — and acquires a token before the httpx transport posts to the channel manager. A 401 pauses the drain and requests a fresh bearer token from the OAuth2 refresh provider; a payload that exhausts its retry budget is written to the dead-letter queue. 401 → refresh → bearer retries exhausted enqueue() drain acquire HTTPS Sync worker burst of mutations PriorityQueue priority + id per-OTA token buckets booking_com · 90/min expedia · 60/min agoda · 120/min httpx transport token → POST Channel mgr → 3 OTA channels Token refresh OAuth2 provider Dead-letter queue parked for reconcile
The governor sits between the sync worker and the transport: a drain loop pulls each payload from the PriorityQueue, acquires a token from that channel's bucket before httpx posts to the channel manager, pauses on a 401 to refresh the bearer, and dead-letters anything that exhausts its retry budget.

Implementation

The governor is built in four steps: a token bucket that models the channel’s published budget, an async dispatcher that acquires a token before every request, a 429-aware retry wrapper that honours Retry-After, and a drain loop that walks the priority queue and dead-letters what it cannot deliver.

Step 1 — Model the channel budget as an async token bucket

Each OTA gets its own bucket. Tokens refill continuously at the channel’s published rate; a request must acquire one before it may dispatch, so bursts are smoothed into a steady stream that never exceeds the ceiling.

python
import asyncio
import time

class TokenBucket:
    """Async token bucket — one per OTA channel (booking_com, expedia, ...)."""
    def __init__(self, rate_per_minute: int, burst: int | None = None):
        self.capacity = burst or rate_per_minute
        self.tokens = float(self.capacity)
        self.refill_per_sec = rate_per_minute / 60.0
        self.updated = time.monotonic()
        self._lock = asyncio.Lock()

    async def acquire(self) -> None:
        # Block until a token is available, refilling based on elapsed wall time.
        async with self._lock:
            while True:
                now = time.monotonic()
                self.tokens = min(
                    self.capacity,
                    self.tokens + (now - self.updated) * self.refill_per_sec,
                )
                self.updated = now
                if self.tokens >= 1:
                    self.tokens -= 1
                    return
                # Sleep exactly long enough for the next token to accrue.
                await asyncio.sleep((1 - self.tokens) / self.refill_per_sec)

Refilling from time.monotonic() deltas rather than a background timer task means the bucket is correct even if the event loop stalls — no ticks are ever “lost”, so the governor cannot silently over-issue tokens after a GC pause or a blocked coroutine.

Step 2 — Build the per-channel dispatcher

The dispatcher owns one bucket per channel and acquires a token immediately before the network call. It also stamps an idempotency key so a re-queued payload cannot double-apply a rate.

python
import hashlib
import httpx
import structlog

logger = structlog.get_logger("rate_parity.governor")

def build_idempotency_key(p: "RatePushPayload") -> str:
    # Content-derived so retries of the SAME mutation collapse OTA-side.
    material = (
        f"{p.property_id}|{p.ota}|{p.rate_plan_code}|{p.room_type_code}"
        f"|{p.stay_date.isoformat()}|{p.amount}|{p.currency}"
    )
    return hashlib.sha256(material.encode("utf-8")).hexdigest()

class RateLimitGovernor:
    # Published per-minute budgets per channel; tune from each OTA's contract.
    BUDGETS = {"booking_com": 90, "expedia": 60, "agoda": 120}

    def __init__(self, base_url: str, token_provider):
        self.base_url = base_url.rstrip("/")
        self.token_provider = token_provider  # supplies a fresh bearer token
        self.buckets = {ota: TokenBucket(rpm) for ota, rpm in self.BUDGETS.items()}

    async def _send(self, client: httpx.AsyncClient, p: "RatePushPayload") -> httpx.Response:
        await self.buckets[p.ota].acquire()  # pace BEFORE the call
        return await client.post(
            f"{self.base_url}/{p.ota}/rates/sync",
            json=p.model_dump(mode="json"),
            headers={
                "Authorization": f"Bearer {self.token_provider.current()}",
                "Content-Type": "application/json",
                "X-Idempotency-Key": build_idempotency_key(p),
                "X-Correlation-ID": p.correlation_id,
            },
        )

Acquiring the token inside _send — not when the payload is first enqueued — is deliberate: a payload can sit in the queue for minutes behind higher-priority work, and pacing must reflect dispatch time, not enqueue time, or the bucket accounting drifts.

Step 3 — Wrap dispatch in a 429/Retry-After-aware retry policy

tenacity expresses the throttle contract declaratively. A 429 (or a 5xx) raises a dedicated exception carrying the server’s Retry-After, which the wait function honours over its own computed backoff.

python
from tenacity import (
    retry, stop_after_attempt, wait_exponential_jitter,
    retry_if_exception, before_sleep_log,
)

RETRYABLE = frozenset({429, 500, 502, 503, 504})

class ThrottledError(Exception):
    """Raised only for RETRYABLE statuses; carries any Retry-After hint."""
    def __init__(self, status_code: int, retry_after: float | None = None):
        self.status_code = status_code
        self.retry_after = retry_after
        super().__init__(f"throttled: {status_code}")

def _wait_with_retry_after(retry_state):
    # Server's Retry-After wins; otherwise fall back to jittered exponential.
    exc = retry_state.outcome.exception()
    if isinstance(exc, ThrottledError) and exc.retry_after is not None:
        return exc.retry_after
    return wait_exponential_jitter(initial=1, max=60, jitter=2)(retry_state)

class RateLimitGovernor(RateLimitGovernor):  # extend Step 2
    async def dispatch_one(self, client: httpx.AsyncClient, p: "RatePushPayload") -> str:
        @retry(
            retry=retry_if_exception(lambda e: isinstance(e, ThrottledError)),
            wait=_wait_with_retry_after,
            stop=stop_after_attempt(5),
            before_sleep=before_sleep_log(logger, 30),  # WARNING
            reraise=True,
        )
        async def _attempt() -> str:
            resp = await self._send(client, p)
            if resp.status_code in RETRYABLE:
                ra = resp.headers.get("Retry-After")
                logger.warning("ota_throttled", ota=p.ota, status=resp.status_code,
                               retry_after=ra, rate_plan_code=p.rate_plan_code)
                raise ThrottledError(resp.status_code, float(ra) if ra else None)
            resp.raise_for_status()  # deterministic 4xx surface as HTTPStatusError
            return resp.headers.get("X-OTA-Confirmation-ID", "accepted")
        return await _attempt()

Using Retry-After when present rather than the computed backoff is what keeps a throttled channel from being throttled harder — the OTA has told you exactly when the window resets, and jittered exponential backoff is only the fallback for when it stays silent.

Step 4 — Drain the priority queue and dead-letter the rest

High-impact mutations (a last-minute availability drop, a promotional override) carry a lower priority integer so they jump ahead of bulk parity syncs when the budget is tight.

python
import time

class RateLimitGovernor(RateLimitGovernor):  # extend Step 3
    def __init__(self, base_url, token_provider):
        super().__init__(base_url, token_provider)
        self.queue: asyncio.PriorityQueue = asyncio.PriorityQueue()
        self.dead_letter: list[dict] = []

    async def enqueue(self, priority: int, p: "RatePushPayload") -> None:
        await self.queue.put((priority, p.correlation_id, p))  # tie-break on id
        logger.info("enqueued", priority=priority, ota=p.ota, rate_plan_code=p.rate_plan_code)

    async def drain(self) -> None:
        async with httpx.AsyncClient(timeout=15.0) as client:
            while not self.queue.empty():
                priority, _, p = await self.queue.get()
                log = logger.bind(ota=p.ota, rate_plan_code=p.rate_plan_code,
                                  correlation_id=p.correlation_id)
                try:
                    confirmation = await self.dispatch_one(client, p)
                    log.info("committed", confirmation_id=confirmation)
                except ThrottledError as exc:
                    self.dead_letter.append({"payload": p.model_dump(mode="json"),
                                             "reason": "retry_budget_exhausted",
                                             "status_code": exc.status_code, "ts": time.time()})
                    log.error("dead_lettered", reason="retry_budget_exhausted",
                              status_code=exc.status_code)
                except httpx.HTTPStatusError as exc:
                    self.dead_letter.append({"payload": p.model_dump(mode="json"),
                                             "reason": "client_rejection",
                                             "status_code": exc.response.status_code, "ts": time.time()})
                    log.error("dead_lettered", reason="client_rejection",
                              status_code=exc.response.status_code)
                finally:
                    self.queue.task_done()

Including p.correlation_id as the tuple’s second element is a small but load-bearing detail: PriorityQueue compares the next field when priorities tie, and RatePushPayload is not orderable — without a comparable tie-breaker, two equal-priority payloads raise a TypeError mid-drain.

Schema & Data Contracts

Every payload the governor paces is a validated RatePushPayload. Pydantic v2 rejects malformed mutations before they consume a token, converting a class of would-be 400/422 responses into local validation errors that never touch the budget.

python
from datetime import date
from decimal import Decimal
from uuid import uuid4
from pydantic import BaseModel, Field, field_validator

class RatePushPayload(BaseModel):
    property_id: str = Field(pattern=r"^prop_[0-9a-f]{8}$")
    room_type_code: str = Field(min_length=2, max_length=16)   # e.g. "DLXKING"
    rate_plan_code: str = Field(min_length=2, max_length=24)   # e.g. "BAR_FLEX"
    ota: str                                                    # channel slug
    stay_date: date
    amount: Decimal = Field(gt=0, max_digits=10, decimal_places=2)
    currency: str = Field(pattern=r"^[A-Z]{3}$")
    inventory: int = Field(ge=0, le=999)
    correlation_id: str = Field(default_factory=lambda: uuid4().hex)

    @field_validator("ota")
    @classmethod
    def known_channel(cls, v: str) -> str:
        allowed = {"booking_com", "expedia", "agoda", "direct"}
        if v not in allowed:
            raise ValueError(f"unknown OTA slug: {v}")
        return v

    @field_validator("stay_date")
    @classmethod
    def not_in_past(cls, v: date) -> date:
        if v < date.today():
            raise ValueError("cannot push a rate for a past stay date")
        return v

Modelling amount as Decimal with decimal_places=2 (never a binary float) is deliberate — floating-point drift on a rate is a parity violation waiting to happen, and validating the ota slug fails closed so a typo like bookingcom never reaches a bucket that does not exist and raises a KeyError mid-drain.

Error Handling & Retry Strategy

The governor’s contract reduces to one rule for throttling: pace to stay inside the budget, and when the OTA says wait, wait exactly as long as it asked. The table below is the operational contract for throttle-adjacent statuses; the full deterministic-vs-transient classification lives in error categorization & retry logic.

Status Meaning Governor action Retry?
200 / 201 Accepted Record confirmation ID, mark committed No
401 Token expired mid-drain Pause, request token refresh, re-queue once No (auth, not backoff)
429 Rate limit hit Honour Retry-After, defer and re-attempt Yes, paced by header
500/502/503/504 Upstream degradation Jittered exponential backoff Yes, bounded
400 / 409 / 422 Deterministic rejection Dead-letter — pacing cannot fix it No

Backoff parameters for 429 and 5xx: honour Retry-After first; otherwise start at 1s, double per attempt, add up to 2s of jitter, cap at 60s, over a maximum of 5 attempts. Jitter matters as much as the doubling — without it, a fleet of workers that all hit the limit at the same second retry in lockstep and re-trigger the throttle the instant the window opens (the thundering-herd problem). The deeper mechanics of composing the wait, and why jitter is non-negotiable, are covered in implementing exponential backoff in Python.

Idempotency is what makes deferral safe. Because the X-Idempotency-Key is derived from mutation content (Step 2), a 429-retried request that actually committed on the OTA side before the throttle response was returned is collapsed to a no-op — no double-applied rate, no phantom inventory block. When the token buckets themselves cannot keep up during a sustained incident — a channel dropping to a fraction of its published budget — the governor sheds load to the priority tail and defers to the broader async polling for inventory updates sweep to reconcile true channel state before resuming.

Verification & Testing

You cannot trust a rate governor you have not watched throttle. Verify three properties: (1) the bucket actually caps throughput, (2) a 429 with Retry-After defers for the stated interval rather than the computed backoff, and (3) idempotency holds across a deferred-then-committed retry.

python
import pytest, respx, httpx, time

@pytest.mark.asyncio
async def test_bucket_caps_throughput():
    bucket = TokenBucket(rate_per_minute=60, burst=1)  # 1 token/sec, no burst
    start = time.monotonic()
    await bucket.acquire(); await bucket.acquire(); await bucket.acquire()
    elapsed = time.monotonic() - start
    assert elapsed >= 2.0          # 3 tokens at 1/sec ⇒ ≥2s of pacing

@pytest.mark.asyncio
@respx.mock
async def test_429_honours_retry_after():
    route = respx.post("https://cm.example/booking_com/rates/sync").mock(side_effect=[
        httpx.Response(429, headers={"Retry-After": "1"}),
        httpx.Response(200, headers={"X-OTA-Confirmation-ID": "CONF-77"}),
    ])
    gov = RateLimitGovernor("https://cm.example", token_provider=FakeToken())
    async with httpx.AsyncClient() as c:
        assert await gov.dispatch_one(c, sample_payload()) == "CONF-77"
    assert route.call_count == 2

def test_idempotency_key_is_stable_across_retries():
    p = sample_payload()
    assert build_idempotency_key(p) == build_idempotency_key(p)  # deterministic

The throughput test is the one that matters most in CI — a regression that removes the acquire() call still passes every functional test while silently burning the entire daily budget in production. Beyond unit tests, assert on structured-log counts: the ratio of committed to dead_lettered per channel is your parity health signal, and a rising ota_throttled count against a flat request volume means an OTA has quietly tightened its published limit. Cross-check committed confirmation IDs against the nightly batch reconciliation run to catch any mutation the OTA accepted but the worker never recorded.

Troubleshooting

Daily API budget exhausted by mid-morning; dashboards show thousands of calls. Root cause: a deterministic 400/422 is being retried, or acquire() was removed and the queue drains unthrottled. Fix: confirm 4xx routes to raise_for_status()/dead-letter (not the retry set) and that test_bucket_caps_throughput passes.

429 responses keep firing even though the worker “backs off”. Root cause: the wait function ignores Retry-After and uses a short computed backoff that expires before the OTA window resets, so every retry re-trips the limit. Fix: honour Retry-After first (Step 3) and confirm jitter is applied so parallel workers do not retry in lockstep.

KeyError on an OTA slug mid-drain. Root cause: a payload carries a channel with no configured bucket (e.g. agoda enabled in the PMS but missing from BUDGETS). Fix: validate the slug in the payload model (known_channel) and add every live channel to BUDGETS with its contracted per-minute limit.

TypeError: '<' not supported while draining the queue. Root cause: two payloads share a priority and PriorityQueue tries to compare the un-orderable RatePushPayload objects. Fix: include a comparable tie-breaker (correlation_id) as the second tuple element, as in Step 4.

Bursts of 401 on long-running drains. Root cause: the bearer token expired between the first and last dispatch of a large queue. Fix: pull the token from a refreshing provider per request (as in Step 2) rather than capturing it once, per the OAuth2 token refresh strategies guidance.

FAQ

Should a 429 be treated as an error or as normal backpressure?

As backpressure. A 429 is the channel telling you its window is full, not that your payload is wrong — the condition clears on its own once the window resets. Treat it as retryable with a Retry-After-aware wait and pace future requests through the token bucket so you approach the limit rather than slamming into it. A steady trickle of 429s means your bucket rate is slightly above the channel’s real budget; tune it down.

Why one token bucket per OTA instead of a single global throttle?

Because limits are enforced per channel, per property, and often per endpoint — Booking.com’s 90/min budget is independent of Expedia’s 60/min. A single global throttle would either starve the faster channel (paced to the slowest) or overrun the slower one. Per-channel buckets let each stream run at its own contracted ceiling, and adding a channel is just one entry in BUDGETS.

How do I set the per-minute rate if the OTA does not publish an exact number?

Start conservative — 80% of whatever ceiling their documentation or support hints at — and watch the ota_throttled log counter. If it stays at zero across peak load, raise the bucket rate in small increments; the first sustained 429 marks your real limit, so back off just below it. Encoding the number in BUDGETS (not scattered constants) makes this a one-line tuning change.

Does pacing at dispatch time make the queue fall behind during peak load?

It intentionally caps throughput, so yes, a burst of 5,000 changes drains over minutes rather than seconds. That is why priority matters: last-minute availability drops and promotional overrides carry a low priority integer and jump the bulk parity backlog. If the steady-state backlog never clears, the channel’s budget is genuinely too small for your change volume — batch non-urgent updates or negotiate a higher limit.

What happens to payloads that exhaust the retry budget?

They land in the dead-letter queue tagged reason="retry_budget_exhausted" with their status code, so nothing is silently lost. A single dead-lettered payload during a channel blip is normal; a rising trend is an incident and should page. Because the idempotency key is content-derived, replaying a dead-lettered payload after the channel recovers cannot double-apply a rate that actually committed before the throttle response arrived.

← Back to API Sync & Data Ingestion Workflows