HTTP, DNS, REST & JSON
The plumbing under every API and LLM call — DNS, HTTP, REST, JSON — climbing to a resilient, reusable client with retries, rate-limit handling, and pagination. This is the SDK, unwrapped.
Building an API, calling an LLM, or deploying a service all use the web's plumbing: DNS, HTTP, REST, JSON. This chapter demystifies each, then climbs to a resilient, reusable API client. One block makes a real call (marked); the rest run offline using stdlib and fakes, so every runnable example works with zero errors.
The words you'll hear (in plain terms):
| Term | What it actually means |
|---|---|
| HTTP | the request/response protocol (methods, status codes, headers). |
| DNS | turns a name (example.com) into an IP address. |
| REST | a convention for APIs over HTTP verbs on resource URLs. |
| JSON | the text format structured data travels in. |
| idempotent | safe to retry (GET); POST is not. |
What you need before starting:
- Python basics;
pip install requestsfor the one live call. - Having called an API helps but isn't required.
- Runnable blocks are offline; the one network call is labeled.
New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.
Learning objectives
- Trace DNS → TCP → TLS → HTTP for a request.
- Use methods, status codes, headers, and JSON from Python.
- Build a resilient client with retries, backoff, and timeouts.
- Standardize a reusable, testable client for a team.
code/df4-how-web-works/. Python runs offline; configs are ready to use.1 · What happens when you hit a URL essential
Type example.com → the browser resolves DNS to an IP, opens a TCP connection, negotiates TLS (for https), sends an HTTP request, and gets a response (status + headers + body). That round trip is the whole web.
This strip shows what really happens in the split second between typing a web address and seeing a page. Read it left to right — each box is one step, and the arrows are the order they happen in. Every API call and every LLM call goes through this same chain.
- Box 1 · Type URL (
example.com) — the human-friendly name you type. A name is not an address the network can use yet, so it must be looked up. - Box 2 · DNS → IP ("the phone book") — DNS is the internet's phone book: it translates the name
example.cominto a numeric IP address (like93.184.216.34) that identifies the actual server machine. - Box 3 · TCP+TLS connect ("the pipe") — your computer opens a connection to that IP. TCP is the reliable pipe the data flows through; TLS is the lock that encrypts it (that's the 's' and the padlock in
https). - Box 4 · HTTP req/resp (
GET / → 200) — now the real conversation: your browser sends an HTTP request ("GET me the home page") and the server sends back a response.200is the status code meaning "OK, here it is". - The arrows show this is a sequence — you can't connect before you know the IP, and you can't send the request before the pipe is open. Name → address → pipe → conversation.
In short: A URL is just a name; the network needs an address (DNS), a pipe (TCP+TLS), then a request/response (HTTP). Every SDK you'll use — including the LLM one — hides these four steps behind a single function call.
2 · HTTP methods & status codes essential
| Method | Means | Idempotent? |
|---|---|---|
| GET | read | yes (safe to retry) |
| POST | create | no |
| PUT | replace | yes |
| PATCH | update part | no |
| DELETE | remove | yes |
| Status | Means |
|---|---|
| 2xx | success (200 ok, 201 created, 204 no content) |
| 3xx | redirect (301 moved, 304 not modified) |
| 4xx | you erred (400 bad, 401 unauth, 403 forbidden, 404 missing, 429 rate-limited) |
| 5xx | server erred (500, 502 bad gateway, 503 unavailable) |
3 · JSON — how data travels essential
JSON is the API lingua franca. Python's json maps it to dicts/lists and back.
json_demo.pyimport json
data = json.loads('{"user":"ava","roles":["dev","admin"],"active":true,"age":30}')
print(data["roles"][0], "| active:", data["active"], "| type:", type(data["active"]).__name__)
print(json.dumps({"ok": True, "items": [1, 2]}, indent=2))
dev | active: True | type: bool
{
"ok": true,
"items": [
1,
2
]
}
JSON is the text format that data travels in between programs — almost every API sends and receives JSON. This tiny program does both directions: it reads a JSON string into Python, then writes Python data back out as JSON.
json.loads(...)means "load from string": it takes the JSON text (the part in quotes) and turns it into normal Python values — a dict here. Now you can look things up like any dictionary.data["roles"][0]reads the first role out of the list, andtype(data["active"]).__name__shows that JSONtruebecame a real Pythonbool— the types are converted for you, not left as text.json.dumps(...)is the reverse — "dump to string": it turns a Python dict back into JSON text.indent=2pretty-prints it with 2-space indentation so it's easy to read.
What the output means: The first line proves the values came through as real Python types (active: True is a bool, not the word "true"). The rest is the same data printed back as neat, indented JSON — note JSON writes it lowercase true.
Try this: Add "score": 9.5 inside the input string and print data["score"] — you'll get a Python float. JSON numbers, strings, lists, and true/false all map to the matching Python types automatically.
4 · Make a real request intermediate
The requests library makes HTTP trivial — this is what the LLM/AWS SDKs do under the hood. This one block hits the network.
live_request.pyimport requests
# NOTE: makes real network calls — run where you have internet.
r = requests.get("https://httpbin.org/get", params={"q": "hello"}, timeout=10)
print("status:", r.status_code, "| args:", r.json()["args"])
r = requests.post("https://httpbin.org/post", json={"name": "Ava"}, timeout=10)
print("server saw:", r.json()["json"])
status: 200 | args: {'q': 'hello'}
server saw: {'name': 'Ava'}
This is the one block that actually touches the internet. It uses the popular requests library to make two real HTTP calls to httpbin.org, a free site that simply echoes back whatever you send — perfect for seeing how requests work.
requests.get(url, params={...}, timeout=10)makes a GET request — GET means "read / fetch something". Theparamsbecome the?q=hellopart of the URL (the query string).timeout=10means "give up after 10 seconds" so your program never hangs forever.r.status_codeis the status code —200means success.r.json()parses the response body (which is JSON) into a Python dict, and["args"]pulls out the query values the server saw.requests.post(url, json={...})makes a POST request — POST means "send / create something". Thejson=argument sends your dict as a JSON body; the echo server hands it right back so you can confirm it arrived.
What the output means: status: 200 confirms the call succeeded, and the echoed args and json show the server received exactly what you sent — proof the round trip worked.
Try this: Change "q": "hello" to your own word and re-run — the server will echo it straight back. This GET-plus-POST pattern is the core of every API you'll ever call.
5 · Status-handling logic intermediate
Branch on the status: 200 use it, 404 is a normal "not found", unexpected 4xx/5xx raise. Here's that logic as a pure function — offline-runnable and exactly what you wrap around a response.
status.pyclass Resp: # stand-in for requests.Response
def __init__(self, status, data=None): self.status_code, self._d = status, data
def json(self): return self._d
def raise_for_status(self):
if self.status_code >= 400: raise RuntimeError(f"HTTP {self.status_code}")
def handle(r):
if r.status_code == 200: return r.json()
if r.status_code == 404: return None # normal outcome
if r.status_code == 429: return "RATE_LIMITED" # back off + retry
r.raise_for_status() # unexpected -> raise
print("200 ->", handle(Resp(200, {"ok": True})))
print("404 ->", handle(Resp(404)))
print("429 ->", handle(Resp(429)))
try: handle(Resp(500))
except RuntimeError as e: print("500 ->", e)
200 -> {'ok': True}
404 -> None
429 -> RATE_LIMITED
500 -> HTTP 500
A response isn't always a success — you must decide what to do based on the status code. This shows that decision logic as one small function. It uses a fake Resp object instead of a real call so it runs anywhere with no internet.
class Respis a pretend response with just astatus_codeand some data — enough to test the logic.raise_for_status()imitates the real library: it throws an error when the status is400or higher.- Inside
handle(r)the code branches on the status:200→ return the data (success);404→ returnNonebecause "not found" is a normal outcome, not a crash;429→ return a signal meaning "you're rate-limited, back off and retry". - Any other bad status falls through to
r.raise_for_status(), which raises — because an unexpected500is a real problem you want to hear about, not swallow silently.
What the output means: Each line shows the branch that fired: 200 gives the data, 404 gives None, 429 gives RATE_LIMITED, and 500 is caught as a raised HTTP 500 error.
Try this: Add a line print(handle(Resp(201))). 201 ("created") isn't handled, so it falls through to raise_for_status — showing you'd want to treat other 2xx codes as success too.
6 · Advanced — retries with exponential backoff advanced
Networks fail transiently. Production clients retry with exponential backoff (and jitter), cap attempts, and only retry retryable errors (timeouts, 5xx, 429 — not a 400). The resiliency habit from py4.
retry.pydef with_retry(fn, tries=4, base=0.01):
for attempt in range(tries):
try:
return fn()
except (TimeoutError, ConnectionError) as e:
if attempt == tries - 1: raise
delay = base * (2 ** attempt) # 0.01, 0.02, 0.04 (+ jitter in prod)
print(f" attempt {attempt+1} failed ({e}); backing off {delay:.3f}s")
calls = {"n": 0}
def flaky():
calls["n"] += 1
if calls["n"] < 3: raise ConnectionError("transient")
return "ok"
print("result:", with_retry(flaky), "after", calls["n"], "attempts")
attempt 1 failed (transient); backing off 0.010s
attempt 2 failed (transient); backing off 0.020s
result: ok after 3 attempts
Networks fail randomly — a call that failed a second ago often works if you just try again. This retry with backoff wrapper re-runs a function a few times, waiting a little longer between each attempt, instead of giving up on the first hiccup.
with_retry(fn, tries=4, base=0.01)takes the function to run (fn), how many attempts to allow, and a base wait time. Thefor attempt in range(tries)loop is the "try again" mechanism.try: return fn()attempts the call; if it works, we return immediately. Theexcept (TimeoutError, ConnectionError)only catches transient network errors — the kind worth retrying. On the last attempt itraises so failures aren't hidden.delay = base * (2 ** attempt)is exponential backoff: the wait doubles each time — 0.01, then 0.02, then 0.04 seconds — so you don't hammer a struggling server. (Real code adds a little random "jitter" too.)- The
flaky()test function fails the first two times, then succeeds — a controlled way to prove the retry loop actually recovers.
What the output means: You see two "attempt failed … backing off" lines (the doubling delays), then result: ok after 3 attempts — the third try succeeded, exactly as designed.
Try this: Change if calls["n"] < 3 to < 5. Now it fails more times than tries allows, so the retries run out and the error is raised — showing why capping attempts matters.
7 · Advanced — rate limiting & pagination advanced
Two realities of real APIs: they rate-limit you (respect 429 + Retry-After), and they paginate large results (follow next cursors/pages). Handle both or you'll get partial data and bans.
pagination.py# A fake paginated endpoint: 3 pages of results.
PAGES = {
None: {"items": [1, 2], "next": "p2"},
"p2": {"items": [3, 4], "next": "p3"},
"p3": {"items": [5], "next": None},
}
def fetch_page(cursor): return PAGES[cursor]
def fetch_all():
items, cursor = [], None
while True:
page = fetch_page(cursor)
items += page["items"]
cursor = page["next"]
if cursor is None: break
return items
print("all items across pages:", fetch_all())
all items across pages: [1, 2, 3, 4, 5]
Big result sets don't arrive all at once — APIs send them in pages, and each page tells you where the next one is. This shows how to follow those pages until there are none left, using a fake endpoint so it runs offline.
PAGESis a fake API: each page holds someitemsplus anextpointer (a cursor) to the following page. The last page'snextisNone, meaning "you're done".fetch_all()starts with an empty list andcursor = None(the first page). Thewhile Trueloop fetches a page, adds its items withitems += page["items"], then moves the cursor forward topage["next"].if cursor is None: breakis the stop condition — when a page reports no next cursor, the loop ends and we return everything collected. This is exactly how you'd walk a real API's pages without missing or repeating data.
What the output means: all items across pages: [1, 2, 3, 4, 5] — the loop stitched three separate pages into one complete list.
Try this: Add a fourth page "p3": {"items": [5], "next": "p4"} and a "p4" page ending in None. The same loop follows the extra page automatically — you never hard-code how many pages there are.
8 · Professional — a reusable client class professional
Teams don't scatter requests.get everywhere — they wrap an API in a client class with base URL, auth, timeouts, and error handling in one place (SD3 SOLID), with an injectable session so it's testable without a network.
api_client.pyclass ApiClient:
def __init__(self, base_url, token=None, timeout=10, session=None):
self.base_url = base_url.rstrip("/"); self.timeout = timeout
self.session = session # inject -> testable
if token and session: session.headers["Authorization"] = f"Bearer {token}"
def get(self, path, **params):
r = self.session.get(f"{self.base_url}/{path.lstrip('/')}",
params=params, timeout=self.timeout)
r.raise_for_status(); return r.json()
class FakeResp:
status_code = 200
def json(self): return {"user": "ava"}
def raise_for_status(self): pass
class FakeSession:
headers = {}
def get(self, url, params=None, timeout=None):
print("would GET:", url); return FakeResp()
client = ApiClient("https://api.example.com", token="secret", session=FakeSession())
print("result:", client.get("users/1"))
would GET: https://api.example.com/users/1
result: {'user': 'ava'}
Instead of scattering requests.get calls all over a codebase, teams wrap an API in one client class that keeps the base URL, auth token, and timeout in a single place. This is the same shape as the official LLM and AWS SDKs.
__init__stores the settings once:base_url(with any trailing slash stripped), atimeout, and asession. If atokenis given, it's added as anAuthorization: Bearer …header so every request is authenticated automatically.- The
sessionis injected (passed in) rather than created inside — that's the key trick. In production you pass a real network session; in a test you pass a fake one, so the class is testable without any internet. get(self, path, **params)builds the full URL, calls the session'sget, thenr.raise_for_status()turns any error status into an exception before returningr.json()— so callers always get clean data or a clear failure.FakeSessionandFakeRespare stand-ins:FakeSession.getjust prints the URL and returns a canned{"user": "ava"}. That's how you prove the client's logic works with zero network calls.
What the output means: would GET: https://api.example.com/users/1 shows the URL the client built, and result: {'user': 'ava'} is the parsed data — all from the fake, no internet needed.
Try this: Add a post method that mirrors get but calls self.session.post. One class now owns every call's URL, auth, and error handling — the whole point of a reusable client.
9 · Tech-lead — standardize reliability tech-lead
This IS the LLM SDK unwrapped: every client.messages.create(...) is an HTTP POST with an auth header + JSON body, status-checked, retried. A lead defines one resilient client (retries, timeouts, logging, rate-limit handling) so every integration inherits the same reliability.
ApiClient and the team uses it everywhere, every external call gets retries, timeouts, and consistent error handling for free. That's how you make an entire codebase robust with one class.Exercise DF4.1 — Build a resilient client
Context: The capstone of the chapter is a single client that behaves well against a real, flaky network: it retries the right failures, times out, paginates, and — crucially — is fully testable without a network.
Your task: Wrap a public API in an ApiClient with retries + backoff, a timeout, 404→None and 429→retry handling, pagination, and an injectable session so you can unit-test it with a fake.
Requirements:
- Add retries with backoff plus a request timeout
- Map 404 to
Noneand treat 429 as a retryable case - Page through a multi-page response until it's exhausted
- Inject the session/transport so the network seam is swappable for tests (TQ3)
- Prove the retry logic with a fake that fails twice then succeeds, and page through a multi-page fake
💡 Hint: Design against a fake session first: if your client can be fully exercised offline — flaky retries and pagination included — the real requests.Session is just a drop-in.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every HTTP response carries a status code, and its first digit tells you the whole story: success, redirect, your mistake, or the server's. Knowing which codes are worth retrying is the foundation of every resilient client.
Your task: Without any network, write a function that maps an HTTP status code to its class (informational / success / redirect / client-error / server-error) and whether a client should retry. Test it on 200, 301, 404, and 503.
Requirements:
- Derive the class from the first digit (e.g.
code // 100) - Return a retryable flag that is true only for transient codes (429, 500, 502, 503, 504)
- Show 404 classed as client-error and not retryable
- Show 503 classed as server-error and retryable
- Print the classification for each of 200, 301, 404, 503
💡 Hint: The mental model: 4xx means you got the request wrong (don't retry, fix it); 5xx means the server failed (retrying may help).
Show solution
Pure Python, no network needed:
def classify(code):
klass = {1: "informational", 2: "success", 3: "redirect",
4: "client-error", 5: "server-error"}[code // 100]
retryable = code in (429, 500, 502, 503, 504)
return klass, retryable
for c in (200, 301, 404, 503):
print(c, classify(c))
# 200 ('success', False)
# 301 ('redirect', False)
# 404 ('client-error', False) -> your bug, do not retry
# 503 ('server-error', True) -> transient, retry with backoff
4xx means you got it wrong (fix the request); 5xx means the server failed (retrying may help).
Context: Data travels between services as JSON text, and JSON only knows a handful of types. Understanding what survives a round-trip — and what doesn't — prevents a whole class of serialization bugs.
Your task: Build a small dict, serialize it to a JSON string, parse it back, and confirm the round-trip preserves types — then show one gotcha: JSON has no tuple or datetime.
Requirements:
- Serialize with
json.dumpsand parse back withjson.loads - Confirm
str/list/bool/floatsurvive the round-trip - Show that a
datetime/dateraisesTypeErroruntil converted - Note that tuples come back as lists
- State the fix: convert dates/sets/bytes to JSON-native types before sending
💡 Hint: Try serializing a value JSON can't represent and catch the TypeError — the error message itself points you at what needs converting first (e.g. isoformat()).
Show solution
import json
from datetime import date
payload = {"user": "ada", "roles": ["admin", "dev"], "active": True, "score": 3.5}
text = json.dumps(payload) # dict -> JSON string (what goes on the wire)
back = json.loads(text) # JSON string -> dict
print(back == payload) # True: str/list/bool/float survive
# gotcha: no native tuple or datetime -- you must convert first
try:
json.dumps({"when": date.today()})
except TypeError as e:
print("needs conversion:", e) # serialize as when.isoformat()
Tuples come back as lists; dates/sets/bytes must be converted to JSON-native types before sending.
Context: Talking to a live API is where theory meets the network's messiness: the call can come back with a bad status, or never come back at all. A real client must handle both without hanging or crashing.
Your task: Call a public API with requests, check the status, and parse the JSON — handling both failure modes: a non-2xx response and a network/timeout error.
Requirements:
- Issue the request with
requests.getand a timeout set - Turn 4xx/5xx into an exception with
r.raise_for_status() - Catch
requests.HTTPErrorfor bad statuses and report the code - Catch
requests.RequestExceptionfor DNS/timeout/connection errors - Return the parsed JSON on success,
Noneon handled failure
💡 Hint: Always pass a timeout — without one a hung server hangs your program forever, which is the single most common production footgun here.
Show solution
Needs network + requests (pip install requests). Uses the free httpbin echo service:
import requests
def get_json(url, timeout=5):
try:
r = requests.get(url, timeout=timeout)
r.raise_for_status() # turns 4xx/5xx into an exception
return r.json()
except requests.HTTPError as e:
print("bad status:", e.response.status_code)
except requests.RequestException as e:
print("network error:", e) # DNS, timeout, connection refused
return None
data = get_json("https://httpbin.org/json")
print(data and list(data.keys()))
Always set a timeout — without it a hung server hangs your program forever.
Context: Transient failures are normal at scale, so a good client retries — but only on the right failures, with growing delays and jitter so a fleet of clients doesn't retry in lockstep. Modelling it offline keeps the logic testable.
Your task: Wrap a flaky call so it retries only on retryable failures (429/5xx and network errors), backing off 1s, 2s, 4s… with jitter, and gives up after N attempts — modelled offline with a fake that fails twice then succeeds.
Requirements:
- Retry up to a configurable number of attempts, then raise after the last one
- Grow the delay exponentially (
base * 2**i) and add random jitter - Only retry retryable failures — never a 4xx, whose request is itself wrong
- Make it runnable offline by injecting/stubbing the
sleepso it prints instead of waiting - Demonstrate with a fake that fails twice then returns success
💡 Hint: Jitter (the small random add on each delay) is what prevents a thundering herd of clients all retrying at the same instant.
Show solution
Runnable offline — the backoff sleep is stubbed so it prints instead of waiting:
import random
def with_retries(call, attempts=4, base=1.0, sleep=print):
for i in range(attempts):
ok, result = call()
if ok:
return result
if i == attempts - 1:
raise RuntimeError(f"gave up after {attempts} attempts")
delay = base * (2 ** i) + random.uniform(0, 0.3) # jitter avoids thundering herd
sleep(f"retry {i+1} in {delay:.2f}s")
# fake service: fails twice (transient), then returns 200
_calls = {"n": 0}
def flaky():
_calls["n"] += 1
if _calls["n"] < 3:
return False, 503
return True, {"data": "ok"}
print(with_retries(flaky)) # prints two backoff lines, then {'data': 'ok'}
Jitter (the random add) prevents many clients retrying in lockstep. Never retry a 4xx — the request itself is wrong.
Context: Once several call sites hit the same API, you want base URL, auth, timeout, and retries configured in one place. Making the network seam swappable is what lets you unit-test the client without touching the network.
Your task: Wrap a base URL, default headers, timeout, and retries into a small ApiClient class so callers just say client.get('/users/1') — keeping the transport swappable so it's testable offline.
Requirements:
- Store base URL, default headers (Accept, optional Bearer auth), and timeout on the instance
- Expose a simple
get(path)that joins path to base and returns parsed JSON - Raise on error statuses (≥ 400) instead of returning a bad body
- Inject the transport (a callable) so a fake can stand in for the network
- Demonstrate the client working against an offline test double
💡 Hint: Centralizing base URL, auth, and timeout means every call is consistent and you change reliability policy in exactly one place — pass a real requests.Session in prod.
Show solution
The transport is injected, so the class is unit-testable offline; in production pass a real requests.Session.
class ApiClient:
def __init__(self, base_url, token=None, timeout=5, transport=None):
self.base = base_url.rstrip("/")
self.headers = {"Accept": "application/json"}
if token:
self.headers["Authorization"] = f"Bearer {token}"
self.timeout = timeout
self.transport = transport # callable(method,url,headers,timeout)
def get(self, path):
url = f"{self.base}{path}"
status, body = self.transport("GET", url, self.headers, self.timeout)
if status >= 400:
raise RuntimeError(f"{status} for {url}")
return body
# offline test double
def fake(method, url, headers, timeout):
assert headers["Authorization"] == "Bearer t0k"
return 200, {"id": 1, "name": "ada"}
c = ApiClient("https://api.example.com", token="t0k", transport=fake)
print(c.get("/users/1")) # {'id': 1, 'name': 'ada'}
Centralizing base URL, auth and timeout means every call is consistent and you change reliability policy in one place.
Context: Ten services each hand-rolling HTTP calls with inconsistent timeouts and no retries is how cascading outages start. As tech lead you ship one blessed way to make a call so reliability is enforced by code, not by a wiki page.
Your task: Ten services hand-roll their own HTTP with inconsistent timeouts and no retries. Define the org standard and show how a shared, configured session enforces it everywhere.
Requirements:
- Ship a single factory (e.g.
make_session) teams import instead of building bare sessions - Mount a
Retry/HTTPAdapterthat retries on 429/5xx with backoff - Enforce a default
timeouton every outbound call - Retry only idempotent methods; do not auto-retry POSTs (double-write risk)
- Make the point that a shared library — not documentation — is what actually enforces the standard
💡 Hint: Wrap the session's request to inject a default timeout, and scope retries to idempotent methods so a retried non-idempotent write can't silently double-apply.
Show solution
Ship one blessed factory that bakes in the org defaults; teams import it instead of constructing bare sessions.
import requests
from requests.adapters import HTTPAdapter
try:
from urllib3.util.retry import Retry
except ImportError: # very old urllib3
from requests.packages.urllib3.util.retry import Retry
def make_session(total=3, backoff=0.5, timeout=5):
s = requests.Session()
retry = Retry(total=total, backoff_factor=backoff,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(["GET", "PUT", "DELETE"]))
adapter = HTTPAdapter(max_retries=retry)
s.mount("https://", adapter)
s.mount("http://", adapter)
s.request = _with_default_timeout(s.request, timeout) # enforce timeout
return s
def _with_default_timeout(fn, timeout):
def wrapper(method, url, **kw):
kw.setdefault("timeout", timeout)
return fn(method, url, **kw)
return wrapper
The standard: every outbound call has a timeout; idempotent methods retry with backoff on 429/5xx; non-idempotent POSTs do not auto-retry (risk of double-writes). Enforcing it through a shared library — not a wiki page — is what actually stops the next cascade.
✓ Checkpoint — you can move on when you can…
- Trace DNS→TCP→TLS→HTTP; use methods/status codes/JSON.
- Handle statuses, retries+backoff, rate limits, and pagination.
- Build a testable client with a fake session.
- Standardize a reliable client for the whole team.
Knowledge check check yourself
What four steps happen between typing a URL and getting a response, in order?
Show answer
In a resilient client, why use exponential backoff on retries, and which failures should you actually retry?