Skip to main content
Learning Center
Python Programming

Module 7: APIs and Web Data

HTTP and the Shape of an API Contract

What a request and a response actually contain, what a status code commits the server to, and how to read documentation for the four things your client depends on.

Lesson 22 of 46 in the recommended order · About 25 min (estimate)

On this page

Outcome

By the end of this lesson you can describe any API call in six parts, and you can look at a status code and say whether the right response is to use the payload, retry the request, or stop and report.

Why it matters

Every batch of records this course has processed came from somewhere. In the capstone that somewhere is a public procurement API, and the first thing to understand is that the API is a contract: an agreement about what you may ask for and what you will get back, including what happens when something goes wrong.

Most integration bugs are contract misunderstandings, not coding errors. Treating a rate-limit response as a permanent failure, or a permanent failure as something to retry, produces behaviour that is either uselessly fragile or actively rude to the service you depend on.

Nothing in this module makes a network call. Every example runs against fixtures, which is deliberate and permanent: the whole capstone must be completable with no internet connection and no API key.

Concept

Six parts describe any call.

  • Method. GET retrieves and changes nothing; POST submits. A GET is idempotent: repeating it is safe, which is what makes retrying reasonable.
  • Endpoint. The path identifying the resource, for example /opportunities/v2/search.
  • Query parameters. Filters and controls appended after ?, joined by &, each value URL-encoded so spaces and punctuation survive the trip.
  • Headers. Metadata about the request or response: what format you accept, how you authenticate, how many requests you have left.
  • Status code. A three-digit number saying what happened.
  • Body. The payload, usually JSON.

Status codes group by their first digit, with two important exceptions inside the groups:

  • 2xx succeeded. 200 OK is the normal case.
  • 4xx means the request was wrong: 400 malformed, 401 missing or invalid credentials, 403 authenticated but not permitted, 404 no such resource. Repeating an identical request will fail identically, so these are not retryable.
  • 429 Too Many Requests is the exception in that range. It means "correct request, sent too often", so it is retryable, after waiting.
  • 5xx means the server failed. 500 is a fault on their side, 503 means temporarily unavailable. These are usually worth a bounded retry.

One header earns its keep in every example here: Accept: application/json states the format you want. Many APIs also authenticate through a header, but not all — some documented contracts pass a credential as a query parameter, a signed request, or another mechanism entirely. There is no universal rule for where a credential travels in a request; the provider's own current documentation decides it, and a client follows that rather than assuming.

When reading documentation, look for four things and write them down: the exact endpoint and its parameters; the shape of a successful payload; how paging works; and the documented limits. Everything else can be looked up later; those four determine whether your client is correct.

Read the code

No network call here; this builds and inspects a request, and interprets a stored response.

from urllib.parse import urlencode

BASE = "https://example-procurement.test/opportunities/v2/search"

params = {
    "postedFrom": "2026-03-01",
    "postedTo": "2026-03-31",
    "state": "OR",
    "limit": 100,
    "offset": 0,
}
headers = {"Accept": "application/json"}

print(f"{BASE}?{urlencode(params)}")
print(headers)

response = {
    "status": 200,
    "headers": {"Content-Type": "application/json", "X-RateLimit-Remaining": "97"},
    "body": {"totalRecords": 240, "limit": 100, "offset": 0, "results": []},
}

print(response["status"], response["headers"]["X-RateLimit-Remaining"])
print(response["body"]["totalRecords"], "records available")

urlencode builds the query string and escapes each value, which is the reason to use it rather than joining strings: a parameter containing a space or an ampersand would otherwise change the meaning of the URL.

The response is modelled as a dictionary of the three things a response carries. X-RateLimit-Remaining is the sort of header a client should read rather than ignore; it is the service telling you, without being asked, how much budget is left.

totalRecords is 240 while limit is 100, so this response holds at most a third of the matching records. That gap is what the next lesson is about.

Predict the output

Predict the four printed lines. The first is the interesting one.

Check your prediction
https://example-procurement.test/opportunities/v2/search?postedFrom=2026-03-01&postedTo=2026-03-31&state=OR&limit=100&offset=0
{'Accept': 'application/json'}
200 97
240 records available

