Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Harvest a Flaky Paginated API
The vendor API is paginated, occasionally 500s, rate-limits without documenting it, and returns overlapping pages when the data moves under you. Collect everything, retry what deserves retrying, deduplicate, and return a partial result with the failures recorded rather than an exception.
The problem
Implement harvest(fetch_page, max_attempts). fetch_page(cursor) returns a dict/object that is either {"records": [...], "next": cursor_or_None} on success or {"error": code} on failure, where code is 500, 429 or 404. Start at cursor None. Retry a page on 500 or 429 up to max_attempts total attempts; a 404 is permanent and must not be retried. Deduplicate records by their "id", keeping the first seen. If a page exhausts its attempts, stop and return what you have. Return {"records": [...], "pages": n, "failures": [...]} where pages counts pages successfully fetched and each failure is {"cursor", "code", "attempts"}.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement harvest(fetch_page, max_attempts) returning records, pages and failures.
Examples and constraints 4 examples
max_attempts = 3; fetch_page(None) → {"records": [{"id": 1}, {"id": 2}], "next": "p2"}; fetch_page("p2") → {"records": [{"id": 2}, {"id": 3}], "next": None}{'records': [{'id': 1}, {'id': 2}, {'id': 3}], 'pages': 2, 'failures': []}max_attempts = 3; fetch_page(None) → {"error": 500} on the first call, then {"records": [{"id": 1}], "next": None}{'records': [{'id': 1}], 'pages': 1, 'failures': []}max_attempts = 3; fetch_page(None) → {"records": [{"id": 1}], "next": "p2"}; fetch_page("p2") → {"error": 404}{'records': [{'id': 1}], 'pages': 1, 'failures': [{'cursor': 'p2', 'code': 404, 'attempts': 1}]}max_attempts = 2; fetch_page always → {"error": 429}{'records': [], 'pages': 0, 'failures': [{'cursor': None, 'code': 429, 'attempts': 2}]}500and429are retryable;404is permanent and consumes exactly one attempt.- Never exceed
max_attemptstotal attempts for any single cursor. - Records are deduplicated by id, first occurrence wins.
- A page that exhausts its attempts stops the crawl and returns a partial result — it never raises.
Hints 4 ideas
- Two loops: an outer one over cursors, an inner one over attempts for the current cursor.
- A 404 must break out of the attempt loop immediately — retrying a permanent error just burns a customer’s quota.
- Keep a set of seen ids and append only unseen records, so a shifting dataset cannot duplicate rows.
- On exhaustion, return what you have with the failure recorded. Never throw away collected records.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious loop follows the cursor until it is None and assumes every call succeeds: fetch, extend records with page["records"], move to page["next"]. Against a clean API it is correct, with P calls for P pages and O(R) space for R records. Against this one it fails three ways. The first {"error": 500} has no "records" key, so it raises KeyError and every record already collected is lost. A 404 is treated as data instead of a permanent stop. And overlapping pages are appended twice: the happy-path example comes back as ids [1, 2, 2, 3].
The approach
Separate the three decisions the naive loop conflates: what to retry, how many times, and what to keep. The reference nests two loops. The outer while True walks cursors from None; the inner while attempts < max_attempts retries the current cursor. A response without an "error" key is the page and breaks the inner loop. An error in RETRYABLE = {500, 429} loops again, and any other code, such as 404, breaks at once, so a permanent error costs exactly one attempt.
If the inner loop ends with page is None, the reference appends {"cursor", "code": last_code, "attempts"} to failures and breaks the outer loop, returning what it has instead of raising. On success it increments pages and appends only records whose "id" is not yet in seen. The invariant: records holds each id once, in first-seen order, and nothing collected is ever discarded.
Walk through an example
With max_attempts = 3, a server that answers "p2" with a 500, then a 429, then a page that repeats id 2, and answers "p3" with a 500 every time, the crawl keeps ids 1 to 3 and records one failure.
| step | cursor | attempt | response | action | record ids |
|---|---|---|---|---|---|
| 1 | None | 1 | ids 1, 2; next "p2" | pages = 1 | [1, 2] |
| 2 | "p2" | 1 | {"error": 500} | retryable, try again | [1, 2] |
| 3 | "p2" | 2 | {"error": 429} | retryable, try again | [1, 2] |
| 4 | "p2" | 3 | ids 2, 3; next "p3" | id 2 already in seen; pages = 2 | [1, 2, 3] |
| 5 | "p3" | 1 | {"error": 500} | retryable, try again | [1, 2, 3] |
| 6 | "p3" | 2 | {"error": 500} | retryable, try again | [1, 2, 3] |
| 7 | "p3" | 3 | {"error": 500} | attempts exhausted: record {"cursor": "p3", "code": 500, "attempts": 3}, stop | [1, 2, 3] |
Complexity
Each cursor costs at most A = max_attempts calls, so P pages take at most O(P·A) calls, and the work around them is O(R) for R records received, with O(1) set operations each. Space is O(R) for records and seen. The naive loop also makes P calls in O(R) space; its problem is correctness, not cost.
Edge cases to test
- A 404 on a later page stops the crawl and keeps the earlier records:
pages = 1and one failure,{"cursor": "p2", "code": 404, "attempts": 1}. - A 429 that never clears is still a failure once the attempts run out:
harvest(lambda c: {"error": 429}, 2)returns no records and{"cursor": None, "code": 429, "attempts": 2}. - A retryable error followed by a 404 records the last code: answering the first cursor with 500 and then 404, under
max_attempts = 5, gives{"code": 404, "attempts": 2}. - A status outside the spec, such as 503, is not in
RETRYABLE, so the reference treats it as permanent and stops after one attempt. Real crawlers usually retry 502, 503 and 504 as well. - A server that keeps returning the same
nextcursor makes the reference loop forever. A production crawler keeps a set of cursors already fetched, or a page limit. max_attempts = 0makes no calls at all and records{"cursor": None, "code": None, "attempts": 0}.
Common mistakes
- Treating a 429 as a permanent failure and abandoning the crawl — it is the one error the API is explicitly telling you to retry.
- Retrying forever, so a genuinely broken page turns into an infinite loop against a customer’s rate-limited quota.
- Discarding everything on a single page failure instead of returning what was collected with the failure recorded.
- Not deduplicating, so a dataset that shifts mid-crawl silently produces duplicate rows downstream.
Follow-ups an interviewer asks next
- How should retries wait between attempts?
- Use exponential backoff with jitter: sleep a random time up to
base · 2^attempt, capped, so many clients recovering from one outage do not retry in lockstep. If a 429 response carries aRetry-Afterheader, wait at least that long. The reference retries immediately, which is fine in a replay but would hammer a real rate limit. - The crawl died at page 40 of 100. How do you resume?
- Checkpoint after every successful page: persist the next cursor, the page count and the seen ids, or a high-water mark if ids are ordered. On restart, load the checkpoint and continue from the saved cursor. The partial result the reference returns already says where to start: the failure’s
cursoris the page to retry. - The dataset is too big to keep every id in memory. Now what?
- Deduplicate in storage: write records to a table with a unique key on
idand upsert, so a repeat overwrites or is ignored. If ids grow with time, a high-water mark replaces the set. A Bloom filter fits in bounded memory, but a false positive would silently drop a real record, so it can only screen ids before an exact check. - How would you make the crawl faster?
- Cursor pagination is sequential, because each page names the next, so parallelism has to come from elsewhere: crawl independent partitions concurrently, such as date ranges or accounts, or use offset pagination where the API offers it and the data is stable. Every worker still shares one rate limit, so put a single limiter in front of all of them.
Where this shows up
Every integration meets this in week two: the customer’s vendor API is paginated, occasionally 500s, has a limit nobody documented, and returns overlapping pages when records are being written while you read. The version that returns a partial result plus a failure list is the one you can put in front of a customer, because it answers "what did we get?" rather than raising an exception at 3am with nothing to show.
Reference solution
Reference solution — Python
RETRYABLE = {500, 429}
def harvest(fetch_page, max_attempts):
records, seen, failures = [], set(), []
cursor, pages = None, 0
while True:
attempts, page, last_code = 0, None, None
while attempts < max_attempts:
attempts += 1
res = fetch_page(cursor)
if "error" not in res:
page = res
break
last_code = res["error"]
if last_code not in RETRYABLE: # 404 is permanent — stop trying
break
if page is None:
failures.append({"cursor": cursor, "code": last_code, "attempts": attempts})
break # partial result, not an exception
pages += 1
for r in page.get("records", []):
if r["id"] not in seen: # dedup: pages can overlap
seen.add(r["id"])
records.append(r)
cursor = page.get("next")
if cursor is None:
break
return {"records": records, "pages": pages, "failures": failures}
pages = [{"records": [{"id": 1}, {"id": 2}], "next": "p2"},
{"records": [{"id": 2}, {"id": 3}], "next": None}]
seq = {"i": 0}
def fake(cursor):
p = pages[seq["i"]]
seq["i"] += 1
return p
out = harvest(fake, 3)
print([r["id"] for r in out["records"]], out["pages"], out["failures"])
# -> [1, 2, 3] 2 []Reference solution — TypeScript
const RETRYABLE = [500, 429];
function harvest(fetchPage: (c: any) => any, maxAttempts: number): any {
const records: any[] = [], seen = new Set<any>(), failures: any[] = [];
let cursor: any = null, pages = 0;
for (;;) {
let attempts = 0, page: any = null, lastCode: any = null;
while (attempts < maxAttempts) {
attempts++;
const res = fetchPage(cursor);
if (res.error === undefined) { page = res; break; }
lastCode = res.error;
if (RETRYABLE.indexOf(lastCode) === -1) break; // 404 is permanent
}
if (page === null) {
failures.push({ cursor: cursor, code: lastCode, attempts: attempts });
break; // partial result
}
pages++;
for (const r of (page.records || [])) {
if (!seen.has(r.id)) { seen.add(r.id); records.push(r); }
}
cursor = page.next === undefined ? null : page.next;
if (cursor === null) break;
}
return { records: records, pages: pages, failures: failures };
}
let i = 0;
const fixture = [{ records: [{ id: 1 }, { id: 2 }], next: "p2" },
{ records: [{ id: 2 }, { id: 3 }], next: null }];
const out = harvest(function () { return fixture[i++]; }, 3);
console.log(out.records.map(function (r) { return r.id; }), out.pages, out.failures);
// -> [1, 2, 3] 2 []Questions about Harvest a Flaky Paginated API
Which HTTP errors should a crawler retry?
Retry the errors that mean try again later: 429 Too Many Requests and server errors such as 500, 502, 503 and 504, plus timeouts and dropped connections. Do not retry errors that mean the request itself is wrong, such as 400, 401, 403 and 404, because the same request will fail the same way and every attempt burns the customer’s quota. This challenge narrows the set to 500 and 429 as retryable and 404 as permanent. Whatever the set, cap the attempts, because a retryable error can still last for good.
Why return a partial result instead of raising an exception?
Because the records already collected are real and expensive to fetch again. If page 40 of 100 fails and the crawler raises, the caller gets an error and none of the 39 good pages. Returning the records plus a failure entry naming the cursor, the error code and the attempt count answers the questions the caller actually has: what did we get, and where did it stop? The caller can then resume from that cursor, alert someone, or ship the partial data with a warning. An exception forces that decision before anyone has seen the data.
Why do paginated APIs return duplicate records?
Because the data changes while you read it. With offset pagination, a record inserted near the start pushes every later record down by one, so the last record of page 1 shows up again as the first record of page 2, and a deletion can make a record skip a page entirely. Cursor pagination reduces this, but not every API implements it in a way that rules it out. Deduplicating by a stable id, first occurrence wins, makes the crawl correct either way. It cannot recover a record that was skipped, which is why long crawls are often followed by a reconciliation pass.
Does max_attempts count retries or total calls?
Total calls for one cursor, including the first try. With max_attempts set to 3, a page gets one attempt plus up to two retries, and the failure entry reports 3 attempts when all of them fail. A 404 stops after the first call, so its failure entry reports 1. The count starts over for every new cursor, so a long crawl can make many calls in total while never exceeding max_attempts on any single page. Off-by-one confusion here is common, so name the parameter for exactly what it counts.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Retry with Exponential BackoffTools time out and APIs rate-limit — a production agent retries without hammering. Exponential backoff waits longer after each failure (1s, 2s, 4s, 8s…) up to a cap. Compute the delay schedule: delay[i] = min(cap, base·2ⁱ). Solve it in Python or TypeScript.Read →
- Verify a Webhook SignatureEvery real integration sends signed webhooks — and every FDE has to verify them. Recompute the signature over the payload, compare in constant time, and reject stale events to stop replay attacks. Solve it in Python or TypeScript, with hidden tests.Read →
- Idempotency Key HandlerNetworks retry. A webhook fires twice; a payment is re-sent after a timeout. Without idempotency you double-charge or double-write. The fix: dedupe by a client-supplied key and always return the first result. Solve it in Python or TypeScript, with hidden tests.Read →
- CSV → Schema MapperEvery customer hands you a messy export. Before it flows into your system, each row must be coerced to a schema — strings to ints, "true" to booleans — and the rows that don’t fit quarantined, not silently dropped. Solve it in Python or TypeScript, with hidden tests.Read →
- Data Reconciliation DiffThe customer swears the migration worked. You reconcile: compare source records against what landed in the target, keyed by id, and report exactly what was added, removed, or changed. Solve it in Python or TypeScript, with hidden tests.Read →
- PII RedactorBefore customer data touches a log line — or a third-party LLM — the personal bits have to go. Mask emails, SSNs, credit cards, and phone numbers with pattern matching, leaving the rest readable. Solve it in Python or TypeScript, with hidden tests.Read →