Christian Luis
10/26/2025, 9:23 PMMarvin
10/26/2025, 9:23 PMMarvin
10/26/2025, 9:24 PMupdated_after, since_id, or modified_at >= last_success to pull only changes. Store the checkpoint so re-runs are tiny.
- Use keyset/cursor pagination with a stable sort
- Avoid offset pagination for large result sets; it gets slower and can skip/duplicate rows. Use cursor or since_id with an ascending, unique key (e.g., id).
- Maximize network efficiency
- Request only the fields you need (e.g., fields=id,sku,price,updated_at).
- Use compression: add Accept-Encoding: gzip and/or br.
- Use HTTP/2 if the server supports it — it improves throughput and resource use.
- Use the largest allowed page size.
- Parallelize safely
- Async I/O with a small number of long-lived connections is highly efficient on modest hardware (e.g., httpx.AsyncClient).
- Use a bounded number of concurrent requests (start ~16–64, then tune). A quick rule: optimal concurrency ≈ target_rps × p95_latency_seconds.
- Respect rate limits; back off on 429/5xx and honor Retry-After.
- Stream results to storage; do not hold in memory
- Write records out incrementally (DB batch insert, Parquet row groups, or NDJSON lines) to keep memory flat.
- Resumability and observability
- Checkpoint last cursor/updated_at frequently so you can resume after failures.
- Log throughput and error counts to tune concurrency and page size.
- Deduplicate by primary key on write if your API can occasionally return overlaps (e.g., eventual consistency).
Reference implementation (async, minimal hardware)
- Pattern: a cursor queue with N async workers, a single writer, streaming to disk. Replace the writer with your DB/warehouse sink.
- This example uses cursor-based pagination; adapt params/keys for your API.
```
import asyncio
import json
import os
from typing import Optional, Tuple
import httpx
API_URL = os.environ.get("PRODUCTS_URL", "https://api.example.com/products")
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "250")) # use API max
MAX_CONCURRENCY = int(os.environ.get("MAX_CONCURRENCY", "32"))
FIELDS = os.environ.get("FIELDS", "id,sku,price,updated_at") # only what you need
OUTPUT_PATH = os.environ.get("OUTPUT_PATH", "products.ndjson")
TIMEOUT = httpx.Timeout(30.0, connect=10.0)
HEADERS = {
"Accept": "application/json",
"Accept-Encoding": "gzip, br",
"User-Agent": "product-sync/1.0",
}
class Stop:
pass
async def fetch_page(client: httpx.AsyncClient, cursor: Optional[str]) -> Tuple[list, Optional[str]]:
params = {"limit": PAGE_SIZE, "fields": FIELDS}
if cursor:
params["cursor"] = cursor
backoff = 1.0
while True:
try:
resp = await client.get(API_URL, params=params, headers=HEADERS)
# Handle transient and rate-limit errors
if resp.status_code in (429, 500, 502, 503, 504):
retry_after = resp.headers.get("Retry-After")
delay = float(retry_after) if retry_after else backoff
await asyncio.sleep(delay)
backoff = min(backoff * 2, 30.0)
continue
resp.raise_for_status()
data = resp.json()
# Adjust these keys to match your API's shape
items = data.get("items") or data.get("data") or []
next_cursor = data.get("next_cursor") or (data.get("paging") or {}).get("next_cursor")
return items, next_cursorMarvin
10/26/2025, 9:24 PMupdated_after from a Prefect Variable and run incrementally on a schedule.
- If you need centralized throttling across multiple runs/agents, use Prefect concurrency limits (e.g., guard each request with a concurrency slot) so you never exceed the provider’s limits from multiple deployments.
Questions to tailor this to your API
- Which pagination model does your /products endpoint use? (cursor/next, since_id, offset/page)
- Max page size allowed?
- Published rate limits? Is HTTP/2 supported? Compression supported?
- Can you filter by updated_at to do incremental syncs?
- Any bulk export or async job endpoints?
Share those details and I’ll adjust the code and parameters for maximum speed with your specific endpoint.Christian Luis
10/26/2025, 9:37 PMMarvin
10/26/2025, 9:38 PMMarvin
10/26/2025, 9:39 PMitems and next_cursor. If your API uses updated_after or a different shape, I can adapt this quickly.
- Avoids creating a Prefect task per page (keeps overhead low); all parallelism is pure asyncio inside a single flow.
- Uses prefect.variables.Variable to store the last updated_after (optional) so subsequent runs are incremental.
Flow code
```
from future import annotations
import asyncio
import json
import os
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple, List, Dict, Any
import httpx
from prefect import flow, get_run_logger
from prefect.variables import Variable
# Tunables (can also be provided as flow parameters)
DEFAULT_PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "250")) # use API max
DEFAULT_MAX_CONCURRENCY = int(os.environ.get("MAX_CONCURRENCY", "32"))
DEFAULT_FIELDS = os.environ.get("FIELDS", "id,sku,price,updated_at")
DEFAULT_OUTPUT_PATH = os.environ.get("OUTPUT_PATH", "products.ndjson")
# Optional incremental sync checkpoint
CHECKPOINT_VAR = os.environ.get("CHECKPOINT_VAR", "products_updated_after")
CHECKPOINT_SKEW_SEC = int(os.environ.get("CHECKPOINT_SKEW_SEC", "60")) # re-fetch last minute
TIMEOUT = httpx.Timeout(30.0, connect=10.0)
HEADERS = {
"Accept": "application/json",
"Accept-Encoding": "gzip, br",
"User-Agent": "prefect-products-sync/1.0",
}
def parse_ts(ts: str) -> datetime:
# Robust-ish ISO8601 parse without extra deps
# Handles 'Z' by normalizing to +00:00
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
return datetime.fromisoformat(ts)
def isoformat_utc(dt: datetime) -> str:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
async def fetch_page(
client: httpx.AsyncClient,
api_url: str,
page_size: int,
fields: str,
cursor: Optional[str],
updated_after: Optional[str],
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
params = {"limit": page_size, "fields": fields}
if cursor:
params["cursor"] = cursor
# Optional incremental filter if your API supports it
if updated_after:
params["updated_after"] = updated_after
backoff = 1.0
while True:
try:
resp = await client.get(api_url, params=params, headers=HEADERS)
if resp.status_code in (429, 500, 502, 503, 504):
retry_after = resp.headers.get("Retry-After")
delay = float(retry_after) if retry_after else backoff
await asyncio.sleep(delay)
backoff = min(backoff * 2, 30.0)
continue
resp.raise_for_status()
data = resp.json()
# Adjust these keys to match your API
items = data.get("items") or data.get("data") or []
next_cursor = data.get("next_cursor") or (data.get("paging") or {}).get("next_cursor")
return items, next_cursor
except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.RemoteProtocolError, httpx.TransportError, httpx.HTTPStatusError):
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30.0)
@flow(name="sync_products")
async def sync_products_flow(
api_url: str = "https://api.example.com/products",
page_size: int = DEFAULT_PAGE_SIZE,
max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
fields: str = DEFAULT_FIELDS,
output_path: str = DEFAULT_OUTPUT_PATH,
start_cursor: Optional[str] = None,
use_incremental: bool = True, # set False for a full refresh
):
"""Marvin
10/26/2025, 9:39 PMMarvin
10/26/2025, 9:39 PMsync_products_flow(api_url="<https://your.api/products>")
- On a schedule: create a deployment of this flow, then run on an interval (e.g., every 5–15 minutes). For maximal efficiency, keep use_incremental=True.
Optional improvements
- Replace the NDJSON writer with a database batch writer (e.g., Snowflake, Postgres, BigQuery) or Parquet via pyarrow.
- If your org runs multiple deployments that hit the same API, add a global rate limit or concurrency guard so you never exceed provider limits. I can wire this up for your workspace.
- If your API uses updated_after pagination instead of a cursor, we’ll tweak the fetch to pass a page token and iterate until no more results.
Can you share:
- The pagination model (cursor, since_id, updated_after + page_token)?
- Max page size allowed and rate limits (RPS)?
- The exact response shape for items and next_cursor?
- Where you want to write the data (file, warehouse, object storage)?
I’ll tailor the flow to your API and sink with those details.