Making HTTP Requests with the requests Library

9 min read

API testing is one of the biggest reasons QA engineers learn Python — and requests is the reason it's pleasant. The library wraps Python's verbose built-in HTTP client (urllib) in a clean API: three lines for a GET, one method per HTTP verb, automatic JSON parsing, sensible defaults. This lesson covers GET, POST, PUT, PATCH, DELETE, headers, query parameters, status codes, timeouts, error handling, and Session for cookie-based flows. By the end you'll be writing real API tests.

Installing requests

requests is a third-party library. Install it once per project, ideally inside a virtual environment:

pip install requests

Then in your script:

import requests

That's it. No client to instantiate, no boilerplate to wire up.

A GET request — the basic shape

import requests
 
response = requests.get("https://jsonplaceholder.typicode.com/users")
 
print(response.status_code)        # 200
print(response.json()[0]["name"])  # parses the JSON body, indexes the first user

requests.get(url) sends a GET and returns a Response object. Three properties you'll touch most often:

  • response.status_code — the HTTP status as an int (200, 404, 500, …).
  • response.json() — parses the body as JSON, returns Python dicts/lists. Raises if the body isn't valid JSON.
  • response.text — the raw response body as a string. Useful when the response isn't JSON, or for debugging an unexpected error page.

Compare this to Java's HttpClient or Rest Assured — fewer lines, no fluent builders. That terseness is exactly what makes requests the de facto standard.

Other HTTP methods

Same shape, different verbs:

requests.get(url)
requests.post(url, json=payload)
requests.put(url, json=payload)
requests.patch(url, json=payload)
requests.delete(url)

Use the verb that matches the API contract:

  • GET — read.
  • POST — create (usually).
  • PUT — replace.
  • PATCH — partial update.
  • DELETE — remove.

Sending JSON — the json= parameter

For a POST with a JSON body, pass json= and requests serialises the dict and sets Content-Type: application/json for you:

payload = {"name": "Alice", "email": "alice@test.com"}
 
response = requests.post(
    "https://api.example.com/users",
    json=payload
)
 
print(response.status_code)        # 201 Created
print(response.json()["id"])       # the new user's ID

Don't confuse json= with data=. json= serialises a dict to JSON; data= sends a form-encoded body (name=Alice&email=...) — the kind of body a browser submits from a <form>. Pick the right one for the API you're testing.

Headers — auth, content type, custom

Pass headers as a dict via headers=:

headers = {
    "Authorization": "Bearer eyJhbGciOiJI...",
    "Accept": "application/json",
    "X-Request-Id": "test-1234"
}
 
response = requests.get("https://api.example.com/me", headers=headers)

requests already sends sensible defaults — Accept-Encoding: gzip, deflate, a User-Agent. You only need to set the headers your API actually requires.

A common QA pattern: store the auth token in a variable once, reuse it:

TOKEN = "eyJhbGciOiJI..."
auth_header = {"Authorization": f"Bearer {TOKEN}"}
 
requests.get(url1, headers=auth_header)
requests.get(url2, headers=auth_header)

For repeated calls, Session (later) is even cleaner.

Query parameters — params=

Don't hand-build query strings — pass a dict and requests URL-encodes everything for you:

params = {"page": 1, "limit": 10, "role": "admin"}
 
response = requests.get("https://api.example.com/users", params=params)
 
print(response.url)
# https://api.example.com/users?page=1&limit=10&role=admin

Special characters (spaces, &, +, non-ASCII) are escaped automatically — no chance of building a malformed URL by hand.

The Response object — the rest of it

A Response exposes more than just status and body:

response = requests.get("https://jsonplaceholder.typicode.com/users/1")
 
response.status_code               # 200
response.ok                        # True if status < 400
response.json()                    # parsed JSON
response.text                      # raw body as string
response.content                   # raw body as bytes (binary safe)
response.headers                   # dict-like of response headers
response.headers["Content-Type"]   # 'application/json; charset=utf-8'
response.elapsed                   # timedelta — how long the round-trip took
response.elapsed.total_seconds()   # 0.142 — useful for SLA assertions
response.url                       # final URL after any redirects
response.history                   # list of intermediate redirects

response.ok is a quick "did it succeed at the HTTP level" boolean. Useful, but raise_for_status() (next section) is what most tests should use.

Failing fast on errors — raise_for_status()

If you want a 4xx or 5xx response to abort the test loudly, call raise_for_status():

response = requests.get("https://api.example.com/missing")
response.raise_for_status()        # raises HTTPError for 4xx / 5xx

A passing test that gets a 500 should fail visibly. A failing call that you assumed was succeeding will silently trip up later code with confusing errors. raise_for_status() collapses that ambiguity.

The pattern in test code:

response = requests.get(url)
response.raise_for_status()
data = response.json()
# ... use data ...

For a defensive check that expects a particular failure, compare response.status_code directly:

response = requests.get("https://api.example.com/users/0")
assert response.status_code == 404, f"expected 404, got {response.status_code}"

Timeouts — always pass one

By default, requests has no timeout — it will wait forever for a hanging server. In a test suite that's a recipe for stuck CI jobs. Always pass timeout=:

