Async Polling for Inventory Updates: Deterministic Rate Parity in Hotel Distribution

Asynchronous polling is the synchronization mechanism of last resort — and, in practice, of daily reality — for hotel property management systems (PMS) and channel managers whenever push delivery is unreliable, silently dropped, or simply unsupported by a legacy OTA endpoint. When channel manager webhook delivery misses an availability event, the failure is invisible: no error is raised, the room quietly stays sellable, and the first signal a revenue manager gets is an overbooking or a parity violation flagged by Booking.com. A polling workflow removes that blind spot by continuously pulling the current inventory state, comparing it against a local baseline, and pushing corrective deltas into the pricing engine on a predictable cadence. This page sits inside the broader API Sync & Data Ingestion Workflows domain and specifies the exact architecture, code, data contracts, and failure handling a Python automation engineer needs to run polling in production for revenue and operations teams.

Unlike event-driven designs that assume perfect message delivery, polling enforces strict idempotency, explicit state tracking, and bounded latency. Revenue managers depend on it to hold rate parity across fragmented distribution channels; operations teams depend on it to prevent phantom availability; engineers depend on it because it degrades gracefully when an upstream system misbehaves.

Architecture & Prerequisites

Polling for inventory updates functions as a scheduled ingestion pipeline that bridges real-time pricing decisions and asynchronous channel responses. A centralized scheduler triggers concurrent polling jobs scoped per property_id, per channel, and per room_type_code. Each job holds a local state cache containing the last known availability, rate_plan_code, and restriction matrix. On each tick, the job issues an authenticated GET to the channel manager or OTA inventory endpoint, retrieves the current payload, validates it against the canonical schema, and runs a deterministic diff against the cached baseline. Only validated deltas proceed to the rate parity engine, which pushes corrective updates downstream. This isolates transient network failures from core pricing logic so revenue operations never act on stale or partially synced inventory.

Async inventory-polling data flow A scheduler fans out concurrent poll jobs keyed per property and room type to OTA or channel-manager inventory endpoints. Each authenticated GET response passes through Pydantic schema validation and a version_token diff against the state cache; validated deltas flow to the rate parity engine, while accumulated drift escalates to batch reconciliation. A 429 or Retry-After response feeds back to the scheduler to back off and shed concurrency. fan-out auth GET validated baseline / token validated delta drift > tolerance 429 / Retry-After — back off & shed concurrency payload Scheduler per-tick fan-out Poll jobs ×N property_id / room_type OTA / CM endpoint inventory GET Schema validation Pydantic v2 version_token diff monotonic sequence_no State cache last snapshot Rate parity engine idempotent push Batch reconciliation atomic resync
Concurrent poll jobs validate each OTA payload and diff it by version_token before a delta reaches the parity engine; throttling feeds back to the scheduler and accumulated drift escalates to batch reconciliation.

Environment assumptions and dependency versions:

The polling engine consumes the same canonical inventory shape produced by standardized JSON payloads, and the rate_plan_code values it compares must already conform to your rate plan taxonomy. Polling upstream of those two contracts is possible but fragile: unnormalized rate plans produce false deltas on every cycle.

Implementation

The build proceeds in four numbered steps. Each step is anchored to a self-contained, runnable block; wire them together in the order shown.

Step 1 — Define the polling engine and resilient fetch

The fetch method is the only part of the loop that touches the network, so it is the only part wrapped in a retry policy. Keeping retries at this granularity means a transient failure re-issues one request rather than replaying the whole cycle and re-emitting deltas.

python
import asyncio
from typing import Any, Optional

import httpx
import structlog
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
)

structlog.configure(processors=[structlog.processors.JSONRenderer()])
log = structlog.get_logger()


class PollingEngine:
    def __init__(
        self,
        base_url: str,
        auth_headers: dict[str, str],
        state_cache: dict[str, "InventorySnapshot"],
    ) -> None:
        self.base_url = base_url
        self.auth_headers = auth_headers
        self.state_cache = state_cache
        self.client = httpx.AsyncClient(timeout=15.0, http2=True)

    @retry(
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=2, max=10),
        retry=retry_if_exception_type((httpx.RequestError, httpx.HTTPStatusError)),
        reraise=True,
    )
    async def fetch_inventory(self, property_id: str, room_type_code: str) -> dict[str, Any]:
        endpoint = f"{self.base_url}/inventory/{property_id}/rooms/{room_type_code}"
        response = await self.client.get(endpoint, headers=self.auth_headers)
        response.raise_for_status()
        return response.json()

