Debugging Stale Inventory Responses in Real-Time CMMS Parts Availability REST API Calls

Preventive maintenance routing fails silently when a real-time inventory query returns cached stock levels instead of live counts. The endpoint answers HTTP 200, the payload looks valid, and the dispatch worker confidently routes a technician to a bin that is physically empty. This page debugs one precise failure mode behind that outcome: an availability response served from an intermediate cache because the Python client never told the proxy to bypass it, so a parts availability check commits a routing decision against stale on_hand_qty data. The fix lives entirely in how the client issues the request and validates the response, not in the CMMS database.

Incident Profile

A scheduled PM routing job queries the CMMS inventory endpoint to confirm a part is in stock before assigning a work order. The request succeeds, the worker reads on_hand_qty: 12, and the routing engine dispatches a technician — who arrives to find an empty shelf. The work order misses its SLA, an emergency procurement request fires, and the asset stays down past its mean-time-to-repair estimate.

The tell is in the response headers, not the status line. The following request/response sequence was captured during a scheduled routing execution; the endpoint returned 200 but delivered data from a reverse proxy cache rather than the live transactional ledger.

2024-05-14 08:12:03,441 INFO  [cmms_sync_worker] POST /api/v2/inventory/availability
2024-05-14 08:12:03,441 DEBUG [cmms_sync_worker] Headers: {'Authorization': 'Bearer eyJ...', 'Content-Type': 'application/json', 'Accept': 'application/json'}
2024-05-14 08:12:03,892 DEBUG [urllib3.connectionpool] https://cmms-api.internal:443 "POST /api/v2/inventory/availability HTTP/1.1" 200 142
2024-05-14 08:12:03,893 DEBUG [cmms_sync_worker] Response Headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT', 'Cache-Control': 'public, max-age=300', 'Age': '247', 'X-Request-ID': 'a1b2c3d4'}
2024-05-14 08:12:03,894 WARNING [cmms_sync_worker] Payload: {"part_id": "BRG-4402", "location_id": "WH-04", "on_hand_qty": 12, "reserved_qty": 0}
2024-05-14 08:12:03,895 ERROR [routing_engine] Part BRG-4402 routed to WH-04 (qty: 12). Physical audit shows 0 units. Work order WO-88421 blocked.

The symptom signature is a successful status paired with cache provenance headers:

  • X-Cache: HIT confirms an edge cache or API gateway answered the request instead of the origin.
  • Cache-Control: public, max-age=300 marks the response reusable for five minutes.
  • Age: 247 means the cached copy is already 247 seconds old — the count reflects stock as it was over four minutes ago.
Stale cached read versus forced cache bypass A sequence diagram with three lifelines: the routing worker, the API gateway and edge cache, and the origin live inventory ledger. In the stale path, the worker sends POST /inventory/availability with no cache directive; the gateway answers from its own cache and never queries the origin, returning HTTP 200 with X-Cache HIT, Age 247 seconds, and on_hand 12; the worker dispatches a technician to an empty shelf and the work order is blocked. In the fixed path, the worker sends the same POST plus a unique cache_bust query parameter and Cache-Control no-store; the gateway forwards the request to the origin, the origin reads the live ledger as on_hand 0, and the gateway returns HTTP 200 with X-Cache MISS, Age 0, and on_hand 0; the worker fails closed and defers the work order. Why a 200 lies: cached read versus forced bypass Routing worker Python client API gateway edge cache Origin ledger live inventory STALE PATH — the bug POST /inventory/availability no cache directive origin never queried 200 · X-Cache: HIT · Age: 247 · on_hand=12 dispatch → shelf empty WO-88421 blocked, SLA missed FIXED PATH — forced bypass POST …?cache_bust=<ns> Cache-Control: no-store forward — revalidate live read: on_hand=0 200 · X-Cache: MISS · Age: 0 · on_hand=0 fail closed → defer WO routes on origin-fresh data only

Root Cause Analysis

Nothing in the CMMS database is wrong; the ledger holds the correct zero. Three configuration gaps converge so that the routing pipeline never reads it.

  1. The Python client adds no cache-busting directive. A default requests (or httpx) session sends no Cache-Control on the request. With no directive forbidding it, an intermediary is free to serve its own cached copy, and the high-velocity routing loop hammers the same cache key for the duration of max-age.
  2. The endpoint is cacheable but the routing path treats it as live. The availability resource is marked public, max-age=300 — appropriate for a dashboard, fatal for routing. The CMMS API contract specifies an explicit bypass for automated routing callers, and the integration script omits it.
  3. No freshness validation before committing the decision. The pipeline assumes synchronous consistency between asset lookup and inventory synchronization and dispatch, but never inspects X-Cache or Age before acting. A 200 is taken as ground truth.