response = requests.get("https://api.example.com/users", timeout=5)

timeout=5 waits up to 5 seconds for the server to start sending bytes; if it doesn't, requests.exceptions.Timeout is raised. For finer control: timeout=(connect, read) accepts a tuple — timeout=(3, 10) for "3s to connect, 10s to read."

A common API test pattern: log in (gets a session cookie), then perform actions while authenticated. Session keeps cookies and default headers across calls:

session = requests.Session()
 
session.post("https://api.example.com/login", json={
    "email": "alice@test.com",
    "password": "SecurePass123"
})
# the session now holds whatever cookies the login set
 
response = session.get("https://api.example.com/dashboard")
print(response.json())   # authenticated request — cookies sent automatically

You can also set default headers once on the session: session.headers.update({"X-Tenant": "acme"}) and they apply to every subsequent request. For test scripts with many calls, prefer Session over re-passing headers.

Common error types

Three exceptions you'll meet, all from requests.exceptions:

import requests
 
try:
    response = requests.get("https://api.example.com/users", timeout=5)
    response.raise_for_status()
    data = response.json()
except requests.exceptions.Timeout:
    print("Request timed out")
except requests.exceptions.ConnectionError:
    print("Could not connect — DNS, network, or refused")
except requests.exceptions.HTTPError as e:
    print(f"HTTP error: {e.response.status_code}")

We'll cover try/except properly in chapter 6. For now, know that wrapping a request in one stops a single bad call from killing the whole script.

A QA example — login then fetch profile

A short end-to-end script that logs in, fetches the user profile, and asserts the basics:

import requests
 
BASE = "https://api.example.com"
 
def login(email: str, password: str) -> requests.Session:
    """Log in and return an authenticated Session."""
    session = requests.Session()
    response = session.post(
        f"{BASE}/login",
        json={"email": email, "password": password},
        timeout=5
    )
    response.raise_for_status()
    return session
 
def get_profile(session: requests.Session) -> dict:
    response = session.get(f"{BASE}/me", timeout=5)
    response.raise_for_status()
    return response.json()
 
session = login("alice@test.com", "SecurePass123")
profile = get_profile(session)
 
assert profile["email"] == "alice@test.com"
assert profile["role"] in ("admin", "tester", "viewer")
print(f"Logged in as {profile['name']} ({profile['role']})")

Two helpers, three lines of test logic. The Session keeps the cookies between calls; raise_for_status() makes any HTTP failure abort the script with a useful traceback. That's the skeleton most API tests share.

A request, end to end

Step 1 of 6

Build the request

Pick a verb (get/post/...), a URL, plus headers, params, and json= as needed. requests handles encoding for you.

Six steps, the same shape on every call. Internalise it once and the rest of API testing is just picking the right verb and the right assertion.

⚠️ Common mistakes

  • No timeout passed. requests.get(url) with no timeout waits forever on a hung server. CI jobs sit there until they're killed externally. Always pass timeout=5 (or whatever your SLA allows). Treat it as a required argument.
  • Confusing json= and data=. json=payload sends {"key": "value"} as JSON with the right Content-Type. data=payload sends key=value&... as form-urlencoded. Pick the one your API expects — using the wrong one usually returns a 400 or 415.
  • Calling .json() without checking the status. A 500 response from an HTML error page raises JSONDecodeError from .json(), masking the real status. Check response.ok (or call response.raise_for_status()) before parsing.

🎯 Practice task

Hit a real public API. 25-30 minutes.

  1. Make sure requests is installed: pip install requests. Use a venv if possible.
  2. Create api_play.py. Use JSONPlaceholder — a free public test API.
  3. GET — fetch https://jsonplaceholder.typicode.com/users. Pass timeout=5. Assert response.status_code == 200. Print the count of users and the first user's name from response.json().
  4. GET with params — fetch https://jsonplaceholder.typicode.com/posts with params={"userId": 1}. Print response.url to confirm the query string and len(response.json()) to see how many posts came back.
  5. POST — call requests.post("https://jsonplaceholder.typicode.com/posts", json={"title": "QA test", "body": "hello", "userId": 1}). Print the resulting status code (should be 201) and the new post's id.
  6. Headers — repeat the GET from step 3 but pass headers={"Accept": "application/json", "X-Request-Id": "test-001"}. Print response.request.headers["X-Request-Id"] to confirm it was sent.
  7. Error handling — call requests.get("https://jsonplaceholder.typicode.com/users/9999", timeout=5). Print the status_code. Wrap a response.raise_for_status() in a try/except requests.exceptions.HTTPError and print the caught error.
  8. Timing — print response.elapsed.total_seconds() after one of your calls. Add assert response.elapsed.total_seconds() < 2.0.
  9. Stretch: open a Session, set a default header (session.headers.update({"X-Suite": "smoke"})), then make three GET calls through the session. Confirm via response.request.headers that the custom header is sent on every call.

You can now make any HTTP call a test needs to make. The next lesson focuses on what to do with the response — parsing, validating, and asserting on the JSON you get back.

// tip to track lessons you complete and pick up where you left off across devices.