Every SDK you've ever loved is a lie about the API underneath — a beautiful, deliberate lie that hides the pagination, the retries, and the 37 error codes nobody memorized. This post builds that lie on purpose: a real client for a real API, then the debugging method for the night someone else's API lies to you.
Assumes: Posts 1–3 (REST, auth, pagination/retries). Installs: pip install httpx. All code below runs — including the flaky mock server, which is built first so you can watch the SDK earn its keep.
Tuesday, 11:20 AM. Maria's deputy drops into your desk: "The field crew's new routing service has an API. I need you to pull tomorrow's crew assignments into CityOps by Thursday. Here's the docs link."
You open the docs. Forty pages. Three auth schemes (only one works). Pagination that uses page in one endpoint and cursor in another. Error codes in two languages — one of them not documented.
Your instinct: write requests.get(...) calls inline, in the pipeline script, Thursday deadline, what could go wrong.
What goes wrong: everything, eventually, at 2 AM, when the vendor changes a field name and five copy-pasted call sites break five different ways.
What you build instead: one small SDK — a Python module that turns that API into clean methods your teammates can call without reading the docs. Then you write the debugging method for the night the vendor's API lies to you anyway.
The minimum concept
An SDK (software development kit, though here it's just a client library) is a boundary: vendor weirdness on one side, your team's clean method calls on the other. Every good SDK does four jobs:
| Job | What it hides | What it exposes |
|---|---|---|
| Authentication | Token refresh, header formats, key rotation | client = CrewAPI(key="...") |
| Pagination | Cursors, page tokens, "is there more?" | for crew in client.crews(): — a plain iterator |
| Retries | 429s, 5xxs, backoff math | Nothing — it just works, until it can't |
| Error translation | Vendor error codes, two languages | CrewNotFound, RateLimited — exceptions you'd write yourself |
The design principle for the whole post: hide the wire, not the error. The SDK may hide pagination mechanics and retry math, but it must never hide what went wrong — every failure surfaces as a specific, actionable exception with the vendor's original detail attached.
The SDK contract
- One entry point:
CrewAPI(key=...)— configuration lives here, nowhere else - Methods return domain objects (
Crew,Assignment), not raw JSON dicts - Pagination is an iterator — the caller never sees a cursor
- Failures raise typed exceptions, each carrying the vendor's original error payload
- Nothing in the SDK prints, logs credentials, or retries forever
Build it: the flaky vendor, first
Before the SDK, the thing it wraps: a mock of the routing vendor's API, with the vendor's real habits — cursor pagination on one endpoint, page-number on another, a 429 when you ask too fast, and an error body in two languages. Run it, then build against it:
"""Mock vendor API: the routing service's real habits, faithfully reproduced."""
import json, time, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
CREWS = [
{"id": f"CRW-{i:03d}", "name": n, "zone": z, "shift": s}
for i, (n, z, s) in enumerate([
("Alpha", "north", "day"), ("Bravo", "south", "day"),
("Charlie", "north", "night"), ("Delta", "east", "day"),
("Echo", "west", "night"), ("Foxtrot", "south", "night"),
("Golf", "east", "night"), ("Hotel", "west", "day"),
], start=1)
]
# one crew per page is slow on purpose: it forces the iterator to earn its keep
PAGE_SIZE = 1
class VendorHandler(BaseHTTPRequestHandler):
# the vendor rate-limits: more than 3 requests/second -> 429
_hits = []
def _throttled(self):
now = time.time()
VendorHandler._hits = [h for h in VendorHandler._hits if now - h < 1.0]
VendorHandler._hits.append(now)
return len(VendorHandler._hits) > 3
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _auth_ok(self):
return self.headers.get("X-API-Key") == "crew-secret-key"
def do_GET(self):
if not self._auth_ok():
# note the vendor's bilingual habit: message AND nachricht
self._send(401, {"error": {"code": "AUTH_001",
"message": "Invalid API key",
"nachricht": "Ungültiger API-Schlüssel"}})
return
if self._throttled():
self._send(429, {"error": {"code": "RATE_001",
"message": "Too many requests", "retry_after": 1}})
return
parts = urlparse(self.path)
qs = parse_qs(parts.query)
if parts.path == "/crews":
# cursor pagination here...
cursor = int(qs.get("cursor", ["0"])[0])
page = CREWS[cursor:cursor + PAGE_SIZE]
nxt = cursor + PAGE_SIZE if cursor + PAGE_SIZE < len(CREWS) else None
self._send(200, {"data": page, "next_cursor": nxt})
elif parts.path == "/assignments":
# ...but page-number pagination here. vendors are like this.
page_n = int(qs.get("page", ["1"])[0])
start = (page_n - 1) * 2
chunk = ASSIGNMENTS[start:start + 2]
self._send(200, {"items": chunk,
"page": page_n,
"total_pages": (len(ASSIGNMENTS) + 1) // 2})
else:
self._send(404, {"error": {"code": "NOT_FOUND",
"message": f"Unknown endpoint {parts.path}",
"nachricht": f"Unbekannter Endpunkt {parts.path}"}})
ASSIGNMENTS = [
{"id": f"ASN-{i:03d}", "crew_id": c, "route": r, "date": d}
for i, (c, r, d) in enumerate([
("CRW-001", "R-101", "2026-10-08"), ("CRW-002", "R-102", "2026-10-08"),
("CRW-003", "R-103", "2026-10-08"), ("CRW-001", "R-104", "2026-10-09"),
("CRW-004", "R-105", "2026-10-09"),
], start=1)
]
def run(port=8931):
srv = HTTPServer(("127.0.0.1", port), VendorHandler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv
Build it: the SDK
Now the client. Read it as a series of deliberate choices — each one answers a question the vendor's docs raised:
"""crew_sdk.py — the SDK we wish the vendor had shipped."""
import time
import httpx
from dataclasses import dataclass
# --- domain objects: callers get Crews, not dicts ---
@dataclass
class Crew:
id: str
name: str
zone: str
shift: str
@dataclass
class Assignment:
id: str
crew_id: str
route: str
date: str
# --- typed errors: hide the wire, not the error ---
class CrewAPIError(Exception):
"""Base: every failure carries the vendor's original payload."""
def __init__(self, message, *, vendor_code=None, payload=None):
super().__init__(message)
self.vendor_code = vendor_code
self.payload = payload
class AuthError(CrewAPIError): pass
class NotFoundError(CrewAPIError): pass
class RateLimitedError(CrewAPIError): pass
class ServerError(CrewAPIError): pass
def _translate(status, payload):
"""Vendor codes -> typed exceptions. Unknown codes stay loud, not silent."""
err = (payload or {}).get("error", {}) or {}
code = err.get("code", "UNKNOWN")
# bilingual messages: prefer English, fall back gracefully
msg = err.get("message") or err.get("nachricht") or "Unknown vendor error"
if status == 401:
raise AuthError(msg, vendor_code=code, payload=payload)
if status == 404:
raise NotFoundError(msg, vendor_code=code, payload=payload)
if status == 429:
raise RateLimitedError(msg, vendor_code=code, payload=payload)
if 500 <= status < 600:
raise ServerError(msg, vendor_code=code, payload=payload)
raise CrewAPIError(f"Unexpected status {status}: {msg}",
vendor_code=code, payload=payload)
class CrewAPI:
def __init__(self, key, base_url="http://127.0.0.1:8931", timeout=10):
self._http = httpx.Client(
base_url=base_url, timeout=timeout,
headers={"X-API-Key": key})
def _get(self, path, params=None, *, _tries=0):
resp = self._http.get(path, params=params)
if resp.status_code == 429 and _tries < 3:
# honor the vendor's own hint when it gives one
wait = resp.json().get("error", {}).get("retry_after", 1)
time.sleep(wait)
return self._get(path, params=params, _tries=_tries + 1)
if resp.status_code >= 400:
_translate(resp.status_code, resp.json())
return resp.json()
def crews(self):
"""Yield every crew. Cursor pagination is our problem, not the caller's."""
cursor = 0
while True:
page = self._get("/crews", {"cursor": cursor})
for row in page["data"]:
yield Crew(**row)
cursor = page["next_cursor"]
if cursor is None:
return
def assignments(self, page_size_note=""):
"""Page-number pagination, same iterator shape. Two vendor styles, one API."""
page_n = 1
while True:
page = self._get("/assignments", {"page": page_n})
for row in page["items"]:
yield Assignment(**row)
if page_n >= page["total_pages"]:
return
page_n += 1
def close(self):
self._http.close()
The whole point, in one session — the caller's code never mentions cursors, pages, 429s, or German:
client = CrewAPI(key="crew-secret-key")
names = [c.name for c in client.crews()]
print(names)
# ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel']
asns = [(a.id, a.crew_id, a.route) for a in client.assignments()]
print(len(asns), asns[0])
# 5 ('ASN-001', 'CRW-001', 'R-101')
client = CrewAPI(key="wrong-key")
try:
list(client.crews())
except AuthError as e:
print(type(e).__name__, "|", e, "| vendor code:", e.vendor_code)
# AuthError | Invalid API key | vendor code: AUTH_001
Eight crews, five assignments, and a wrong key that raises AuthError with the vendor's code attached — not a raw 401 dict, not a bare Exception. The bilingual error body never reaches the caller; the meaning does.
SDK design checklist
- One constructor takes all configuration — keys, URLs, timeouts
- Methods return domain objects, never raw response dicts
- Every paginated endpoint becomes an iterator
- Every vendor error becomes a typed exception with the original payload attached
- Retries live inside
_get, bounded (3 tries here), honoringretry_after - No printing, no logging of secrets, no unbounded loops
Break it: the debugging method
Thursday, 2 AM. The pipeline fails: CrewNotFound — no wait, there is no CrewNotFound; the SDK raises NotFoundError, and that's the first clue the method works. The vendor changed something. Here's the method, in order:
1. Read the exception, not the tea leaves. The SDK gives you e.vendor_code and e.payload. Print the payload first — the vendor's nachricht field once contained the actual reason while the English message said "error":
try:
list(client.crews())
except CrewAPIError as e:
print("vendor code:", e.vendor_code)
print("payload:", e.payload)
2. Reproduce outside your code. One curl with the same key, same endpoint. If curl fails too, it's the vendor, not you — and you now have a minimal reproduction to paste into the ticket:
curl -s -H "X-API-Key: crew-secret-key" \
"http://127.0.0.1:8931/crews?cursor=0" | head -c 400
3. Diff against what worked. The vendor changed a field name at 2 AM? Your SDK's dataclass is the contract: Crew(**row) throws TypeError on unexpected keys, which tells you exactly which field changed. That strictness is deliberate — silent .get() chains would have passed None downstream instead.
4. Check the boring causes first. Clock skew (expiry validation), key rotation (did ops rotate the secret?), quota resets (monthly caps renew at midnight UTC, not local). The debugging checklist in the box below is ordered by how often each cause actually fires.
The 2 AM debugging checklist
- Read
e.vendor_codeande.payload— the SDK already translated; don't re-translate - Reproduce with curl — one command, same key, same endpoint
- Check the vendor status page and changelog before theorizing
- Diff the failing payload against a known-good one (dataclass
TypeErrornames the field) - Clock skew → key rotation → quota → vendor deploy — in that order
- Write the ticket while it's fresh: payload, curl repro, timestamps in UTC
The ticket that gets fixed
Vendors fix tickets they can reproduce. Everything the method produced above goes into it:
Subject: /crews returns 404 for cursor=0 after 2026-10-02 deploy
Endpoint: GET /crews?cursor=0
Key type: server key (last 4: ...9f31)
First seen: 2026-10-02 02:14 UTC
Last known good: 2026-10-01 23:58 UTC
Response body:
{"error": {"code": "NOT_FOUND", "message": "Unknown endpoint /crews",
"nachricht": "Unbekannter Endpunkt /crews"}}
Minimal repro:
curl -s -H "X-API-Key: REDACTED" \
"http://127.0.0.1:8931/crews?cursor=0"
Expected: 200 with {"data": [...], "next_cursor": ...}
Actual: 404, consistently, since 02:14 UTC
No theories, no stack traces from your internals, no "it worked yesterday" — a timestamped, reproducible, minimal report. This is also why the SDK keeps the vendor payload on the exception: the ticket writes itself.
Productionize: the SDK as a dependency
Thursday's script becomes a team's dependency. That changes the obligations:
Version it. Pin the vendor API version in the base URL or a header the moment the vendor offers one — the 2 AM field rename is a versioning story, and unversioned clients inherit every vendor change instantly.
Bound everything. The SDK above retries 3 times and times out at 10 seconds — because Post 3's lesson applies to clients too. An SDK without timeouts is a pipeline that hangs instead of failing.
Log the shape, not the secret. Debug logging shows the endpoint, the status, the vendor code — never the key. The one time you log a key is the day it ends up in a log aggregator with a 90-day retention.
Test against the mock. The flaky vendor server at the top of this post isn't just a demo prop — it's the seed of a test fixture. Vendor behavior (429s, bilingual errors, two pagination styles) is exactly what you pin in tests, so the next vendor change breaks a test before it breaks Thursday.
Explain it to the customer
You, Thursday: "The crew assignments flow into CityOps every morning before standup. Your team never touches the vendor's docs — one Python module, client.assignments(), and it handles the pagination and the rate limits. When the vendor changes something at 2 AM, we get a specific error with their original detail attached, a one-command curl repro, and a ticket they can actually fix."
Maria's deputy: "And when they change the API again?"
You: "The strict dataclass throws on the changed field, a test pins the vendor's behavior, and the version is pinned. We find out from a test, not from Thursday."
Useful later
- Auto-generated clients (OpenAPI Generator) — when the vendor ships a real spec
- Async clients (
httpx.AsyncClient) — when one process fans out to many vendors - Response caching — when the vendor's data changes slower than you poll it
- Webhook receivers — when the vendor pushes instead of you pulling (Post 4)
Myth: "A good SDK hides everything about the API."
Reality: A good SDK hides the mechanics and amplifies the meaning. Pagination mechanics: hidden. Retry math: hidden. What went wrong and what the vendor actually said: louder than the raw API ever made it. Hide the wire, not the error.
Post 3's principle was be a polite client. This post's: hide the wire, not the error.
Field check
- Your SDK's
crews()iterator works, butassignments()silently returns nothing when the vendor adds a new required query param. Where should the failure surface, and as what? - The vendor announces API v2 with a renamed
crew_idfield. List every place in the SDK that must change, and one place that must not. - It's 2 AM and
list(client.crews())raisesRateLimitedErrorin a loop forever. What bounded it, and what would you check first? - Write the ticket for a vendor 500 whose English message says "error" and whose
nachrichtsays "Datenbank vorübergehend nicht verfügbar". What goes in, what stays out?
What good answers look like
1. In _get, as a translated typed exception — the vendor would return 400/422 for the missing param, and _translate turns it into a loud CrewAPIError rather than an empty iterator. Silent empty results are the worst failure mode; the SDK's job is to make missing data loud. 2. Must change: the Assignment dataclass field, the assignments() row mapping, and the version pin (base URL or header). Must not: the exception hierarchy or _translate — error semantics didn't change, only a field name. Callers' code (a.crew_id) changes only if you rename the dataclass field; a good SDK absorbs the rename and keeps the old attribute working. 3. Bounded by _tries < 3 in _get — it cannot loop forever. Check first whether the 429s come with retry_after (honored) or whether the caller's own loop re-creates the client per iteration (fresh _tries each time) — the bound that saves you is per-call, so a per-iteration client defeats it. 4. In: endpoint, key type (never the key), first-seen and last-known-good timestamps in UTC, the full bilingual payload (the nachricht is the actual diagnosis: "database temporarily unavailable"), minimal curl repro with REDACTED key, expected vs actual. Out: theories about their database, your internal stack traces, "it worked yesterday".