This is the read-side analogue of the control-character corruption documented in syncing barcode scanners with CMMS asset registries: the transport layer quietly mutates what the routing engine believes is authoritative data.

Resolution

The fix is a calibrated change to how the client constructs the request and how it treats the response. Force the bypass at two layers (headers and query string, because many gateways key only on the URL), then refuse to trust a response that still smells cached.

Before — default session, no bypass, blind trust:

import requests

session = requests.Session()
session.headers.update({"Authorization": f"Bearer {token}"})


def get_availability(part_id: str, location_id: str) -> dict:
    # No cache directive -> the gateway is free to serve a stale copy.
    resp = session.post(
        "https://cmms-api.internal/api/v2/inventory/availability",
        json={"part_id": part_id, "location_id": location_id},
        timeout=5.0,
    )
    resp.raise_for_status()
    # HTTP 200 trusted unconditionally -> phantom inventory enters routing.
    return resp.json()

After — forced bypass plus freshness validation:

import time
import requests


def make_session(token: str) -> requests.Session:
    session = requests.Session()
    session.headers.update({
        "Authorization": f"Bearer {token}",
        "Accept": "application/json",
        # Tell every intermediary to revalidate against the origin.
        "Cache-Control": "no-cache, no-store, must-revalidate",
        "Pragma": "no-cache",  # HTTP/1.0 proxies still honour this.
    })
    return session


def get_availability(session: requests.Session, part_id: str, location_id: str) -> dict:
    resp = session.post(
        "https://cmms-api.internal/api/v2/inventory/availability",
        json={"part_id": part_id, "location_id": location_id},
        # Unique key per call: gateways that ignore headers cannot match a cache entry.
        params={"cache_bust": str(time.time_ns())},
        timeout=5.0,
    )
    resp.raise_for_status()

    # Refuse to trust a response that is still served from cache.
    served_from_cache = resp.headers.get("X-Cache", "").upper() == "HIT"
    age = int(resp.headers.get("Age", "0"))
    if served_from_cache or age > 15:
        raise StaleInventoryError(part_id, location_id, age)

    return resp.json()

Three changes carry the fix. The request-side Cache-Control: no-store instructs intermediaries to revalidate; the cache_bust query parameter defeats gateways that key only on the URL and ignore request headers; and the post-response guard rejects any answer that still carries X-Cache: HIT or an Age past the freshness budget, converting a silent stale read into a loud, catchable error.

Minimal Reproducible Pipeline

This end-to-end script runs as-is. It defines the canonical WorkOrderPayload (with the site-wide SLA fields priority, requested_completion, and escalation_tier), a hardened inventory client, and a routing decision that fails closed rather than dispatching against stale stock. Point base_url at a mock or your CMMS gateway and run it.

import time
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, Optional

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("cmms_inventory_client")


class Priority(str, Enum):
    CRITICAL = "critical"
    HIGH = "high"
    STANDARD = "standard"


@dataclass
class WorkOrderPayload:
    """Canonical CMMS work order — SLA fields are mandatory site-wide."""
    work_order_id: str
    asset_id: str
    part_skus: List[str]
    required_quantities: Dict[str, int]
    location_id: str
    priority: Priority = Priority.STANDARD
    requested_completion: Optional[datetime] = None
    escalation_tier: int = 0
    status: str = "open"
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


class StaleInventoryError(RuntimeError):
    """Raised when the availability response is served from cache."""

    def __init__(self, part_id: str, location_id: str, age: int):
        super().__init__(f"Stale availability for {part_id}@{location_id} (Age={age}s)")
        self.part_id = part_id
        self.location_id = location_id
        self.age = age


