Every lesson so far has called APIs from the Python side — you were the customer of someone else's contract. This stage flips the table. Before you build CityOps' own API in Milestone 3, you learn to read any API's contract the way a lawyer reads the fine print: what the nouns are, which verbs each one speaks, and what the status codes are really telling you. Ten minutes with any docs, dangerous by the end of it.
Assumes: Posts 1–9, especially Post 6. One install: pip install requests.
Monday, 9:05 AM. Tom has the city's open-data API docs open on one monitor and a blank script on the other. "Ops wants all open sanitation complaints from Brooklyn on the dashboard. The docs are right here —" he pastes a URL into his browser, "— can't I just hit this?"
You: "You can. What happens when you need to send the filter — are you going to POST it?"
Tom: "POST… sends data, right? I have data to send — the query. So yes?"
He is one click from the exact misuse this lesson exists to prevent. The docs are open in front of him and he still can't read them — because nobody taught him that an API is a contract written in nouns, verbs, and status codes, and he is about to sign it without reading.
Before you code: clarify the ask
You: "Before the verbs — what does 'open' mean? I counted the status values in the feed just now: 22 million Closed, 285 thousand In Progress, 82 thousand Open, 63 thousand Pending, 24 thousand Assigned. 'Not closed' is a wildly different dashboard number than 'Open'."
Tom: "Ops said open. Hmm — anything not closed, I think."
You: "Then the filter is status != 'Closed', and we write that down, because 'open' in English and 'Open' in the data are different things. Next: whole rows, or just a few columns?"
Tom: "Just the basics — id, date, type, status."
You: "And you're only reading? Not creating complaints, not updating them?"
Tom: "Just reading."
You: "Good. That one answer decides the verb for the whole task."
Input: the 311 dataset's API docs, plus Tom's half-understood requirement
Output: one readable GET request Tom can reuse, and a reader that fails loudly instead of silently
Deadline: the dashboard refreshes Thursday
Notice that "just reading" did more work than any code will. It ruled out every verb except one before a single request was fired.
The minimal concept
Four ideas, and the first one is the whole lesson in a sentence:
The URL is a noun; the verb is the method. A REST API is sentences. GET (give me) /tickets (the tickets). Resources are nouns that live at URLs; the HTTP method says what you're doing to them: GET reads, POST creates, PUT replaces, PATCH partially updates, DELETE removes. Read any API's docs as sentences and the docs start making sense.
Status codes are the API's vocabulary for what happened. 2xx — success. 3xx — go look over there. 4xx — the request is the problem: 400 malformed, 401 missing credentials, 403 insufficient permission, 404 not found, 429 slow down. 5xx — the server is having a bad day: 500, 502, 503, 504. Read the code before you touch the body — parsing the body of a failed request is where the confusing errors come from.
Query parameters are the fine print. Filters, field lists, and limits ride in the URL after the ? — but you build them with params={...}, never by concatenating strings. The library encodes; your fingers don't.
Idempotency is the retry rule. GET and HEAD are designed safe to repeat — asking twice changes nothing, so retrying them is usually fine. POST usually isn't idempotent: asking twice can create twice. PUT and DELETE are designed repeat-safe — repeat them and the net effect is the same. This is why Post 6's fetch_json only ever retried GETs. It was already living this lesson's rule; today the rule gets a name.
Build it, part 1: read the docs like a contract
Take the endpoint Tom was staring at and decode it like a contract. Every REST endpoint decomposes the same way:
| Piece | What it is | 311 example |
|---|---|---|
| Base URL | Whose API this is | https://data.cityofnewyork.us |
| Resource | The noun — which dataset, which flavor | /resource/erm2-nwe9.json (the 311 dataset, as JSON) |
| Method | The verb — what you're doing | GET: we're only reading |
| Parameters | The fine print — fields, filters, limits | $select, $where, $limit (Socrata's dialect) |
Now Tom's ask as a sentence — "GET (give me) the 311 resource, but only these columns, where the agency is DSNY and the borough is Brooklyn, and only three rows to start":
import requests
s = requests.Session()
s.headers["User-Agent"] = "cityops-lesson/1.0 (field training)"
r = s.get("https://data.cityofnewyork.us/resource/erm2-nwe9.json",
params={"$select": "unique_key,created_date,agency,complaint_type,status,borough",
"$where": "status='Open' AND agency='DSNY' AND borough='BROOKLYN'",
"$limit": "3"},
timeout=(5, 30))
print(r.status_code, r.headers["Content-Type"])
for row in r.json():
print(row)
200 application/json;charset=utf-8
{'unique_key': '45838074', 'created_date': '2020-03-16T21:34:00.000', 'agency': 'DSNY', 'complaint_type': 'Electronics Waste Appointment', 'status': 'Open', 'borough': 'BROOKLYN'}
{'unique_key': '45838075', 'created_date': '2020-03-16T20:51:00.000', 'agency': 'DSNY', 'complaint_type': 'Electronics Waste Appointment', 'status': 'Open', 'borough': 'BROOKLYN'}
{'unique_key': '45841003', 'created_date': '2020-03-16T15:16:00.000', 'agency': 'DSNY', 'complaint_type': 'Electronics Waste Appointment', 'status': 'Open', 'borough': 'BROOKLYN'}
That's a real run against the real feed — 200, JSON, three rows. And here's the ten-minutes-with-any-docs checklist this decomposes into. Run it against unfamiliar docs and you're dangerous fast:
- Base URL — whose server am I talking to? (Copy it exactly;
httpvshttpsmatters.) - Resource path — which noun? The path names the thing; the extension or
Acceptheader names the flavor. - Verbs — which methods does this resource speak? A read endpoint that only speaks GET will not negotiate with your POST.
- Parameters — which filters, which field selection, which pagination? Required vs optional.
- Auth — does it need a key, and where does the key go? (Stage 3's next lesson lives here.)
- Status codes — what does this API return when it fails, and in what format?
- Rate limits — how fast may I ask? What happens when I ask faster?
Build it, part 2: status codes are the vocabulary
Vocabulary is learned by hearing it spoken. Three clean examples, all real runs:
>>> requests.get("https://httpbin.org/status/200", timeout=10).status_code
200
>>> requests.get("https://httpbin.org/status/404", timeout=10).status_code
404
>>> requests.get("https://httpbin.org/status/503", timeout=10).status_code
503
200: success, carry on. 404: the thing isn't there — check your resource path. 503: the server is having a bad day — this one may be worth retrying. Same call shape, three different meanings, all in the code before you ever look at a body.
And the Socrata feed speaks the vocabulary fluently — including in failure. Typo the dataset id and the API tells you exactly what went wrong, in JSON, no guessing:
>>> r = requests.get("https://data.cityofnewyork.us/resource/erm2-nwe.json", timeout=10)
>>> r.status_code
404
>>> r.json()
{'code': 'dataset.missing', 'error': True, 'message': 'Not found',
'data': {'id': 'erm2-nwe'}}
dataset.missing — not "something broke," but "that dataset doesn't exist." Good APIs narrate their failures. Your job is just to read the narration before doing anything else — which is exactly what the next section is about not doing.
Build it, part 3: verbs in the lab
To feel the verbs, you need an API you can safely poke — so this section runs against a tiny local lab server (http.server, on your own machine) with four endpoints: GET/POST on /tickets, PUT/DELETE on /tickets/0. Never experiment with verbs against a production API; that's what labs are for.
Watch what each verb means, by repetition:
base = "http://127.0.0.1:8811"
p = {"title": "pothole on 5th", "status": "open"}
# the same POST, sent twice:
>>> requests.post(base + "/tickets", json=p, timeout=5).json()["id"]
0
>>> requests.post(base + "/tickets", json=p, timeout=5).json()["id"]
1
>>> len(requests.get(base + "/tickets", timeout=5).json())
2 # two tickets. the retry created a second one.
# GET twice: identical, nothing changed
>>> a = requests.get(base + "/tickets", timeout=5).json()
>>> b = requests.get(base + "/tickets", timeout=5).json()
>>> a == b
True
# PUT the same update twice: one ticket, same final state
>>> requests.put(base + "/tickets/0", json={"title": "pothole on 5th", "status": "closed"}, timeout=5).status_code
200
>>> requests.put(base + "/tickets/0", json={"title": "pothole on 5th", "status": "closed"}, timeout=5).json()
{'title': 'pothole on 5th', 'status': 'closed', 'id': 0}
# DELETE twice: 200, then 404 — but the ticket stays gone either way
>>> requests.delete(base + "/tickets/0", timeout=5).status_code
200
>>> requests.delete(base + "/tickets/0", timeout=5).status_code
404
All real runs. The pattern: POST created something new every time it was asked. GET asked twice and changed nothing. PUT landed in the same state twice. DELETE's second call complained (404, "already gone") but the net effect — ticket gone — was identical. That's idempotency, not as a definition but as observed behavior: which verbs can you safely repeat, and which one punishes you for it.
Break it, three ways
1. The 404 nobody checked. Tom's first script read the docs, built the request — and treated every response as data. Watch what happens when the resource path is wrong and nobody reads the status (real run, typo'd dataset id):
r = requests.get("https://data.cityofnewyork.us/resource/erm2-nwe.json",
params={"$limit": "3"}, timeout=15)
print("status was:", r.status_code, "(nobody checked)")
rows = r.json()
print("first row keys:", list(rows[0].keys()))
status was: 404 (nobody checked)
KeyError: 0
The error body is a dict describing the failure; the script treated it as a list of rows. The KeyError surfaces three lines later, pointing at nothing. Post 6's rule stands: status first, body second — the failure you check for at the boundary is the failure you can diagnose.
2. The hand-built URL. Tom's second attempt built the query string by concatenation — because the value contained an &, the server saw two parameters where he meant one (real runs):
base = "https://data.cityofnewyork.us/resource/erm2-nwe9.json"
hand = base + "?$select=unique_key&$where=complaint_type='Noise & Construction'&$limit=1"
>>> requests.get(hand, timeout=15).status_code
400
>>> requests.get(hand, timeout=15).json()["message"]
"Unrecognized arguments [ Construction']"
>>> requests.get(base, params={"$select": "unique_key",
... "$where": "complaint_type='Noise & Construction'",
... "$limit": "1"}, timeout=15).status_code
200
The server is not being difficult — & means "next parameter" in a query string, so the hand-built URL genuinely asked for a parameter named Construction'. params= encodes the value; concatenation trusts your fingers. One of those is a strategy.
3. The retried POST. The lab already showed you: same body, sent twice, two tickets (201 id=0, 201 id=1). Now put it in production clothes. Your network hiccups after the server created the ticket but before the response reached you. Your retry logic — the good, Post 6-style retry logic — fires the POST again. The server, doing exactly what POST means, creates it again. In the lab that's two rows. In production it's two work orders dispatched, two charges on the card, two "your request is confirmed" emails to the customer. The retry wasn't wrong and the server wasn't wrong — the verb was wrong for the retry.
Productionize: the idempotency rule, as code
Everything above compresses into one helper: rest_call. It bakes the retry rule into the call itself — safe methods (GET, HEAD) may be retried with backoff on 429/5xx; unsafe methods (POST, PUT, DELETE) are attempted exactly once unless you explicitly pass allow_retry=True. And every failure raises an ApiError carrying the status, the URL, and a hint about what the code means — caught only to add that context, per the exception rule:
import logging, random, time
import requests
SAFE_METHODS = {"GET", "HEAD"} # designed safe to repeat
RETRYABLE = {429, 500, 502, 503, 504}
class ApiError(RuntimeError):
"""An HTTP failure with the status, the URL, and what it usually means."""
HINTS = {
400: "bad request — the request is malformed (do not retry as-is)",
404: "not found — wrong resource, or it was deleted",
429: "too many requests — back off, then retry",
}
def __init__(self, method, url, status, body=""):
hint = self.HINTS.get(status,
"server-side failure — retry may help"
if str(status).isdigit() and int(status) >= 500
else "request problem — fix the request, don't retry")
super().__init__(f"{method} {url} -> {status}: {hint} | body: {str(body)[:120]}")
def rest_call(method, url, params=None, json=None, tries=3, allow_retry=False):
"""REST with the idempotency rule baked in.
Safe methods retry (bounded, backoff+jitter) on 429/5xx.
Unsafe methods run exactly once unless allow_retry=True —
because a retried POST can create twice.
"""
method = method.upper()
attempts = tries if (method in SAFE_METHODS or allow_retry) else 1
delay = 1.0
for attempt in range(1, attempts + 1):
try:
r = requests.request(method, url, params=params, json=json,
timeout=(5, 30),
headers={"User-Agent": "cityops-lesson/1.0 (field training)"})
except (requests.ConnectionError, requests.Timeout) as e:
if attempt == attempts:
raise ApiError(method, url, "no response", e) from e
else:
if r.status_code in RETRYABLE and attempt < attempts:
wait = delay * (0.5 + random.random())
logging.getLogger("cityops").warning(
"%s %s -> %s: retry in %.1fs", method, url, r.status_code, wait)
time.sleep(wait); delay = min(delay * 2, 30)
continue
if r.status_code >= 400:
raise ApiError(method, url, r.status_code, r.text)
return r.json()
Three behaviors, all verified against live servers — note what the log lines prove:
# GET recovers from a flaky endpoint (two 503s, then healed):
WARNING GET http://127.0.0.1:8811/flaky -> 503: retry in 0.9s
WARNING GET http://127.0.0.1:8811/flaky -> 503: retry in 2.1s
{'ok': True}
# POST to the same failing endpoint: ZERO warning lines — one attempt, then out:
ApiError: POST http://127.0.0.1:8811/flaky -> 503: server-side failure — retry may help | body: {"er...
# POST with allow_retry=True: retries twice, then gives up honestly:
WARNING POST http://127.0.0.1:8811/flaky -> 503: retry in 0.8s
WARNING POST http://127.0.0.1:8811/flaky -> 503: retry in 1.1s
ApiError: POST http://127.0.0.1:8811/flaky -> 503: server-side failure — retry may help | body: {"er...
And the hint, doing its job on the typo'd dataset — the real message, unabridged:
ApiError: GET https://data.cityofnewyork.us/resource/erm2-nwe.json -> 404:
not found — wrong resource, or it was deleted | body: {
"code" : "dataset.missing",
"error" : true,
"message" : "Not found",
"data" : {
"id" : "erm2-nwe"
}
}
Read the discipline in the code: we catch ConnectionError and Timeout only — the network failed, so a retry might help — and re-raise as ApiError with the method, URL, and status attached. raise ... from e keeps the original chained. Non-retryable failures (400, 404) raise immediately with the hint; nothing is swallowed, nothing retried blindly. This is Post 6's fetch_json grown up: same backoff-and-jitter DNA, now with the verb-awareness this lesson taught it.
Explain it to the customer
"Tom — the dashboard query is one readable call: rest_call("GET", ...) with the columns, the status != 'Closed' filter, and the Brooklyn filter as parameters — params= builds the URL, so the &-in-a-value bug can't happen. 'Open' is now defined in writing as status != 'Closed', not the English word — that's a different number by hundreds of thousands of rows, so the definition lives in the code, not in a Slack thread. If the endpoint ever 404s, you get one error naming the URL, the status, and what it means — not a KeyError three lines later. And the retry rule is in the helper now: reads retry themselves, writes never retry unless you explicitly say so."
The pattern: what you built, the definition that changed the number, the failure mode that's now loud, and the rule that's now code instead of tribal knowledge.
Must know
- The URL is a noun; the verb is the method — read API docs as sentences
- Status codes are the vocabulary: 2xx success, 4xx the request's problem, 5xx the server's — read the code before the body
params={...}builds query strings; concatenation breaks on&, spaces, and commas- GET/HEAD are designed safe to repeat; POST usually isn't — a retried POST can create twice
- Failures carry their meaning: status + URL + hint, raised — never swallowed, never retried blindly
Useful later
- Auth in the wild — API keys, OAuth2, SSO: what to do when the API doesn't let you in for free (next lesson)
- Pagination — one page of results is rarely the whole dataset (coming in Stage 3)
- Idempotency keys — how some APIs make POST safely retryable (coming in Stage 3)
- Webhooks — the API calling you (coming in Stage 3)
Don't memorize this
- The full status-code catalog — remember the families (2xx/4xx/5xx), look up the rest
- Which verbs each API speaks — remember nouns take verbs, read the docs for the list
- Any query dialect (SoQL or otherwise) — remember the docs' checklist, look up the dialect
Where this lands in CityOps
This lesson is the hinge of the whole stage. Everything so far, you've been the caller; in Milestone 3 you build CityOps' own API with FastAPI — the other side of the table. Every rule in this lesson becomes a decision you'll make: which nouns your API exposes, which verbs each one speaks, which status codes it returns, and what its failure bodies look like. The dataset.missing JSON you admired today is the standard you're about to hold yourself to.
And the intake pipeline from Milestone 2 gets its reader upgraded: rest_call replaces the raw session.get, so the nightly pull now fails with a named, hinted error instead of a KeyError at 6 AM. Post 7's suite can pin the new rule — a POST that retries without allow_retry is a test that should fail.
The signature line for this one: the URL is a noun; the verb is the method. Post 6 said don't trust the happy path — reading the contract is how you stop trusting it and start verifying it, before the first request is ever sent.
Field check
- Tom's script got a 404 and died with
KeyError: 0three lines later. What was the actual mistake, and what's the one-line fix in spirit? - Your hand-built URL returns
400: Unrecognized arguments [ Construction']. What happened, mechanically — and why doesparams=fix it? - A POST timed out — no response received. Do you retry it? Why is this the hard case?
rest_callretries GET on a 503 but attempts POST exactly once. What in the code enforces that, and how would you override it?- The ops dashboard needs "open complaints." You found the feed has Open (82k), Pending (63k), In Progress (286k), Assigned (25k). Write the one-line filter definition you'd put in the code, and say why "open" in English isn't the definition.
What good answers look like
1. The mistake wasn't the KeyError — it was never reading the status. The script treated an error body as data. The fix in spirit: check the status (or raise_for_status() / the ApiError) before touching the body — the failure you check at the boundary is the one you can diagnose. 2. The & inside 'Noise & Construction' is a query-string separator, so the server parsed Construction' as a separate parameter name — the URL genuinely asked for something unintended. params= percent-encodes the value, so the & travels as data, not syntax. 3. You don't know — that's exactly why it's the hard case. The request may have reached the server and created the thing, or died on the wire; retrying risks a duplicate, not retrying risks a missed creation. Safe answers: check first with a GET (did it land?), or use an idempotency key if the API supports one — never blind-retry a POST. 4. attempts = tries if (method in SAFE_METHODS or allow_retry) else 1 — unsafe methods get a single attempt. Override with allow_retry=True, which is the explicit "I understand this POST is safe to repeat" (and the test suite should make you prove it). 5. Something like "$where": "status <> 'Closed'" (or the explicit list), committed in the code. "Open" in English is ambiguous — the feed distinguishes Open, Pending, In Progress, and Assigned, and the dashboard number swings by hundreds of thousands of rows depending on which you include. The definition belongs in the code, not in a Slack thread.