Troubleshooting OTA integration failures

Most OTA integration incidents are not exotic. They are the same handful of failures — throttling, broken pagination cursors, and partial responses — showing up on a new endpoint. This guide is a practical playbook for diagnosing and recovering from them, extending Async Polling & Pagination Handling in the Data Ingestion & OTA API Integration Workflows pillar. The through-line is that every recovery must be resumable: a failed sync should pick up where it left off, not restart and re-ingest.

Prerequisites

  • Python 3.11+ with httpx (async)
  • Standard library logging, asyncio
  • Access to response headers (Retry-After, rate-limit, and pagination Link headers)
  • A checkpoint store to persist the last successful cursor
  • Idempotent upserts downstream, per Data Quality & Schema Contracts

Step 1 — Handle HTTP 429 by honoring Retry-After

A 429 is not an error to retry blindly; it is an instruction. Read Retry-After and wait exactly that long before the next attempt, falling back to exponential backoff only when the header is absent.

python
from __future__ import annotations

import asyncio
import logging
import httpx

logger = logging.getLogger("ota.troubleshoot")


async def get_with_429_respect(client: httpx.AsyncClient, url: str,
                               attempt: int = 0) -> httpx.Response:
    resp = await client.get(url)
    if resp.status_code == 429:
        retry_after = resp.headers.get("Retry-After")
        wait = float(retry_after) if retry_after and retry_after.isdigit() \
            else min(2 ** attempt, 60)
        logger.warning("429 on %s; waiting %.0fs (attempt %d)", url, wait, attempt)
        await asyncio.sleep(wait)
        return await get_with_429_respect(client, url, attempt + 1)
    resp.raise_for_status()
    return resp

Step 2 — Recover cursor-based pagination from a checkpoint

A pagination run that dies midway should resume from the last committed cursor, not from the beginning. Persist the cursor after each successful page so a crash costs one page, not the whole traversal.

python
async def paginate_resumable(client: httpx.AsyncClient, base_url: str,
                             load_cursor, save_cursor) -> int:
    cursor = load_cursor()          # None on a fresh run
    pages = 0
    while True:
        url = f"{base_url}?cursor={cursor}" if cursor else base_url
        resp = await get_with_429_respect(client, url)
        body = resp.json()
        # ... upsert body["results"] idempotently here ...
        cursor = body.get("next_cursor")
        pages += 1
        save_cursor(cursor)          # checkpoint after each committed page
        if not cursor:
            break
    logger.info("pagination complete: %d pages", pages)
    return pages

Step 3 — Reconcile partial responses

A response that returns fewer records than its own count claims is a partial response; committing it as complete creates a silent gap. Detect the mismatch and re-fetch the page rather than advancing the cursor past missing data.

python
class PartialResponse(Exception):
    pass


def assert_complete(body: dict) -> None:
    expected = body.get("total_in_page")
    got = len(body.get("results", []))
    if expected is not None and got != expected:
        raise PartialResponse(f"expected {expected} records, got {got}")


async def fetch_page_verified(client: httpx.AsyncClient, url: str,
                              max_retries: int = 2) -> dict:
    for attempt in range(max_retries + 1):
        resp = await get_with_429_respect(client, url)
        body = resp.json()
        try:
            assert_complete(body)
            return body
        except PartialResponse as exc:
            logger.warning("partial page %s: %s (retry %d)", url, exc, attempt)
    raise PartialResponse(f"page never returned complete: {url}")

Re-fetching on a count mismatch is the difference between a reconciled dataset and one with invisible holes that surface weeks later as forecast anomalies nobody can explain.

Verification and testing

python
def test_partial_response_detected() -> None:
    complete = {"total_in_page": 2, "results": [{"id": 1}, {"id": 2}]}
    partial = {"total_in_page": 3, "results": [{"id": 1}, {"id": 2}]}
    assert_complete(complete)            # no raise
    try:
        assert_complete(partial)
        assert False, "should have raised"
    except PartialResponse:
        pass

Common pitfalls and edge cases

  • Ignoring Retry-After. Backing off on your own schedule during a 429 extends the ban; honor the header.
  • Restarting pagination. Re-traversing from page one wastes quota and risks duplicates; resume from the checkpoint.
  • Trusting counts blindly. Advancing past a partial page loses records silently; verify count before committing.
  • Cursor expiry. Some cursors expire; on a cursor-invalid error, restart from the last stable checkpoint window, not from zero.
  • Non-idempotent recovery. Resumption re-ingests the checkpointed page’s tail; the upsert must be idempotent or you double-count.