Categorizing 4xx vs 5xx Sync Errors in Rate Parity Automation

When a rate push to Booking.com or Expedia fails, the single most consequential decision your worker makes is whether that failure can succeed on retry. This page is the status-classification component of the broader error categorization and retry logic layer: it specifies exactly how to separate deterministic client-side 4xx rejections — which must never be retried — from transient server-side 5xx degradation, which must be retried with bounded, jittered backoff, and how to route each class to the correct queue, credential refresh, or circuit breaker.

The failure this prevents is quiet and expensive. When a worker treats every HTTP error identically — either retrying everything or halting on everything — it either burns the daily API quota looping on a 400 that can never succeed, or it abandons a rate update during a two-minute OTA gateway blip and leaves a room selling at a stale price. Correct classification is what keeps parity automation deterministic under load and keeps compliance logs honest instead of masking transient network noise as structural data failure.

Prerequisites & environment

This classifier is deliberately built on the synchronous requests stack so the routing logic reads top-to-bottom without async indirection; the same status map drops cleanly into an httpx/tenacity client when you need concurrency. Pin these versions:

Two upstream concerns are out of scope for the classifier and must be handled before a response ever reaches it. Credential validity is owned by the OAuth2 token refresh layer, and global request pacing belongs to handling OTA API rate limits — the classifier reacts to a 401 or 429 but does not own the token cache or the rate governor. Every payload arriving here should already conform to the canonical shape from standardized JSON payloads and carry codes resolved against your rate plan taxonomy, so the classifier never has to guess whether a 400 came from malformed input or an unmapped room type.

How classify() routes each HTTP status A decision diagram. An HTTP response from the OTA enters classify(status_code), which returns exactly one Route. Routes split into two lanes. The terminal lane returns after a single attempt: 2xx commits the mutation; 400 or 422 go to the validation queue with no retry; 409 goes to the reconciliation queue and never overwrites the OTA state; and 501, 505 or any unclassified status go to the dead-letter route to fail fast. The retry lane continues the loop with bounded backoff: 401 or 403 refresh the token and re-queue once; 429 obeys the Retry-After header and defers; and 5xx statuses 500, 502, 503 and 504 take jittered exponential backoff capped at 30 seconds, and on the final exhausted attempt open the circuit breaker and dead-letter. HTTP response from OTA status_code + Retry-After classify(status_code) exactly one Route per response Terminal — return after one attempt Retry — continue the loop, bounded 2xx → commit mutation accepted · return True 400 / 422 → validation_queue payload is wrong · no retry 409 → reconciliation_queue OTA conflict · never overwrite 501 / 505 / other → dead_letter unclassified · fail fast 401 / 403 → reauthenticate refresh token · re-queue once 429 → respect Retry-After obey header · defer, paced 5xx → transient_backoff 500/502/503/504 · jittered, capped 30s final attempt exhausted open circuit breaker + dead-letter

Step-by-step implementation

The build proceeds in four self-contained steps: an explicit status policy, a Retry-After-aware backoff calculator, the status-routing dispatcher, and the terminal-outcome handlers.

Step 1 — Enumerate the status policy in one place

The classification rule must live in a single explicit table, not scattered across if branches. Enumerating each status against a routing action makes the deterministic-vs-transient split auditable and prevents a 501 Not Implemented from silently entering the retry loop just because it is numerically a 5xx.

python
from enum import Enum

class Route(str, Enum):
    COMMIT = "commit"                 # 2xx: mutation accepted
    VALIDATE = "validation_queue"     # 400/422: payload is wrong, human/schema fix
    REAUTH = "reauthenticate"         # 401/403: refresh token, re-queue once
    RECONCILE = "reconciliation_queue"  # 409: OTA holds a conflicting state
    RATE_LIMIT = "respect_retry_after"  # 429: client throttle, pace and defer
    BACKOFF = "transient_backoff"     # 5xx: upstream degradation, retry bounded
    DEAD_LETTER = "dead_letter"       # anything non-retryable and unclassified

# 429 is a 4xx by number but transient in behavior, so it gets its own route
# rather than being lumped with 400/422. 501/505 are 5xx by number but
# deterministic, so they fall through to DEAD_LETTER instead of BACKOFF.
STATUS_POLICY = {
    400: Route.VALIDATE, 422: Route.VALIDATE,
    401: Route.REAUTH,   403: Route.REAUTH,
    409: Route.RECONCILE,
    429: Route.RATE_LIMIT,
    500: Route.BACKOFF, 502: Route.BACKOFF, 503: Route.BACKOFF, 504: Route.BACKOFF,
}

def classify(status_code: int) -> Route:
    if 200 <= status_code < 300:
        return Route.COMMIT
    return STATUS_POLICY.get(status_code, Route.DEAD_LETTER)

Enumerating 500/502/503/504 explicitly rather than testing 500 <= code < 600 is the key defensive choice: a genuinely fatal 501 (an unsupported OTA endpoint) falls through to DEAD_LETTER and fails fast instead of looping pointlessly for five attempts and burning quota.