The retry_if_exception_type guard deliberately retries only network and HTTP status errors, so a schema or logic bug raises immediately instead of being silently masked by three attempts.

Step 2 — Compute a deterministic delta

The diff keys off a monotonic version_token rather than a naive field comparison. If the token is unchanged the payload is equivalent to the baseline and the cycle exits early, which is what keeps a stable channel from generating pointless parity pushes.

python
    def compute_delta(
        self, cached: "InventorySnapshot", live: dict[str, Any]
    ) -> Optional[dict[str, Any]]:
        try:
            current = InventorySnapshot(**live)
        except ValidationError as exc:
            log.error("schema_validation_failed", error=str(exc), payload=live)
            return None

        if current.version_token == cached.version_token:
            return None
        if current.sequence_no <= cached.sequence_no:
            log.warning(
                "stale_payload_discarded",
                property_id=current.property_id,
                room_type_code=current.room_type_code,
                cached_seq=cached.sequence_no,
                live_seq=current.sequence_no,
            )
            return None

        return {
            "property_id": current.property_id,
            "room_type_code": current.room_type_code,
            "rate_plan_code": current.rate_plan_code,
            "availability_change": current.available_units - cached.available_units,
            "rate_plan_changed": current.rate_plan_code != cached.rate_plan_code,
            "new_version_token": current.version_token,
        }

The sequence_no guard rejects out-of-order responses — a real hazard when concurrent OTA replicas answer the same poll window — so the cache only ever moves forward in time.

Step 3 — Apply the correction idempotently

The downstream push carries an idempotency key derived from the property, room, and version token. Replaying the same delta (for example after a scheduler restart) is therefore a no-op at the parity engine instead of a duplicate rate write.

python
    async def apply_parity_correction(self, delta: dict[str, Any]) -> None:
        idempotency_key = (
            f"{delta['property_id']}:{delta['room_type_code']}:{delta['new_version_token']}"
        )
        await self.client.post(
            f"{self.base_url}/parity/corrections",
            headers={**self.auth_headers, "Idempotency-Key": idempotency_key},
            json=delta,
        )
        log.info("parity_delta_applied", idempotency_key=idempotency_key, **delta)

Deriving the key from version_token rather than a timestamp guarantees that two workers processing the same upstream state produce the identical key and collapse into a single correction.

Deterministic evaluate-then-apply decision path for a polled payload Each fetched OTA payload runs three sequential gates before any correction is sent. A payload that fails schema validation is logged and routed to the batch queue. A payload whose version_token is unchanged short-circuits to a no-op skip. A payload whose sequence_no is not strictly greater than the cached baseline is discarded as stale. Only a strictly newer, valid snapshot emits a delta, builds an Idempotency-Key from property_id, room_type_code and version_token, and POSTs an idempotent correction that updates the state cache — a replay of the same key collapses to a 409 treated as success. yes yes yes no no no replay → 409 = success Fetched OTA payload per property / room Schema valid? version_token changed? sequence_no advanced? Emit validated delta build Idempotency-Key Idempotent POST update state cache Schema invalid log + route to batch queue Token unchanged skip cycle — no-op sequence_no ≤ cached discard stale payload
How each polled payload is evaluated: the schema and version_token gates short-circuit no-op and stale responses, so only a strictly newer snapshot emits an idempotent correction — and any replay collapses to a 409.

Step 4 — Run the concurrent scheduler

asyncio.gather with return_exceptions=True lets one property’s failure fall through to structured logging without aborting the other jobs in the same tick. The engine polls hundreds of property/room pairs concurrently on a single event loop, with no thread overhead.

