Pipeline to Dashboardnihalsulaiman.com

Week 1 · Ingest anything › Day 6

Day 6. APIs and source databases

⏱ 4h · learn 70m · lab 130m · ship 25m · review 15m🏷 marketing (ad platform API)Before: Day 5

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.

\[\text{pull}(d) \text{ for } d \in [\text{today} - N, \text{today} - 1]\]

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

  1. Serve the course ad JSON as a fake API. Save this as fake_api.py and run it with uv run fastapi dev fake_api.py (or use http.server and 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]
  2. Write sources/ads_api.py with retries, pagination and a 7-day look-back. Land each page raw as JSON lines with fetched_at.
  3. Add a random 429 to the fake API (10% of calls) and confirm retries handle it; log retry counts.
  4. Note spend_micros (spend × 1,000,000) and the occasional currency: USD. Don't fix them yet; record them in notes/sources.md as Week 2 work.
  5. 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?

2. Which extraction method catches hard deletes in a source table?

3. An API returns 429 with Retry-After: 30. What should your client do?

My notes