12.1. API Fundamentals: GET, Params, and Error Handling#

Hide code cell source

import json
from urllib.parse import urlparse, parse_qs

import requests

Web APIs let Python programs request data from services over HTTP. In data work, an API is often the difference between copying a file by hand and writing a repeatable data collection step. This section focuses on read-only API calls: choosing an endpoint, passing query parameters, reading JSON, and handling errors without crashing a larger analysis.

12.1.1. What Is an API?#

An application programming interface (API) is a defined way for one program to ask another program for a service. A web API exposes that interface through URLs. A REST-style API usually treats each URL as a resource and uses HTTP methods to say what kind of action the client wants.

Method

Common purpose

Example use

GET

Retrieve data

Get current weather for a city

POST

Send new data

Submit a form or create a record

PUT or PATCH

Update data

Change a profile setting

DELETE

Remove data

Delete a saved item

Status codes summarize how the request went:

Code range

Meaning

Typical action

2xx

Success

Parse the response

3xx

Redirect

Usually handled by the client library

4xx

Client-side problem

Check the endpoint, parameters, or authentication

5xx

Server-side problem

Retry later or report failure gracefully

A useful habit is to describe every API call as endpoint + method + parameters + expected response shape.

12.1.1.1. Worked Example: Read an API Contract#

Suppose a weather endpoint documents this contract:

Field

Value

Endpoint

https://api.example.test/weather

Method

GET

Required params

city, units

Response

JSON object with city, temperature, and units

Before writing code, identify the smallest successful request and the keys your code expects back.

api_contract = {
    "endpoint": "https://api.example.test/weather",
    "method": "GET",
    "required_params": ["city", "units"],
    "response_keys": ["city", "temperature", "units"],
}

print(api_contract["method"], api_contract["endpoint"])
print("Required params:", ", ".join(api_contract["required_params"]))
print("Expected keys:", ", ".join(api_contract["response_keys"]))
GET https://api.example.test/weather
Required params: city, units
Expected keys: city, temperature, units

12.1.2. Making GET Requests with requests#

The requests library is the common Python tool for HTTP. A basic GET request looks like this:

response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()

This notebook avoids live network calls so the book builds reliably offline. The next example uses a tiny response object with the same methods your code uses from a real requests.Response: raise_for_status() and json().

12.1.2.1. Worked Example: Parse a JSON Response#

This example uses a small stand-in for a requests response, so it runs without a network connection. The stand-in has the same raise_for_status() and .json() methods as a real response, so the parsing code is exactly what you would write against a live API.

class DemoResponse:
    def __init__(self, payload, status_code=200):
        self._payload = payload
        self.status_code = status_code

    def raise_for_status(self):
        if self.status_code >= 400:
            raise requests.HTTPError(f"HTTP {self.status_code}")

    def json(self):
        return self._payload

response = DemoResponse({"city": "Chicago", "temperature": 72.5, "units": "fahrenheit"})
response.raise_for_status()
data = response.json()

print(f"{data['city']}: {data['temperature']} degrees {data['units']}")
Chicago: 72.5 degrees fahrenheit

12.1.2.2. JSON Text and Python Objects#

An API sends its response body over the network as text. JSON (JavaScript Object Notation) looks a lot like Python dictionary and list literals, but until something parses it, a JSON body is just a string. The standard library json module converts in both directions:

Function

Input

Output

json.loads(text)

A JSON string

Python dict, list, str, int, float, bool, or None

json.dumps(obj)

A Python dict, list, or other basic value

A JSON string

The s at the end of each name stands for string. Turning Python objects into JSON text is called JSON serialization, and parsing the text back is deserialization. response.json() is a shortcut for the parsing step: it takes the response body text and calls json.loads() on it for you. When JSON text comes from somewhere other than a requests response, such as a saved string, a log line, or a cache, you call json.loads() yourself.

JSON and Python spell a few values differently:

JSON

Python

"text" (double quotes only)

'text' or "text"

true, false

True, False

null

None

object {...}

dict

array [...]

list

12.1.2.2.1. Worked Example: Parse JSON Text by Hand#

The string below stands in for a response body. It contains false and null, so it is valid JSON but not valid Python. After json.loads(), you work with an ordinary dictionary, and .get() supplies a default for a key the payload does not include.

