Networking & APIs
Almost no data starts on your machine. It lives on a database across the room, a vendor's API across the country, a service across the world — and getting it to you means sending bytes over a network you don't control. This chapter explains how two machines actually talk, how HTTP and REST APIs work up close, where your secrets should live, and the single most important fact about networks: they always fail eventually, so your code has to expect it.
The client/server model
Strip away every buzzword and networking is two programs exchanging messages. One side asks; the other answers. The asker is the client (your script, your browser, your pipeline). The answerer is the server (an API, a database, a web service). A request goes out, a response comes back. That's the whole shape, and everything in this chapter is a detail hung on it.
A few things to notice now, because they shape everything later. The client always starts the conversation — the server can't push to you out of nowhere; it answers when asked. Each request is a round trip: out and back, with the network's delay paid both ways. And the same server happily talks to thousands of clients at once, which is exactly why a server you depend on might be slow or unavailable through no fault of yours.
When you ran psql against Postgres in Course 2, psql was the client and the database was the server — same model, different protocol. A web browser fetching a page is a client; the website is a server. Once you see "request out, response back" everywhere, networking stops feeling mysterious.
The network stack, in plain language
For a request to find the right machine and arrive intact, several layers each do one job. You rarely touch them directly, but knowing what they do explains a lot of slowness and a lot of errors.
IP addresses & ports. Every machine on a network has an IP address — a number like 93.184.216.34 that says which machine. A port (like 443 or 5432) says which program on that machine, since one server runs many. Address + port together name an exact endpoint to talk to.
DNS — names to addresses. Humans can't remember IP numbers, so we use names like api.example.com. The Domain Name System is the network's phone book: before any request, your machine asks DNS to turn the name into an IP address. (This lookup is itself a tiny network round trip — and a surprisingly common source of "why is the first request slow?")
TCP vs UDP. Once you have an address and port, you need a way to move bytes. TCP is the reliable, ordered channel: it guarantees every byte arrives, in order, retransmitting anything lost — and almost everything you'll touch (HTTP, Postgres, Kafka) rides on it. UDP trades those guarantees for speed: it just fires packets and doesn't care if some are lost (think live video) — you'll meet it rarely.
Bandwidth is how much data fits through the pipe per second (a wide road). Latency is how long one round trip takes (the length of the road). They're independent — a fat pipe across the world can still have high latency. For data work the killer is latency on many small requests: if each round trip costs 50 ms and you make 10,000 of them one at a time, that's 500 seconds of pure waiting, no matter how fat your pipe is. The fix is almost never "more bandwidth" — it's "fewer round trips": batch, paginate in bigger pages, or fetch in parallel. Keep this in your pocket; it explains half the slow pipelines you'll ever debug.
HTTP up close
HTTP is the language clients and servers speak on the web — and the language nearly every API you'll pull from speaks. It's just structured text. A request has four parts, and so does a response. Here's a request:
GET /v1/orders?page=2 HTTP/1.1 ← method · URL/path · version
Host: api.example.com ← headers (metadata, key: value)
Authorization: Bearer abc123
Accept: application/json
(no body for a GET — a POST would put JSON here)And the response that comes back:
HTTP/1.1 200 OK ← status line (code + reason)
Content-Type: application/json ← headers
Content-Length: 1843
{ "orders": [ … ], "next_page": 3 } ← body (the actual data)The method states your intent — what you want done. There are a handful you'll use constantly:
| Method | Means | Changes data? |
|---|---|---|
GET | Read / fetch a resource. Your bread and butter for pulling data. | No (safe) |
POST | Create something new, or submit a request that does work. | Yes |
PUT | Replace a resource at a known location (set it to this). | Yes |
DELETE | Remove a resource. | Yes |
The status code in the response tells you, at a glance, how it went. You don't need all of them — you need the families and a few specifics:
| Code | Family — meaning | What to do |
|---|---|---|
200 / 201 | 2xx Success — it worked (201 = created). | Proceed; parse the body. |
400 | 4xx — you sent a bad request. | Fix your request; retrying won't help. |
401 / 403 | Not authenticated / not allowed. | Check your key/token & permissions. |
404 | That resource doesn't exist. | Check the URL. |
429 | Too many requests — you're rate-limited. | Slow down; back off and retry later. |
500 / 503 | 5xx — the server broke or is overloaded. | Not your fault; retry with backoff. |
The single rule worth memorizing: 4xx is your problem, 5xx is theirs. Retrying a 4xx (you sent something wrong) just repeats the mistake; retrying a 5xx or 429 (their side) often succeeds once they recover. We'll lean on that in the reliability section.
Headers are the metadata around the message: who you are (Authorization), what format you'll accept (Accept), what format the body is (Content-Type), and often rate-limit counters telling you how many requests you have left. They're easy to overlook and frequently hold the clue when something behaves oddly.
REST APIs — your most common external source
Most third-party data you'll ever pull — payment records, weather, CRM contacts, GitHub stats — arrives through a REST API: a set of HTTP endpoints, each a URL representing a kind of resource, that you GET to read and usually get back as JSON. A vendor publishes one, hands you a key, and your pipeline becomes a client politely asking for pages of data. Two patterns dominate, and you must handle both.
Pagination. No sane API returns a million rows in one response. Instead it returns a page — say 100 records — plus a hint for getting the next one (a next_page number, a cursor token, or a Link header). You loop: fetch a page, process it, ask for the next, and stop when the hint runs out. Forgetting to paginate is the classic rookie bug — you grab page 1 and quietly miss 99% of the data.
Rate limits. APIs cap how fast you may call them (e.g. 60 requests/minute). Exceed it and you get a 429 Too Many Requests, often with a Retry-After header saying how long to wait. The right response is to slow down and back off — not to hammer harder. We'll do exactly that below.
Let's pull real data. This calls a free public API that needs no key, parses the JSON, and walks a few pages.
import requests
# A public, no-auth API. Each "post" is a small JSON object.
resp = requests.get(
"https://jsonplaceholder.typicode.com/posts",
params={"_page": 1, "_limit": 5}, # ask for page 1, 5 items
timeout=10, # never wait forever (see below)
)
resp.raise_for_status() # turn a 4xx/5xx into an error
data = resp.json() # parse the JSON body into Python
print("status:", resp.status_code)
print("items on this page:", len(data))
print("first title:", data[0]["title"][:40])status: 200, then items on this page: 5, then the first post's title (truncated). If you get a requests.exceptions.ConnectionError, check your internet; if raise_for_status throws, the status line in the message tells you whether it was your fault (4xx) or theirs (5xx). You just did the core loop of every API pull: request, check status, parse JSON.
Every API differs in how it paginates, names its parameters, and limits you. The vendor's API docs are not optional reading — they tell you the page size, the rate limit, the auth scheme, and the shape of the JSON. Five minutes there saves an hour of guessing.
Authentication & where secrets go
Most real APIs need to know who you are before they hand over data. That proof travels in a header (or sometimes a query parameter), and it comes in a few common flavors:
| Type | How it works | Looks like |
|---|---|---|
| API key | A single long secret string identifying your account. | ?api_key=… or a header |
| Bearer token | A token you send on every request to prove identity. | Authorization: Bearer … |
| OAuth 2.0 | You exchange credentials for a short-lived access token, then send that. Common for user-account access; the token expires and is refreshed. | a flow that yields a bearer token |
| Basic auth | A username:password pair, encoded (not encrypted — only safe over HTTPS). | Authorization: Basic … |
The mechanics matter less than the discipline around the secret itself. A key or token is a password — anyone who has it can act as you, run up your bill, or read your data. So the iron rule:
Do not paste a key as a string in your code, because the moment you git commit it, it lives in history forever — even if you delete the line later. Keys leak this way constantly and get scraped from public repos within minutes. Put secrets in environment variables (or a secrets manager) and read them at runtime. And make sure the file holding them is in .gitignore (recall Course 2) so it's never tracked.
import os
import requests
# Set this in your shell first, e.g.: export API_TOKEN="abc123"
token = os.environ["API_TOKEN"] # read at runtime, never written in code
resp = requests.get(
"https://api.example.com/v1/orders",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)The token now lives in your shell or your deployment's secret store, not in the file you push to GitHub. Same code runs everywhere; each environment supplies its own value. That separation — config and secrets outside the code — is a habit that will keep you out of real trouble.
When the network fails — and it will
Here is the truth that separates a script from a pipeline: the network always fails eventually. A request hangs, a server returns 503, a connection drops mid-stream, DNS hiccups. Not if — when, and at scale, often. Robust code treats failure as normal and plans for it with three tools.
Timeouts. Never wait forever. A request with no timeout can hang indefinitely if the server stops responding, freezing your whole pipeline. Always set one (you saw timeout=10 above). A request that takes too long is itself a failure — treat it as one and move on.
Retries with exponential backoff. Many failures are transient — a blip, a momentary overload. The cure is to try again, but not instantly and not forever: wait a moment, retry, and if it fails again wait longer (1s, then 2s, then 4s — doubling). This exponential backoff gives a struggling server room to recover instead of a thundering herd of clients hammering it the instant it wobbles. Cap the number of attempts so you don't loop forever, and only retry the failures worth retrying — 5xx and 429, not 4xx.
import time
import requests
def get_with_retry(url, max_attempts=4, **kwargs):
"""GET a URL, retrying transient failures with exponential backoff."""
for attempt in range(max_attempts):
try:
resp = requests.get(url, timeout=10, **kwargs)
if resp.status_code < 500 and resp.status_code != 429:
return resp # success OR a 4xx — retrying won't help
except requests.exceptions.RequestException:
pass # connection error / timeout: fall through to retry
if attempt < max_attempts - 1:
wait = 2 ** attempt # 1s, 2s, 4s, … (exponential backoff)
time.sleep(wait)
raise RuntimeError(f"{url} failed after {max_attempts} attempts")Retrying has a trap. Suppose you POST "create order," the server creates it, but the response is lost on the way back. Your client thinks it failed and retries — and now you have two orders. A request is idempotent when doing it twice has the same effect as doing it once. GET and DELETE usually are; a naive POST usually isn't. So only auto-retry operations that are safe to repeat, or make them safe (e.g. with a unique request ID the server de-duplicates on). This idea — exactly-once is hard, duplicates are the enemy — comes back in full force in Chapter 11; for now, just retry reads freely and writes carefully.
✓ Check yourself
- Can you explain "request out, response back" and which side starts the conversation?
- Why does latency — not bandwidth — dominate a workload of many small requests?
- What's the difference between a 4xx and a 5xx, and which is worth retrying?
- Where should an API key live, and why is committing one dangerous even after you delete the line?
- Why must a request be idempotent before you auto-retry it?
Exercise — Fetch every page from a paginated API (with a timeout and one retry)
Write fetch_all_pages(url) that pulls every page from the public posts API, stopping when a page comes back empty. Each request must use a timeout and get one retry on failure. Return the combined list of all items.
import time
import requests
def get_once_with_retry(url, params):
"""One GET, with a timeout and a single retry on a transient failure."""
for attempt in range(2): # original try + 1 retry
try:
resp = requests.get(url, params=params, timeout=10)
if resp.status_code < 500 and resp.status_code != 429:
resp.raise_for_status() # raise on a real 4xx
return resp.json()
except requests.exceptions.RequestException:
pass
if attempt == 0:
time.sleep(1) # brief backoff before the retry
raise RuntimeError(f"failed to fetch {url} {params}")
def fetch_all_pages(url, page_size=20):
items, page = [], 1
while True:
batch = get_once_with_retry(url, {"_page": page, "_limit": page_size})
if not batch: # empty page → we're done
break
items.extend(batch)
page += 1
return items
all_posts = fetch_all_pages("https://jsonplaceholder.typicode.com/posts")
print("total items fetched:", len(all_posts)) # -> 100Notice the three lessons folded in: a timeout so it can't hang, a single retry that skips un-retryable 4xx errors, and a loop that keeps paging until the data runs out instead of grabbing page 1 and stopping. That's a real, if small, API extractor — the backbone of countless ingestion jobs.
Next
You can now make one machine talk to another reliably — so let's ask what happens when one machine isn't enough and you have to spread the work across many. → Distributed Systems I — Scaling Out