Step 2 — Compute a Retry-After-aware, jittered backoff

5xx and 429 both defer, but for different reasons and on different clocks. A 5xx waits on a doubling curve with jitter to avoid a synchronized retry storm; a 429 must obey the OTA’s Retry-After header verbatim, because guessing shorter than the header gets you throttled harder.

python
import random

def backoff_seconds(attempt: int, base: float = 1.0, cap: float = 30.0,
                    retry_after: float | None = None) -> float:
    if retry_after is not None:
        # Honor the server's window exactly, plus a small jitter so a fleet of
        # workers whose windows reset together don't all re-fire on the same tick.
        return retry_after + random.uniform(0.1, 0.5)
    delay = min(base * (2 ** attempt), cap)      # doubling curve, hard-capped
    return delay + random.uniform(0, delay * 0.25)  # up to 25% jitter

Capping the doubling curve at 30s before adding jitter (rather than after) guarantees the worst-case wait stays bounded; the deeper mechanics of the curve itself are covered in implementing exponential backoff in Python.

Step 3 — Route every response through a status-aware dispatcher

The dispatcher inspects response.status_code directly and dispatches on the Route from Step 1. Crucially, it catches only RequestException (genuine network faults) around the call — it does not wrap the whole method in a broad except, which would swallow the routing logic.

python
import structlog, requests
from requests.exceptions import RequestException

log = structlog.get_logger("rate_parity.classifier")

class ParitySyncDispatcher:
    def __init__(self, max_retries: int = 5):
        self.max_retries = max_retries
        self.session = requests.Session()

    def execute_sync(self, payload: dict, endpoint: str, headers: dict) -> bool:
        ctx = {"property_id": payload.get("property_id"),
               "rate_plan_code": payload.get("rate_plan_code"), "endpoint": endpoint}
        for attempt in range(self.max_retries):
            try:
                resp = self.session.post(endpoint, json=payload, headers=headers, timeout=15)
            except RequestException as exc:  # DNS/TLS/connect: transient, retry
                log.warning("network_failure", error=str(exc), attempt=attempt, **ctx)
                _sleep(backoff_seconds(attempt))
                continue

            route = classify(resp.status_code)
            line = {"status": resp.status_code, "attempt": attempt, "route": route.value, **ctx}

            if route is Route.COMMIT:
                log.info("parity_push_committed", **line)
                return True
            if route is Route.VALIDATE:
                log.error("client_validation_failed", body=resp.text[:2000], **line)
                self._to_validation_queue(payload); return False
            if route is Route.RECONCILE:
                log.warning("parity_conflict_detected", **line)
                self._to_reconciliation_queue(payload); return False
            if route is Route.REAUTH:
                log.warning("auth_failure_detected", **line)
                self._trigger_oauth2_refresh(); continue   # re-queue with fresh token
            if route is Route.RATE_LIMIT:
                wait = float(resp.headers.get("Retry-After", 30))
                log.warning("rate_limit_hit", retry_after=wait, **line)
                _sleep(backoff_seconds(attempt, retry_after=wait)); continue
            if route is Route.BACKOFF:
                log.error("server_degradation", **line)
                if attempt == self.max_retries - 1:
                    self._open_circuit_breaker(endpoint); return False
                _sleep(backoff_seconds(attempt)); continue

            log.critical("unhandled_http_status", **line); return False  # DEAD_LETTER

        log.error("max_retries_exhausted", **ctx)
        return False

Because classify() returns a single Route per response, every status takes exactly one path — there is no fall-through where a 429 accidentally lands in the 5xx branch, and no raise_for_status() firing before the router has decided what to do. Only REAUTH, RATE_LIMIT, and BACKOFF continue the loop; VALIDATE, RECONCILE, and DEAD_LETTER return immediately so a deterministic rejection never consumes a second attempt.

Step 4 — Wire the terminal-outcome handlers

The routing methods are deliberately thin — they hand off to durable infrastructure (a broker-backed queue, the token service) rather than doing work inline, so the dispatcher stays fast and the side effects are independently testable.

python
    def _to_validation_queue(self, payload: dict) -> None:
        # 400/422: schema or business-rule failure — a human or the schema owner fixes it.
        log.info("routed", route=Route.VALIDATE.value, property_id=payload.get("property_id"))

    def _to_reconciliation_queue(self, payload: dict) -> None:
        # 409: the OTA holds a conflicting rate/restriction; reconcile before re-pushing.
        log.info("routed", route=Route.RECONCILE.value, property_id=payload.get("property_id"))

    def _trigger_oauth2_refresh(self) -> None:
        log.info("oauth2_token_refresh_initiated")

    def _open_circuit_breaker(self, endpoint: str) -> None:
        # After consecutive 5xx, pause this endpoint so we stop hammering a degraded upstream.
        log.error("circuit_breaker_opened", endpoint=endpoint)

Separating _to_validation_queue from _to_reconciliation_queue — instead of a single generic “dead-letter” sink — is what lets reconciliation treat a malformed payload and a genuine OTA-side conflict differently; a 409 reconciled against OTA channel mapping strategies recovers automatically, while a 400 needs a schema fix.