class CMMSInventoryClient:
    def __init__(self, base_url: str, token: str, timeout: float = 5.0, max_age_s: int = 15):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.max_age_s = max_age_s
        self.session = requests.Session()
        self.session.headers.update({
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Cache-Control": "no-cache, no-store, must-revalidate",
            "Pragma": "no-cache",
        })
        # Retry only transient gateway errors; never retry a stale 200.
        retry = Retry(total=2, backoff_factor=0.3, status_forcelist=[429, 502, 503, 504])
        self.session.mount("https://", HTTPAdapter(max_retries=retry))

    def get_live_availability(self, part_id: str, location_id: str) -> dict:
        resp = self.session.post(
            f"{self.base_url}/api/v2/inventory/availability",
            json={"part_id": part_id, "location_id": location_id},
            params={"cache_bust": str(time.time_ns())},
            timeout=self.timeout,
        )
        resp.raise_for_status()

        served_from_cache = resp.headers.get("X-Cache", "").upper() == "HIT"
        age = int(resp.headers.get("Age", "0"))
        if served_from_cache or age > self.max_age_s:
            raise StaleInventoryError(part_id, location_id, age)

        payload = resp.json()
        logger.info("Live %s@%s on_hand=%s", part_id, location_id, payload.get("on_hand_qty"))
        return payload


def can_dispatch(client: CMMSInventoryClient, wo: WorkOrderPayload) -> bool:
    """Confirm every required SKU is live and in stock; fail closed on a stale read."""
    for sku in wo.part_skus:
        required = wo.required_quantities.get(sku, 1)
        try:
            stock = client.get_live_availability(sku, wo.location_id)
        except StaleInventoryError as exc:
            # A stale read against a critical job defers rather than risks a phantom dispatch.
            logger.warning("Deferring %s: %s", wo.work_order_id, exc)
            return False
        net = stock.get("on_hand_qty", 0) - stock.get("reserved_qty", 0)
        if net < required:
            logger.warning("Short on %s: need %d, net %d", sku, required, net)
            return False
    return True


if __name__ == "__main__":
    client = CMMSInventoryClient("https://cmms-api.internal", "your_token_here")
    work_order = WorkOrderPayload(
        work_order_id="WO-88421",
        asset_id="PUMP-12",
        part_skus=["BRG-4402"],
        required_quantities={"BRG-4402": 1},
        location_id="WH-04",
        priority=Priority.CRITICAL,
        escalation_tier=1,
    )
    if can_dispatch(client, work_order):
        logger.info("Routing %s to a technician.", work_order.work_order_id)
    else:
        logger.info("Holding %s — availability unconfirmed.", work_order.work_order_id)

To prove the fix locally, point the client at a mock that emits X-Cache: HIT and confirm can_dispatch returns False (the job holds); flip the mock to X-Cache: MISS with live stock and confirm it returns True. The contract you are enforcing — that routing only ever acts on origin-fresh data — is the same one the parent parts availability checks gate depends on, and it feeds cleanly into automated reorder triggers when a deferral signals a genuine shortage.

Prevention Checklist

FAQ

Why not just lower the endpoint’s max-age instead of busting the cache?

Lowering max-age shrinks the staleness window but never closes it, and it weakens caching for every other consumer of the same endpoint (dashboards, reports) that genuinely benefit from it. Forcing the bypass only on the routing path keeps the cache useful elsewhere while guaranteeing routing reads origin-fresh data.

My gateway strips custom request headers — does the header fix even matter?

Some gateways drop Cache-Control before it reaches the origin, which is exactly why the cache_bust query parameter exists. A unique URL per call defeats URL-keyed caches regardless of header handling; keep the headers as a second line of defence for intermediaries that do honour them.

Won’t forcing no-store overload the origin database under heavy PM batches?

It can, which is why bulk availability sweeps should batch their reads through async batch processing and reserve synchronous, cache-busted calls for individual critical dispatches. Watch the origin’s HTTP 429 rate and back off before the bypass becomes a load problem.

How do I tell a stale read apart from a genuine zero-stock result?

A genuine zero arrives with X-Cache: MISS and a small Age; a stale read arrives with X-Cache: HIT or an Age past your budget. The client treats them differently — a fresh zero is a real shortage routed to procurement, while a stale read defers the job until the next live read confirms the count.

This page resolves a transport-layer fault inside parts availability checks; for the broader read-and-reconcile flow see asset lookup and inventory synchronization, align the routing thresholds with inventory threshold optimization, forward confirmed shortages to automated reorder triggers, keep the work order shape consistent with work order schema standards, and compare this with the input-side failure in syncing barcode scanners with CMMS asset registries.

Part of: Asset Lookup & Inventory Synchronization.