python
    async def poll_cycle(self, property_id: str, room_type_code: str) -> None:
        cache_key = f"{property_id}:{room_type_code}"
        cached = self.state_cache.get(cache_key)
        if cached is None:
            log.warning("cache_miss_initializing", property_id=property_id, room_type_code=room_type_code)
            return
        try:
            payload = await self.fetch_inventory(property_id, room_type_code)
            delta = self.compute_delta(cached, payload)
            if delta:
                await self.apply_parity_correction(delta)
                self.state_cache[cache_key] = InventorySnapshot(**payload)
                log.info("inventory_synced", property_id=property_id,
                         room_type_code=room_type_code, version=payload["version_token"])
        except httpx.HTTPStatusError as exc:
            log.error("ota_http_error", status=exc.response.status_code, url=str(exc.request.url))
        except Exception:
            log.exception("polling_unexpected_failure", property_id=property_id, room_type_code=room_type_code)

    async def run_scheduler(self, job_configs: list[dict], interval: int = 60) -> None:
        while True:
            tasks = [self.poll_cycle(c["property_id"], c["room_type_code"]) for c in job_configs]
            await asyncio.gather(*tasks, return_exceptions=True)
            await asyncio.sleep(interval)

Because the whole cycle is bounded by the 15-second client timeout and a three-attempt retry budget, the worst-case tick duration is predictable — a prerequisite for choosing a safe interval.

Adaptive cadence. A fixed interval wastes quota during quiet periods and starves freshness during peaks. Production engines drive the interval from booking velocity and seasonality flags: contract to 60–90 seconds through high-demand windows to catch rapid depletion, and expand to 5–10 minutes on shoulder dates to conserve compute and OTA quota. The ceiling on aggressiveness is always the channel’s published request budget, covered under OTA API rate limits.

Schema & Data Contracts

Every payload is validated before it can mutate the cache. The canonical snapshot is a Pydantic v2 model; field_validator enforces the invariants that a raw OTA response routinely violates (negative availability, unmapped rate plans, lower-cased channel slugs).

python
from datetime import datetime
from pydantic import BaseModel, Field, ValidationError, field_validator

ALLOWED_CHANNELS = {"booking_com", "expedia", "agoda", "direct"}


class InventorySnapshot(BaseModel):
    property_id: str = Field(pattern=r"^prop_[0-9a-f]{8}$")
    room_type_code: str
    rate_plan_code: str
    channel: str
    available_units: int = Field(ge=0)
    closed_to_arrival: bool = False
    min_length_of_stay: int = Field(ge=1, default=1)
    last_updated: datetime
    sequence_no: int = Field(ge=0)
    version_token: str

    @field_validator("channel")
    @classmethod
    def channel_must_be_known(cls, value: str) -> str:
        slug = value.strip().lower()
        if slug not in ALLOWED_CHANNELS:
            raise ValueError(f"unknown channel slug: {value!r}")
        return slug

    @field_validator("rate_plan_code")
    @classmethod
    def rate_plan_uppercase(cls, value: str) -> str:
        return value.strip().upper()

Normalizing channel and rate_plan_code inside the model — rather than at the diff site — means compute_delta compares already-canonical values and never mis-fires a delta on a formatting difference alone. Serialize with snapshot.model_dump(mode="json") when persisting to Redis so datetime fields round-trip as ISO-8601 strings.

Error Handling & Retry Strategy

Polling faces a small, well-defined set of upstream failure classes, and each maps to a distinct action. Reuse the shared taxonomy in categorizing 4xx vs 5xx sync errors rather than inventing per-endpoint logic.

Status Meaning Action
401 Unauthorized Access token expired mid-cycle Trigger OAuth2 token refresh, then retry the same request; do not drop the cadence
409 Conflict Idempotency key already applied Treat as success — the correction landed on a prior attempt
422 Unprocessable Payload failed OTA validation Do not retry; log and route to the batch queue
429 Too Many Requests Rate budget exhausted Honor Retry-After; back off and shed concurrent jobs
5xx Upstream instability Retry with jittered exponential backoff

The retry ceiling for 429 and 5xx uses the same exponential backoff profile as the rest of the pipeline: wait_exponential(multiplier=1, min=2, max=10) capped at three attempts, with full jitter to avoid a synchronized retry stampede across properties. The idempotency key design in Step 3 is what makes retries safe: because the key is deterministic in version_token, a 429-then-retry that actually succeeded on the first (throttled) attempt collapses to a 409 on replay instead of writing the rate twice.

When drift exceeds the configured tolerance — for example, more than a handful of unit changes accumulate while the endpoint was throttled — the engine should stop issuing piecemeal corrections and escalate the whole property to batch reconciliation. Piecemeal catch-up under load is exactly what trips OTA parity-compliance flags.