raw_body = '{"city": "Chicago", "temperature": 72.5, "raining": false, "alerts": null}'
print(type(raw_body))

weather = json.loads(raw_body)
print(type(weather))
print(weather["raining"], weather["alerts"])
print(weather.get("humidity", "not reported"))
<class 'str'>
<class 'dict'>
False None
not reported

Going the other way, json.dumps() turns a Python object into JSON text, for example to log a summary, save it to a file, or send it in a request body. In the output, False becomes false, None becomes null, and every string uses double quotes. Parsing that text again gives back an equal dictionary.

summary = {"city": weather["city"], "raining": weather["raining"], "alerts": weather["alerts"]}
summary_text = json.dumps(summary)

print(summary_text)
print(type(summary_text))
print(json.loads(summary_text) == summary)
{"city": "Chicago", "raining": false, "alerts": null}
<class 'str'>
True

12.1.2.2.2. Checkpoint: JSON Round Trip#

request_options is a Python dictionary that a program wants to store as JSON text. Before you run any code, predict how False and None will appear in the JSON text. Then convert the dictionary to a JSON string named options_text with json.dumps() and print it. Finally, parse options_text with json.loads() and print whether the result equals request_options.

request_options = {"city": "Chicago", "units": "metric", "include_alerts": False, "max_days": 3, "station": None}

### Your code starts here

### Your code ends here

Hide code cell source

### Solution
request_options = {"city": "Chicago", "units": "metric", "include_alerts": False, "max_days": 3, "station": None}

options_text = json.dumps(request_options)
print(options_text)
print(json.loads(options_text) == request_options)
{"city": "Chicago", "units": "metric", "include_alerts": false, "max_days": 3, "station": null}
True

12.1.3. Query Parameters#

Many GET endpoints use query parameters: key/value pairs after the ? in a URL. Build them with the params= argument instead of manually concatenating strings. requests handles escaping spaces, punctuation, and special characters correctly.

12.1.3.1. Worked Example: Build a Request URL Safely#

Instead of sending the request, this example prepares it and prints the URL that requests builds from params=. Notice that the space in New York is encoded for you, and that parsing the query string recovers the original values.

base_url = "https://api.example.test/weather"
params = {"city": "New York", "units": "fahrenheit"}

request = requests.Request("GET", base_url, params=params).prepare()
print(request.url)

parsed = urlparse(request.url)
print(parse_qs(parsed.query))
https://api.example.test/weather?city=New+York&units=fahrenheit
{'city': ['New York'], 'units': ['fahrenheit']}

Behind params=, requests performs URL encoding, also called percent-encoding: a character with special meaning in a URL is replaced by % followed by its byte value in hexadecimal. The standard library function urllib.parse.urlencode() does the same job for a whole dictionary, and urllib.parse.quote() encodes a single value. A space becomes + in urlencode() output and %20 in quote() output, and servers read both as a space. Characters such as & and = are escaped as %26 and %3D, so a value that contains them cannot split the query string into extra parameters. The first line below joins raw values by hand and shows the problem: the server would read fish & chips as two parameters. You rarely call these functions yourself, because requests does this for you with params=.

from urllib.parse import urlencode, quote

search_params = {"q": "fish & chips", "filter": "price=low", "city": "New York"}

naive = "&".join(f"{key}={value}" for key, value in search_params.items())
print(naive)
print(urlencode(search_params))
print(quote("fish & chips"))
q=fish & chips&filter=price=low&city=New York
q=fish+%26+chips&filter=price%3Dlow&city=New+York
fish%20%26%20chips

12.1.3.2. Checkpoint: Query Parameters#

The endpoint https://api.example.test/search accepts q, page, and limit. Create a prepared GET request for page 2 of a search for python api, limited to 5 results. Print the final URL.

### Your code starts here
search_url = "https://api.example.test/search"
params = {
    # Fill in q, page, and limit.
}

prepared = requests.Request("GET", search_url, params=params).prepare()
print(prepared.url)
### Your code ends here
https://api.example.test/search

Hide code cell source

### Solution
search_url = "https://api.example.test/search"
params = {"q": "python api", "page": 2, "limit": 5}

prepared = requests.Request("GET", search_url, params=params).prepare()
print(prepared.url)
https://api.example.test/search?q=python+api&page=2&limit=5

12.1.4. Error Handling#

