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 paginationLinkheaders) - 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.
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.
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.
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
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.
Related
- Async Polling & Pagination Handling — the parent cluster covering cursor traversal in depth.
- Handling Booking.com API pagination limits — a provider-specific instance of these failure modes.
- Rate Limiting & Retry Strategies — the backoff and quota discipline behind 429 recovery.