Verification & Testing

Confirming a polling deployment is behaving means asserting on both the log stream and the cache state, not just a green process.

python
import asyncio

async def test_delta_is_idempotent() -> None:
    baseline = InventorySnapshot(
        property_id="prop_0a1b2c3d", room_type_code="DLX_KING",
        rate_plan_code="BAR", channel="booking_com", available_units=5,
        last_updated="2026-07-02T09:00:00Z", sequence_no=41, version_token="v41",
    )
    cache = {"prop_0a1b2c3d:DLX_KING": baseline}
    engine = PollingEngine("https://cm.example", {"Authorization": "Bearer t"}, cache)

    live = baseline.model_dump(mode="json") | {
        "available_units": 3, "sequence_no": 42, "version_token": "v42",
    }
    delta = engine.compute_delta(baseline, live)
    assert delta["availability_change"] == -2
    # Replaying the identical payload after cache update yields no delta.
    engine.state_cache["prop_0a1b2c3d:DLX_KING"] = InventorySnapshot(**live)
    assert engine.compute_delta(InventorySnapshot(**live), live) is None

asyncio.run(test_delta_is_idempotent())

This smoke test proves the two properties that matter most: a real change produces the correct signed availability_change, and re-observing the same state produces nothing. In production, additionally assert that the count of parity_delta_applied log events equals the count of distinct version_token values seen per property per hour — any excess means the diff is mis-firing. Track poll_duration_ms, delta_magnitude, retry_count, and cache_hit_ratio as structured fields to feed observability dashboards and alert before a parity breach reaches booking conversion.

Troubleshooting

Duplicate parity corrections on every cycle : Root cause: rate_plan_code or channel arrives unnormalized, so compute_delta sees a “change” that is only a formatting difference. Fix: ensure the field_validator normalization runs (see the schema section) and that upstream payloads already match your rate plan taxonomy.

Availability lags real bookings during peak windows : Root cause: fixed interval too coarse for booking velocity. Fix: switch to adaptive cadence keyed on booking velocity and seasonality; contract to 60–90 s in peak windows.

Cycles silently stop after several hours : Root cause: the access token expired and the 401 path was never wired, so fetch_inventory exhausted retries and the job died quietly. Fix: intercept 401, invoke OAuth2 token refresh, and resume without dropping the tick.

429 storms across many properties at once : Root cause: all jobs share one API key and retry in lockstep. Fix: add full jitter to backoff, shed concurrency on 429, and track a sliding-window request budget per key.

Only part of a large property’s inventory updates : Root cause: the endpoint paginates across date ranges or room categories and the loop reads only the first page. Fix: apply cursor-based traversal per parsing paginated OTA responses.

FAQ

When should I poll instead of relying on webhooks?

Use polling whenever the channel does not offer push delivery, offers it without delivery guarantees, or requires a fallback for missed events. In practice most production stacks run both: webhooks for low-latency updates and a slower polling sweep as a reconciliation safety net that catches anything channel manager webhook delivery dropped.

What is a safe polling interval without hitting rate limits?

There is no universal number — it is a function of the channel’s published request budget divided across every property and room you poll. Start conservative (5 minutes), measure your 429 rate, then tighten adaptively toward 60–90 seconds only for high-velocity dates. Always honor Retry-After and keep headroom for the parity-push calls that share the same budget.

How do I avoid pushing duplicate rate corrections?

Two mechanisms working together: a monotonic version_token so an unchanged snapshot produces no delta, and a deterministic Idempotency-Key derived from property_id, room_type_code, and version_token so any replay collapses to a 409 at the parity engine instead of a second write.

Where should the state cache live in production?

An in-process dict is fine for a single worker, but it evaporates on restart and cannot be shared. Back the cache with Redis (store model_dump(mode="json") output) so state survives restarts and multiple workers can scale horizontally while reading a consistent baseline.

When should polling hand off to batch reconciliation?

When accumulated drift exceeds your tolerance threshold — typically after a throttling window where many changes piled up. Emitting a burst of individual corrections risks tripping OTA parity-compliance flags, so escalate the whole property to batch reconciliation and let it resync atomically.

← Back to API Sync & Data Ingestion Workflows