Gotchas & production notes

429 is a client error you must retry. By number it is a 4xx, so naive taxonomies file it with 400/422 and abandon the push. Operationally it is transient — the window resets on its own. Route it separately and obey Retry-After; treating it as fatal drops legitimate rate updates, and treating it with generic 5xx backoff (ignoring the header) gets your IP throttled harder.

Never retry a 409. A conflict means the OTA already holds a rate or restriction for that room type and date — often a channel-side promotion. Retrying overwrites it and triggers parity-violation penalties or double-booking safeguards. Route it to reconciliation and diff the room-type/date range before any re-push.

Exception-subclass ordering hides HTTP errors. In requests/httpx, HTTPError is a subclass of RequestException. If you catch RequestException first in a separate except block, an HTTP error handler placed below it is unreachable. This classifier sidesteps the trap entirely by inspecting response.status_code directly and catching RequestException only around the network call for genuine connect/DNS/TLS faults.

A retried 5xx can double-apply a rate that already committed. A 500 or a socket timeout on the response leg does not prove the OTA rejected the mutation — the rate may have been written just before the connection dropped. Blind retry then pushes it twice. That is harmless for an idempotent absolute-price set but corrupts any relative operation (for example “raise BAR by 10 EUR”). Send an idempotency key — a client-generated sync_id echoed in an Idempotency-Key header derived from property_id, rate_plan_code, and the target date range — on every push so the channel manager collapses the replay, and let the nightly reconciliation run catch any double-application the OTA did not de-duplicate.

A 4xx that keeps returning after a PMS upgrade is a drift alarm, not noise. A sudden rise in route="validation_queue" events almost always means the payload schema drifted out of sync with the OTA contract. Alert on the rate of change of client_validation_failed, and cross-check committed pushes against the nightly batch reconciliation run so an accepted-but-unrecorded mutation surfaces before it becomes an overbooking. During a sustained 5xx outage, the open circuit breaker should hand off to the broader fallback routing for downtime strategy and trigger an async polling sweep to confirm the OTA’s true state before resuming.

Verification snippet

Before promoting the classifier, prove the two properties that matter most: deterministic errors take exactly one attempt, and transient errors retry then succeed. This uses responses to mock the OTA so no live credentials are needed.

python
import responses

@responses.activate
def test_classification_routes_correctly():
    # 1) A 400 must NOT be retried — exactly one call.
    responses.add(responses.POST, "https://cm.example/rates/sync",
                  json={"error": "invalid rate_plan_code"}, status=400)
    d = ParitySyncDispatcher(max_retries=5)
    assert d.execute_sync({"property_id": "prop_1a2b3c4d"}, "https://cm.example/rates/sync", {}) is False
    assert len(responses.calls) == 1            # deterministic → single attempt

    # 2) Two 503s then a 200 must retry and ultimately commit.
    responses.reset()
    responses.add(responses.POST, "https://cm.example/rates/sync", status=503)
    responses.add(responses.POST, "https://cm.example/rates/sync", status=503)
    responses.add(responses.POST, "https://cm.example/rates/sync", status=200)
    assert d.execute_sync({"property_id": "prop_1a2b3c4d"}, "https://cm.example/rates/sync", {}) is True
    assert len(responses.calls) == 3            # transient → retried to success

    # 3) Pure classification unit checks — no network.
    assert classify(429) is Route.RATE_LIMIT    # 4xx by number, transient by behavior
    assert classify(501) is Route.DEAD_LETTER   # 5xx by number, deterministic
    print("classification routing OK")

test_classification_routes_correctly()

The len(responses.calls) == 1 assertion on the 400 case is the single most important test in the suite: a regression that starts retrying validation errors is exactly the failure that silently drains an API quota by mid-morning. Asserting classify(501) is Route.DEAD_LETTER guards the explicit-status-list decision from Step 1 against a well-meaning refactor to a >= 500 range check.

FAQ

Is a 429 a 4xx or a 5xx for retry purposes?

Neither category should own it. A 429 is numerically a client error, but it is transient like a 5xx, so it gets its own route: honor the Retry-After header, defer, and retry paced. Filing it with 400/422 drops legitimate rate updates; filing it with 5xx and ignoring the header gets you throttled harder.

Why not just retry every failure a few times to be safe?

Because roughly half the failure space is deterministic. Retrying a 400, 409, or 422 cannot succeed — the payload or the OTA-side state is the problem, not the network — so retries only waste quota, delay routing to the right queue, and for a 409 risk overwriting a valid channel promotion. Bounded retries apply to 429 and 5xx only.

How do I keep a fatal 5xx like 501 out of the retry loop?

Enumerate the retryable server statuses explicitly (500, 502, 503, 504) instead of testing a 500 <= code < 600 range. A 501 Not Implemented or 505 HTTP Version Not Supported is a wiring problem that will never succeed on replay, so let it fall through to the dead-letter route and fail fast.

← Back to Error Categorization & Retry Logic