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:

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.

Two-threshold state machine for stop-sell and open-sell with a hysteresis gap Two states sit side by side: OPEN on the left and STOPPED on the right. A transition arrow from OPEN to STOPPED is labelled: sellable at or below the stop floor, for example two rooms. A return arrow from STOPPED to OPEN is labelled: sellable at or above the higher reopen threshold, for example five rooms. Between the two thresholds is a shaded hysteresis band where no transition happens, preventing flapping. A self-loop on each state is labelled no-op, meaning a repeated evaluation in the same state pushes nothing. Turquoise marks the OPEN state, pink marks the STOPPED state, and violet marks the hysteresis band. The stop-sell state machine with a hysteresis gap Close at the stop floor, reopen only above a higher threshold; the gap between them absorbs churn. OPEN publish sellable count STOPPED availability forced to 0 hysteresis band no transition here stop_floor < sellable < reopen sellable ≤ stop_floor (2) sellable ≥ reopen (5) no-op no-op
Figure 1: Stop and open are distinct states separated by a hysteresis band; only a threshold crossing triggers a push, and a repeat evaluation in the same state is a no-op.

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.

python
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.

python
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.

python
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.

python
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

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.

python
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.

← Back to Overbooking Prevention & Inventory Buffers