Implementing Stop-Sell Automation in Python
When a room type’s sellable count crosses its safety floor, something has to push availability=0 to every channel within seconds and, just as importantly, reopen it the moment a cancellation makes rooms sellable again — without a human watching a dashboard at midnight. This page builds that threshold-driven stop-sell and open-sell worker in Python, as the automation companion to the overbooking prevention and inventory buffers guide that defines the sellable count and the floor this worker reacts to. The two hard parts are not the push itself — they are making the push idempotent so retries never double-apply, and adding hysteresis so a date does not flap open and closed on every booking and cancellation.
A naive implementation that pushes zero whenever it sees a breach and pushes the count whenever it does not will thrash: one cancellation lifts the date one room above the floor, the worker reopens it, the next booking drops it back, the worker closes it again — dozens of state changes an hour, each one a channel write, each one rate-limit pressure and audit noise. The worker below treats stop and open as a state machine with separate thresholds, emits one structured audit line per real transition, and stamps every push with a deterministic key so a network retry is a no-op.
Prerequisites and environment
This worker sits between the availability recalculation and the channel manager API. Pin these so the state machine and idempotency behave identically across every worker instance:
- Python 3.11+ — for
enum.StrEnum,matchstatements, and modern async. httpx0.27 — async client for fanning the availability write out to multiple channels concurrently.tenacity8.x — declarative retry with exponential backoff on the transient push failures, so the retry policy is a decorator rather than hand-rolled loops.pydantic2.6+ — the stop-sell command is validated before it is sent, so a stop-sell carrying a non-zero availability can never leave the process.structlog24.x — every transition emits one audit event withproperty_id,room_type_code,stay_date, the old and new state, and the idempotency key, so the availability history is fully reconstructable.- A durable state store — Redis or a small table holding the last-known sell state and last pushed count per
(property_id, room_type_code, stay_date), so the worker knows whether a given evaluation is a real transition or a no-op.
The state store is what makes the whole thing idempotent and flap-resistant; without a memory of the last state, every evaluation looks like a fresh decision and the worker cannot tell a transition from a repeat.
Step-by-step implementation
The worker is four parts: a validated stop-sell command, a two-threshold transition decision that reads the last state, an idempotent push wrapped in retry, and a durable state write that records the transition for audit.
Step 1 — Model the command and validate the stop-sell invariant
Define the availability command as a Pydantic v2 model that refuses to represent an impossible state — specifically, a stop-sell that still carries a positive availability, which is the bug that silently reopens a sold-out date.
from pydantic import BaseModel, Field, model_validator
from enum import StrEnum
class SellState(StrEnum):
OPEN = "open"
STOPPED = "stopped"
class AvailabilityCommand(BaseModel):
property_id: str = Field(pattern=r"^[A-Z]{3}-[A-Z]{3}-\d{2}$") # "LON-STJ-01"
room_type_code: str = Field(pattern=r"^[A-Z0-9_]{3,20}$")
stay_date: str
state: SellState
availability: int = Field(ge=0)
@model_validator(mode="after")
def stop_means_zero(self) -> "AvailabilityCommand":
if self.state is SellState.STOPPED and self.availability != 0:
raise ValueError("a STOPPED command must carry availability 0")
return self
Encoding “STOPPED implies zero” in the model means the invalid command cannot even be constructed, so no retry, race, or copy-paste in a future worker can assemble a stop-sell that leaves a room bookable.
Step 2 — Decide the transition with two thresholds
Read the current state from the store and apply hysteresis: close only when sellable is at or below the stop floor, reopen only when sellable clears a higher reopen threshold. Anything in between holds the current state and pushes nothing.
from dataclasses import dataclass
@dataclass(frozen=True)
class Thresholds:
stop_floor: int # close at or below this (e.g. 2)
reopen_at: int # reopen only at or above this (e.g. 5)
def decide(current: SellState, sellable: int, t: Thresholds) -> SellState:
match current:
case SellState.OPEN:
# close only on a genuine breach
return SellState.STOPPED if sellable <= t.stop_floor else SellState.OPEN
case SellState.STOPPED:
# reopen only after clearing the higher bar, not just crossing back
return SellState.OPEN if sellable >= t.reopen_at else SellState.STOPPED
Branching on the current state rather than on the count alone is what creates the dead band between stop_floor and reopen_at: a date that closed at two rooms will not reopen until it reaches five, so the single-cancellation-single-booking churn that would otherwise flap the date is absorbed.
Step 3 — Push idempotently with declarative retry
Only push when the decided state differs from the stored one, and stamp each push with a deterministic idempotency key so a tenacity retry after a transient 429 or 503 cannot double-apply. A no-op transition sends nothing at all.
import hashlib, httpx
from tenacity import retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception_type
def idem_key(cmd: AvailabilityCommand) -> str:
raw = f"{cmd.property_id}|{cmd.room_type_code}|{cmd.stay_date}|{cmd.state}|{cmd.availability}"
return hashlib.sha256(raw.encode()).hexdigest()
@retry(stop=stop_after_attempt(5),
wait=wait_exponential_jitter(initial=0.5, max=8.0),
retry=retry_if_exception_type(httpx.HTTPStatusError))
async def push(client: httpx.AsyncClient, url: str, cmd: AvailabilityCommand) -> None:
resp = await client.post(url, json=cmd.model_dump(),
headers={"Idempotency-Key": idem_key(cmd)})
resp.raise_for_status() # 4xx/5xx → HTTPStatusError → retried per policy
Because the key is derived from the state and availability, a retried stop-sell always produces the same key and the provider deduplicates it, while a later genuine reopen produces a different key and does propagate — so retries are safe but real transitions are never suppressed.
Step 4 — Record the transition and emit one audit line
After a successful push, persist the new state and pushed count, and log exactly one structured event per real transition. A no-op logs at debug and writes nothing, keeping the audit trail to genuine state changes.
import structlog
log = structlog.get_logger()
async def apply(client, store, endpoints: dict[str, str],
property_id: str, room_type_code: str, stay_date: str,
sellable: int, t: Thresholds) -> SellState:
key = (property_id, room_type_code, stay_date)
current = store.get_state(key) # defaults to OPEN for an unseen key
target = decide(current, sellable, t)
if target is current:
log.debug("stop_sell_noop", property_id=property_id,
room_type_code=room_type_code, stay_date=stay_date, state=current)
return current # no push, no state write, no flap
availability = 0 if target is SellState.STOPPED else sellable
cmd = AvailabilityCommand(property_id=property_id, room_type_code=room_type_code,
stay_date=stay_date, state=target, availability=availability)
for channel, url in endpoints.items():
await push(client, url, cmd)
store.set_state(key, target, availability)
log.info("stop_sell_transition", property_id=property_id,
room_type_code=room_type_code, stay_date=stay_date,
from_state=current, to_state=target, availability=availability,
idem_key=idem_key(cmd))
return target
Guarding the push behind target is current is what turns a noisy per-evaluation writer into an edge-triggered one: the audit stream then contains only real open-to-stopped and stopped-to-open transitions, which is exactly what a revenue manager needs to reconstruct why a date was closed at a given minute.
Gotchas and production notes
- A reopen threshold equal to the stop floor is not hysteresis. If
reopen_atequalsstop_floor + 1, a single cancellation still reopens the date and the flap returns. Set the reopen threshold a few rooms above the floor — wide enough that ordinary booking churn stays inside the dead band — and tune it from how often the date actually crosses, not a guess. - Persist state before you trust it, but push before you persist. Write the new state to the store only after the push succeeds. If you persist first and the push then fails, the store says STOPPED while the channel still shows availability — the worst possible disagreement, because reconciliation now believes the date is safe when it is still bookable.
- A failed stop-sell must escalate, not silently retry forever. Reopening late costs an empty room; closing late costs a walk. If the
tenacitypolicy exhausts its attempts on a stop-sell, raise past the worker and alert, and treat the unreachable channel with the fallback routing for downtime posture rather than leaving a sold-out date live. Classify the underlying failures with the same error categorization and retry logic the rest of the pipeline uses. - Rate-limit the fan-out, not just the retries. A property-wide event (a large group cancellation) can trigger stop or open transitions across hundreds of dates at once. Push them through the shared OTA rate-limit budget so a burst of legitimate transitions does not trip a
429storm that then delays the one stop-sell that actually mattered.
Verification snippet
Confirm the two behaviours that make the worker safe: that the hysteresis band suppresses a flap, and that a repeat evaluation in the same state is a genuine no-op.
def test_hysteresis_suppresses_flap():
t = Thresholds(stop_floor=2, reopen_at=5)
# closed at 2; a cancellation lifting sellable to 3 must NOT reopen
assert decide(SellState.STOPPED, 3, t) is SellState.STOPPED
# only clearing the reopen bar reopens
assert decide(SellState.STOPPED, 5, t) is SellState.OPEN
def test_open_closes_only_on_breach():
t = Thresholds(stop_floor=2, reopen_at=5)
assert decide(SellState.OPEN, 3, t) is SellState.OPEN # above floor, stay open
assert decide(SellState.OPEN, 2, t) is SellState.STOPPED # at floor, close
def test_stop_command_forces_zero():
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError, match="STOPPED command must carry availability 0"):
AvailabilityCommand(property_id="LON-STJ-01", room_type_code="DLX_KING",
stay_date="2026-08-14", state=SellState.STOPPED, availability=3)
test_hysteresis_suppresses_flap()
test_open_closes_only_on_breach()
The flap test is the load-bearing one: it proves a date closed at the floor stays closed through the noisy middle range and reopens only when it genuinely recovers, which is the difference between a handful of audited transitions a day and hundreds of thrashing channel writes. In production, also assert the store records exactly one state change per logged stop_sell_transition, so the audit trail and the persisted state can never silently disagree.
Related
- Overbooking prevention and inventory buffers — the parent guide that defines the sellable count and stop floor this worker reacts to.
- Calculating overbooking buffers from no-show rates — where the buffer that sets the stop floor comes from.
- Fallback routing for downtime — the posture for a channel you cannot reach to deliver a stop-sell.
- Handling OTA API rate limits — the shared budget a burst of transitions must respect.
- Error categorization and retry logic — the retryable-versus-terminal split behind the push retry policy.