Two categories of errors matter in most API code:

  1. Network errors: timeouts, DNS failures, broken connections, or other exceptions raised before a response arrives.

  2. HTTP errors: the server responds, but the status code signals a problem, such as 404 Not Found or 500 Internal Server Error.

A reliable API helper should use a timeout, call raise_for_status(), and return a predictable value when the request fails.

12.1.4.1. Worked Example: Safe JSON Fetching#

This example wraps both failure checks in one function. raise_for_status() catches HTTP errors, and a ValueError from .json() catches a body that is not valid JSON. Either way, the function returns None instead of crashing.

def parse_json_response(response):
    """Return JSON data from a response-like object, or None if it fails."""
    try:
        response.raise_for_status()
        return response.json()
    except requests.HTTPError as error:
        print("HTTP error:", error)
    except ValueError as error:
        print("Invalid JSON:", error)
    return None

ok = DemoResponse({"results": ["a", "b"]}, status_code=200)
missing = DemoResponse({"message": "not found"}, status_code=404)

print(parse_json_response(ok))
print(parse_json_response(missing))
{'results': ['a', 'b']}
HTTP error: HTTP 404
None

12.1.4.2. Catching Any Request Failure#

The requests library organizes its exceptions in a hierarchy. requests.RequestException is the base class, and the specific errors inherit from it: requests.ConnectionError (the server cannot be reached), requests.Timeout (no reply arrives within the timeout limit), and requests.HTTPError (raised by raise_for_status()). Because every one of them is a RequestException, the clause except requests.RequestException catches any request failure. Use it when your code handles every failure the same way, and catch a specific subclass such as requests.HTTPError first when one case needs its own handling.

The stand-in function below raises requests.Timeout instead of making a real call, so you can watch the base class catch it without a network connection.

def fake_get(url, timeout):
    """Stand in for requests.get() by simulating a server that never replies."""
    raise requests.Timeout(f"No reply from {url} within {timeout} seconds")

def safe_get(url):
    try:
        return fake_get(url, timeout=5)
    except requests.RequestException as error:
        print(f"{type(error).__name__}: {error}")
        return None

print(issubclass(requests.Timeout, requests.RequestException))
print(issubclass(requests.HTTPError, requests.RequestException))
print(safe_get("https://api.example.com/data"))
True
True
Timeout: No reply from https://api.example.com/data within 5 seconds
None

12.1.5. Weather API Pattern#

A real weather call to Open-Meteo would use this shape:

requests.get(
    "https://api.open-meteo.com/v1/forecast",
    params={"latitude": 41.88, "longitude": -87.63, "current_weather": True},
    timeout=10,
)

For a reproducible notebook, practice with a saved payload that has the same nested structure.

12.1.5.1. Worked Example: Extract Weather Data#

The Open-Meteo response nests the current conditions inside a current_weather dictionary. This example uses a saved sample of that response and reads the temperature and wind speed in two steps: first the inner dictionary, then its fields.

sample_weather = {
    "latitude": 41.88,
    "longitude": -87.63,
    "current_weather": {
        "temperature": 21.4,
        "windspeed": 13.2,
        "weathercode": 2,
    },
}

current = sample_weather["current_weather"]
print("Temperature:", current["temperature"])
print("Wind speed:", current["windspeed"])
Temperature: 21.4
Wind speed: 13.2

12.1.5.2. Checkpoint: Weather Payload#

Write a small function summarize_weather(payload) that returns a dictionary with the temperature and wind speed from a weather response payload.

def summarize_weather(payload):
    ### Your code starts here
    return {}
    ### Your code ends here

print(summarize_weather(sample_weather))
{}

Hide code cell source

### Solution
def summarize_weather(payload):
    current = payload["current_weather"]
    return {
        "temperature": current["temperature"],
        "windspeed": current["windspeed"],
    }

print(summarize_weather(sample_weather))
{'temperature': 21.4, 'windspeed': 13.2}

12.1.6. Summary#

API fundamentals are mostly about making contracts explicit. A GET request asks an endpoint for data, params= builds URL-encoded query strings safely, response.json() converts JSON text into Python dictionaries and lists (json.loads() does the same for any JSON string, and json.dumps() goes the other way), and raise_for_status() turns failing HTTP status codes into exceptions your program can handle. For reliable notebooks and tests, separate the request step from the parsing step so parsing logic can be tested with saved payloads.