The parameters appear in the order they were defined, joined by &, with the integers rendered as text. Nothing needed escaping here, but had state been "OR, WA" the encoder would have produced OR%2C+WA, and hand-built strings are where that goes wrong.

Modify the code

Add "api_key": "not-a-real-key" to the params dictionary and predict the first printed line. Then say why the value itself is a problem, regardless of where it travels in the request.

What changes, and why

The key appears in the printed URL, as &api_key=not-a-real-key.

That is the problem, and it isn't specific to query parameters. A credential written into source code as a literal ends up wherever that code goes — printed to a log, checked into version control, pasted into an AI tool, or shown on a screen — no matter whether the provider's documented mechanism is a query parameter, a header, or something else. Some providers really do use a query parameter as their documented mechanism; the fix is never writing the value as a literal, not moving the same literal somewhere else in the request.

A credential value belongs in an environment variable read at run time, never a literal in source, and it travels wherever the provider's own documentation says it does. The next two lessons build the client that way, and this course's own build gate rejects any content file containing a credential-shaped value, including in an example.

Debug the bug

An assistant was asked to add error handling to a client. It produced this and said failed requests are retried.

def fetch(send, url, attempts=3):
    for attempt in range(attempts):
        response = send(url)
        if response["status"] == 200:
            return response["body"]
        print("retrying after status", response["status"])
    return None


def always_forbidden(url):
    return {"status": 403, "body": None}


print(fetch(always_forbidden, "https://example-procurement.test/search"))
What's actually wrong

It prints three retrying after status 403 lines and then None.

403 means authenticated but not permitted. The request will be refused identically every time, so the two extra attempts achieve nothing except three times the load on a service that has already said no, and three times the delay before the caller learns anything.

There is a second fault that matters more. The function returns None on failure, and None is also a legitimate empty body. The caller cannot distinguish "no results" from "we were refused", so a permission problem becomes an empty shortlist and nobody investigates.

A better shape separates the two decisions and reports the outcome:

RETRYABLE = {429, 500, 502, 503, 504}


def fetch(send, url, attempts=3):
    """Return (body, error). Retries only retryable statuses."""
    for attempt in range(attempts):
        response = send(url)
        if response["status"] == 200:
            return response["body"], None
        if response["status"] not in RETRYABLE:
            return None, f"request failed with status {response['status']}"
    return None, f"gave up after {attempts} attempts"

The verdict-plus-reason pattern from Module 2 turns out to be exactly the right shape here too.

Try it yourself

Write the classification the corrected client depends on: a pure function from a status code to one of three actions.

Loading this exercise…

Practical challenge (optional)

Optional: find the current official documentation for a public API you are curious about, and write down its four essentials: the endpoint and its parameters, the shape of a successful payload, how paging works, and the documented rate limits. Do not call it. The exercise is reading a contract and recording it, which is the first thing the capstone's client phase asks for, and it is the step people skip.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Name the six parts that describe an API call.
  2. Why is 429 retryable when other 4xx codes are not?
  3. Why must client code use the authentication mechanism documented by the provider instead of assuming where a credential belongs?
  4. What four things should you record when reading an API's documentation?
Answers
  1. Method, endpoint, query parameters, headers, status code, and body.
  2. Other 4xx codes mean the request itself is wrong, so repeating it fails identically. 429 means the request was correct but sent too often, so the same request will succeed after a wait.
  3. Different APIs authenticate requests in different documented ways — a header, a query parameter, a signed request, or something else — so a developer must not invent or infer the mechanism; the provider's current documentation is what settles it. Whatever the mechanism, a credential must never be committed, logged, printed, pasted into an AI tool, or exposed in browser code. This course uses no provider credential of any kind, because every exercise and the capstone are fixture-backed.
  4. The endpoint and its parameters, the shape of a successful payload, how paging works, and the documented rate limits.

Sign in to track your progress on this exercise.

Summary and next step

An API call is six parts, status codes group by their first digit with 429 as the notable exception, authentication is provider-specific so a client follows the provider's documented mechanism rather than assuming one, and the four contract essentials are worth writing down before any code. Next: what happens when the answer does not fit in one response, and how to retry without hammering a service.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.