Week 1 · Ingest anything › Day 6
Day 6. APIs and source databases
By the end of today you can
- Pull paginated REST APIs with retries, backoff and rate limits
- Handle late-changing data with a look-back window
- Explain full, incremental and CDC extraction from source databases
- Use dlt to do the same in a fraction of the code, and know when not to
1. Why this matters
Ad platforms, POS clouds, booking engines, CRMs and payment providers all expose REST APIs. Marketing data is the hardest teacher: numbers for "yesterday" keep changing for days as late conversions are attributed. And sometimes the source is another company database you can read from.
2. A well-behaved API client
import time, requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def session():
s = requests.Session()
retry = Retry(total=5, backoff_factor=1.5, status_forcelist=[429, 500, 502, 503, 504],
respect_retry_after_header=True, allowed_methods=["GET"])
s.mount("https://", HTTPAdapter(max_retries=retry))
s.headers["Authorization"] = f"Bearer {settings.ads_token}"
return s
def fetch_day(s, day: str):
url, params, pages = f"{BASE}/reports/daily", {"date": day, "page": 1}, []
while True:
r = s.get(url, params=params, timeout=30); r.raise_for_status()
body = r.json(); pages.append(body) # land every page raw, unchanged
if not body.get("next_page"):
return pages
params["page"] = body["next_page"]
Pagination comes in three shapes: page number, offset/limit, and cursor (next token or link). Cursors are the safest when data changes mid-read.
Maths and principle
Principle: exponential backoff spaces retries so a struggling server can recover, and jitter prevents everyone retrying at the same instant.
Equation: wait before retry \(k\): \(t_k = b \cdot 2^{k-1} + U(0, j)\). With \(b=1.5\)s: 1.5, 3, 6, 12, 24 s. Total worst-case wait \(\approx b(2^n - 1)\).
Analogy: radioactive decay in reverse: each retry halves the rate of attempts. Where it breaks: a 429 with a Retry-After header overrides your formula; respect it.
Try it: \(b=2\), 4 retries. Max total wait without jitter? (2+4+8+16 = 30 s.)
3. Data that changes after the fact
Ad conversions, hotel bookings (modified, cancelled), POS refunds: yesterday's numbers are not final. The pattern is a look-back window: every run re-pulls the last N days and replaces them.
Choose N from data: measure how much a day's total still changes after 1, 3, 7, 14 days. If day+7 changes are under 0.1% of spend, \(N=7\) is a defensible choice, and the report footer says "last 7 days provisional".
4. When the source is a database
| Method | How | Cost on source | Catches deletes? |
|---|---|---|---|
| Full extract | SELECT * every night |
High, grows forever | Yes (by comparison) |
| Incremental | WHERE updated_at > :watermark |
Low | No, unless soft deletes |
| CDC | Read the transaction log (SQL Server CDC / Change Tracking, Postgres logical replication, Debezium) | Lowest | Yes |
Always read from a replica or a read-only login, during agreed hours, with a query timeout. A heavy SELECT on a POS database at lunchtime is how you meet the operations director.
5. dlt: the same thing in 20 lines
dlt (data load tool) is an open-source Python library that handles pagination, incremental state, schema inference and evolution, and loading into DuckDB, Postgres, Azure SQL (mssql), Snowflake, BigQuery and more.
import dlt
from datetime import date, timedelta
def days_between(start: str, end: date):
d = date.fromisoformat(start)
while d <= end:
yield d.isoformat(); d += timedelta(days=1)
@dlt.resource(write_disposition="merge", primary_key=["date", "campaign_id"])
def ad_stats(cursor=dlt.sources.incremental("date", initial_value="2026-09-01")):
s = session()
start = (date.fromisoformat(cursor.last_value) - timedelta(days=7)).isoformat() # 7-day look-back
for d in days_between(start, date.today() - timedelta(days=1)):
for page in fetch_day(s, d):
yield page["data"] # dlt infers the schema, keeps state, merges on the key
pipeline = dlt.pipeline("ads", destination="duckdb", dataset_name="raw_ads")
print(pipeline.run(ad_stats())) # swap destination="mssql" or "postgres" later
The 5-year lens
Know both levels. dlt and Airbyte are excellent and you'd use them in a real cheap stack (Week 7). But interviews and incidents test whether you understand what they do underneath: pagination, state, merge keys, schema drift. Build it by hand once (today), then adopt the tool and say why in your README. "I wrote the client, then replaced 300 lines with dlt and kept my tests" is a strong story.
Lab
- Serve the course ad JSON as a fake API. Save this as
fake_api.pyand run it withuv run fastapi dev fake_api.py(or usehttp.serverand static pages):import json; from pathlib import Path; from fastapi import FastAPI, HTTPException app, DIR = FastAPI(), Path("data/landing/api/ads") @app.get("/reports/daily") def daily(date: str, page: int = 1): f = DIR / f"{date}.json" if not f.exists(): raise HTTPException(404) pages = json.loads(f.read_text())["pages"] return pages[page - 1] - Write
sources/ads_api.pywith retries, pagination and a 7-day look-back. Land each page raw as JSON lines withfetched_at. - Add a random
429to the fake API (10% of calls) and confirm retries handle it; log retry counts. - Note
spend_micros(spend × 1,000,000) and the occasionalcurrency: USD. Don't fix them yet; record them innotes/sources.mdas Week 2 work. - Rebuild the same extract with dlt into DuckDB. Compare row counts with your hand-built version. Write 5 lines in the README on what dlt gave you for free.
Ship
feat(ingest): ads API client with backoff and look-back; dlt comparison.
Check yourself
1. Ad conversions for a day keep changing for about a week. What should the daily job do?
A look-back window keeps recent days fresh while history settles.
2. Which extraction method catches hard deletes in a source table?
Incremental filters on timestamps never see a row that no longer exists.
3. An API returns 429 with Retry-After: 30. What should your client do?
Respect the server's instruction; it's how you avoid being blocked.