Handling Registry API Rate Limits and Idempotent Retries
A registry crawl that runs for six hours and finishes with 94% of the records is worse than one that fails at the first throttle, because the first produces a plausible dataset and the second produces an obvious problem. This guide builds a connector that cannot do the former, within carbon credit registry data integration in the MRV architecture and carbon accounting fundamentals stack. It assumes the pagination and schema-pinning discipline from integrating Verra and Gold Standard APIs into Python pipelines, and adds the operational layer that keeps a long crawl honest.
Three properties do the work. The crawl must back off globally rather than per request, because concurrent workers each backing off independently keep the aggregate rate high and prolong the throttle. It must checkpoint its cursor durably, so an interrupted crawl resumes rather than restarts. And it must assert completeness against an independently known total, because the one thing a partial crawl never does is announce itself.
Root Cause Analysis
Three properties of registry APIs make naive retry logic actively harmful rather than merely inefficient.
Rate limits are usually shared and usually undocumented in their real form. A published limit of, say, sixty requests a minute is often enforced against an organisation or an address range rather than a token, applied over a sliding window rather than a fixed one, and accompanied by burst allowances that are not described anywhere. The practical consequence is that a crawl tuned to the documented number will meet a throttle it did not expect, at an unpredictable moment, usually deep into a long run.
A throttle response is not an error, and treating it as one causes damage. A 429 means the request was refused before doing anything, so the safe response is to wait exactly as long as the server asked and try again. A 500 means the server failed while possibly doing something, so the safe response depends on whether the operation is idempotent. A 400 means the request itself is wrong, so retrying is pure waste that consumes quota and hides the defect. Collapsing these into one retry policy — the default in most HTTP client wrappers — produces a connector that hammers a broken request sixty times and gives up on a recoverable throttle.
Interruption is normal at this duration. A full portfolio reconciliation across several registries takes hours, and over that window a token expires, a deploy restarts the worker, or the network blips. A crawl that holds its progress only in memory turns every interruption into a restart, and a restart against a rate-limited API is exactly the expensive operation you are trying to avoid. Durable cursor checkpoints turn a six-hour restart into a two-minute resume.
The failure that follows from all three is the same one: a crawl that ends early, reports success because no exception escaped, and hands downstream a dataset missing an unknown fraction of its records.
Diagnostic Pipeline / Pre-Flight Validation
Before the crawl, establish what “complete” means. A registry that publishes a result count, a total-pages header, or a stable identifier range gives you an independent expectation; without one, completeness cannot be asserted and the crawl’s own output becomes its only witness, which is exactly the circularity to avoid.
from dataclasses import dataclass
import httpx
import structlog
log = structlog.get_logger()
@dataclass(frozen=True)
class CrawlExpectation:
"""What the crawl must produce, established BEFORE it starts.
Derived from the registry rather than from the crawl, because a crawl that
defines its own success criterion cannot fail a completeness check.
"""
source: str
expected_total: int | None
counting_method: str
snapshot_token: str | None
def establish_expectation(client: httpx.Client, base_url: str,
query: dict) -> CrawlExpectation:
"""Ask the registry how many records the query matches, before fetching any.
Three mechanisms in decreasing order of reliability: an explicit count
endpoint, a total header on the first page, and a stable identifier range.
"""
try:
head = client.get(f"{base_url}/projects/count", params=query, timeout=30)
if head.status_code == 200:
payload = head.json()
total = int(payload["count"])
return CrawlExpectation("count_endpoint", total, "explicit count",
payload.get("snapshot"))
except (httpx.HTTPError, KeyError, ValueError):
pass
first = client.get(f"{base_url}/projects", params={**query, "limit": 1}, timeout=30)
first.raise_for_status()
header_total = first.headers.get("x-total-count")
if header_total and header_total.isdigit():
return CrawlExpectation("total_header", int(header_total), "response header",
first.headers.get("x-snapshot"))
# No independent expectation available: record that fact rather than
# inventing one, so the completeness assertion downstream is honest.
log.warning("crawl.no_expectation", base_url=base_url,
note="completeness cannot be asserted; crawl output is its own witness")
return CrawlExpectation("none", None, "unavailable", None)
def probe_rate_limit(client: httpx.Client, base_url: str) -> dict:
"""Read whatever the registry advertises, then plan for it being wrong.
Published limits are frequently aspirational and often shared across an
organisation, so the probe informs the starting rate, not the ceiling.
"""
response = client.get(f"{base_url}/projects", params={"limit": 1}, timeout=30)
advertised = {
"limit": response.headers.get("x-ratelimit-limit"),
"remaining": response.headers.get("x-ratelimit-remaining"),
"reset": response.headers.get("x-ratelimit-reset"),
"retry_after_supported": "retry-after" in {k.lower() for k in response.headers},
}
log.info("crawl.rate_limit_probe", **advertised)
return advertised
The establish_expectation function’s least useful branch is the important one. Recording that no independent count was available is what stops a later reader assuming the crawl was verified — the difference between “we checked and it was complete” and “we could not check”, which matters a great deal to a verifier and not at all to the code.
Deterministic Transformation Logic
The crawler below implements the three properties. A shared limiter that every worker consults before sending, a durable cursor checkpoint written after each page, and a completeness assertion that runs before the crawl is allowed to report success.
import asyncio
import json
import time
from dataclasses import dataclass, asdict
from pathlib import Path
import httpx
import structlog
log = structlog.get_logger()
MAX_ATTEMPTS = 5
BASE_DELAY_S = 2.0
class GlobalLimiter:
"""A pause every worker respects.
The critical property is that a 429 seen by ONE worker stops ALL of them.
Per-worker backoff keeps the aggregate rate at the limit, which prolongs the
throttle and, on some registries, escalates it to a block.
"""
def __init__(self) -> None:
self._resume_at = 0.0
self._lock = asyncio.Lock()
async def wait(self) -> None:
while True:
async with self._lock:
delay = self._resume_at - time.monotonic()
if delay <= 0:
return
await asyncio.sleep(min(delay, 5.0))
async def pause(self, seconds: float, reason: str) -> None:
async with self._lock:
resume = time.monotonic() + seconds
if resume > self._resume_at: # never shorten an existing pause
self._resume_at = resume
log.warning("crawl.global_pause", seconds=round(seconds, 1), reason=reason)
@dataclass
class Checkpoint:
"""Durable crawl position. Written after every page, read on start."""
source: str
cursor: str | None
pages_done: int
records_seen: int
snapshot_token: str | None
def save(self, path: Path) -> None:
path.write_text(json.dumps(asdict(self)))
@classmethod
def load(cls, path: Path, source: str) -> "Checkpoint":
if path.exists():
data = json.loads(path.read_text())
if data.get("source") == source:
log.info("crawl.resume", **data)
return cls(**data)
return cls(source=source, cursor=None, pages_done=0, records_seen=0,
snapshot_token=None)
def classify(status: int) -> str:
"""Five classes, five actions. Never one policy."""
if status == 429:
return "global_pause"
if status in (502, 503, 504):
return "global_pause"
if status >= 500:
return "retry_request"
if status in (401, 403):
return "refresh_once"
return "stop"
async def fetch_page(client: httpx.AsyncClient, url: str, params: dict,
limiter: GlobalLimiter, refresh_token) -> httpx.Response:
"""One page, with classification-driven retry. Never blind."""
refreshed = False
for attempt in range(1, MAX_ATTEMPTS + 1):
await limiter.wait()
response = await client.get(url, params=params, timeout=60)
if response.status_code == 200:
return response
action = classify(response.status_code)
if action == "stop":
log.error("crawl.request_rejected", status=response.status_code,
url=url, params=params,
note="the request is wrong; retrying would burn quota and hide it")
response.raise_for_status()
if action == "refresh_once":
if refreshed:
response.raise_for_status()
await refresh_token()
refreshed = True
continue
if action == "global_pause":
# Honour Retry-After exactly when given; the server knows its window.
retry_after = response.headers.get("retry-after")
delay = float(retry_after) if (retry_after or "").replace(".", "").isdigit() \
else BASE_DELAY_S * (2 ** (attempt - 1))
await limiter.pause(delay, reason=f"status_{response.status_code}")
continue
await asyncio.sleep(BASE_DELAY_S * (2 ** (attempt - 1)))
raise RuntimeError(f"exhausted {MAX_ATTEMPTS} attempts for {url}")
async def crawl(base_url: str, query: dict, expectation: CrawlExpectation,
checkpoint_path: Path, client: httpx.AsyncClient,
refresh_token) -> dict:
"""Resume-capable crawl with a completeness assertion at the end."""
limiter = GlobalLimiter()
state = Checkpoint.load(checkpoint_path, base_url)
records: list[dict] = []
while True:
params = {**query, "limit": 200}
if state.cursor:
params["after"] = state.cursor
response = await fetch_page(client, f"{base_url}/projects", params,
limiter, refresh_token)
payload = response.json()
page = payload.get("items", [])
if not page:
break
# A snapshot token changing mid-crawl means the collection moved under
# us; the pages already fetched no longer describe one consistent state.
token = payload.get("snapshot")
if state.snapshot_token and token and token != state.snapshot_token:
raise RuntimeError(
f"snapshot changed mid-crawl ({state.snapshot_token} -> {token}); restart")
state.snapshot_token = state.snapshot_token or token
records.extend(page)
state.cursor = page[-1]["id"]
state.pages_done += 1
state.records_seen += len(page)
state.save(checkpoint_path) # durable, after every page
log.info("crawl.page", pages=state.pages_done, records=state.records_seen,
cursor=state.cursor)
result = {"source": base_url, "records": len(records),
"pages": state.pages_done, "expected": expectation.expected_total,
"counting_method": expectation.counting_method}
if expectation.expected_total is not None:
if len(records) != expectation.expected_total:
log.error("crawl.incomplete", **result,
shortfall=expectation.expected_total - len(records))
raise RuntimeError(
f"crawl returned {len(records)} of {expectation.expected_total} records")
result["completeness_verified"] = True
else:
result["completeness_verified"] = False
checkpoint_path.unlink(missing_ok=True) # only on verified success
log.info("crawl.complete", **result)
return result
Three details carry the weight. The limiter never shortens an existing pause, so a worker that sees a shorter Retry-After cannot undo a longer one. The snapshot check fails the crawl when the collection changes mid-run, because pages fetched before and after a change do not describe one consistent state and silently mixing them produces a dataset that matches no moment in the registry’s history. And the checkpoint is deleted only after verified success, so a crashed crawl always resumes rather than starting over.
Compliance Gating & Audit Trail Generation
A crawl is an evidence-gathering operation, and its record needs four things a normal ETL job does not.
The snapshot identity. A reconciliation asserts something about the registry’s state at a point in time, so the record must name that point — a snapshot token where the registry provides one, and the crawl’s start and end timestamps in UTC where it does not. Without it, a discrepancy between your ledger and the registry cannot be attributed to timing versus error.
The completeness verdict, including when it could not be established. completeness_verified: false is a legitimate and important value. A downstream consumer treating an unverified crawl as authoritative is making an assumption the crawl explicitly declined to make, and recording the distinction is what lets a verifier see which claims rest on a checked total.
The rejected requests. Anything classified as stop — a malformed query, a 404 on an identifier you believed existed — is a finding rather than noise, and it belongs in the record with the request that produced it. A 404 on a project you hold credits from is a materially different event from a 404 on a typo.
The retry and pause history, aggregated. A crawl that spent forty minutes throttled is telling you the schedule is too aggressive; one that suddenly starts throttling where it did not before is telling you the registry changed something. Route these into the observability streams described under MRV pipeline observability and failure modes, and keep the completeness verdict and snapshot identity in the durable evidence stream alongside the reconciliation output.
Production Integration
- Establish the expectation first from a count endpoint, a total header, or a stable identifier range, and record when none is available.
- Probe the advertised limits to set a starting rate, then run at roughly half of it and let the adaptive pause find the real ceiling.
- Share one limiter across all workers for a given registry, and key it on the registry rather than the process, so several concurrent jobs do not each discover the throttle independently.
- Checkpoint the cursor after every page to durable storage, and treat resume as the normal path rather than the exception.
- Classify every non-200 response before deciding what to do with it, and never retry a
4xxother than 429. - Assert completeness before reporting success, and fail the run on a shortfall rather than logging it.
Two operational notes. Schedule full reconciliations off-peak for the registry’s own timezone where you can infer it — throttling is frequently a function of overall load rather than your own rate. And keep the raw responses, as recommended in the parent guide: a crawl that verified its completeness and stored its bytes can be re-parsed after a schema surprise without touching the registry again, which is worth a great deal when the registry is the rate-limited resource.
Frequently Asked Questions
Should the connector run concurrently at all?
Yes, but with the concurrency bounded by the shared limiter rather than by the worker count. Concurrency helps because most of the time is latency rather than transfer, and four to eight in-flight requests typically saturate what a registry will allow. Beyond that you are queueing at the server rather than at your own client, which converts throughput into throttling. The limiter makes the concurrency safe; without it, adding workers reliably makes a crawl slower.
What if a registry offers no way to know the expected total?
Record that fact and use the weaker checks available. Page-count monotonicity, an identifier range with no gaps, and comparison against the previous period’s record count all give partial assurance, and a large unexplained drop against last period is a strong signal even without an authoritative total. What matters is that the resulting record says the completeness check was partial, so nobody downstream treats it as verified.
How should token expiry mid-crawl be handled?
Refresh once on a 401 and retry the same request; if the retry also fails, stop. A refresh loop is one of the classic ways to get an account blocked, because a genuinely invalid credential produces an infinite sequence of refresh-and-retry that looks exactly like an attack. Refresh proactively on a timer set well inside the token lifetime as well, so mid-crawl expiry is rare rather than routine.
Is it safe to run two crawls of the same registry at once?
Only if they share a limiter and target disjoint queries, and it is rarely worth it. Two concurrent crawls double the request rate against a limit that is usually shared, so each becomes more than twice as slow. Where a portfolio spans several registries, run those in parallel with a limiter per registry — that genuinely parallelises, because the limits are independent.
What should happen when the snapshot changes mid-crawl?
Fail and restart. Pages fetched before and after a change describe two different states of the collection, and stitching them together produces a dataset that never existed — with records that may be duplicated, missing, or internally inconsistent. Restarting is expensive against a rate-limited API, which is the argument for keeping crawls short enough to complete inside a stable window rather than for tolerating the inconsistency.
How do retries interact with the idempotency of the wider pipeline?
Reads are naturally idempotent, so retrying a page fetch is safe; the risk lives in what you do with the result. Writing crawled records with an append rather than a deterministic upsert means a resumed crawl duplicates whatever the interrupted one had already written. Key the write on the registry’s own record identifier and the snapshot, so a re-fetched page overwrites rather than accumulates — the same deterministic-key discipline that makes building idempotent backfills for carbon pipelines safe.
How aggressive should the retry cap be?
Low, and paired with a crawl-level budget. Five attempts per request is generous for a read; what matters more is a cap on total retries across the crawl, because five attempts on each of ten thousand pages is fifty thousand extra requests against a limit you are already exceeding. A crawl that has spent more than a stated fraction of its requests on retries should stop and raise, since it is no longer making progress and is actively making the throttle worse.
Related guides
- Carbon Credit Registry Data Integration — the parent topic and the reconciliation model this crawl feeds.
- Integrating Verra & Gold Standard APIs into Python Pipelines — pagination, schema pinning, and the connector layer this builds on.
- Reconciling Credit Serial Numbers Across Registries — what the crawled records are reconciled against.
- MRV Pipeline Observability & Failure Modes — where the completeness verdict and throttle history are recorded.