Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced+200 XP

Working with APIs in Python

The requests library, REST calls, authentication, and the error-handling patterns real production code needs when talking to the outside world.

50 min August 2026
// Part 01 — requests and Your First GET

Talking to a Web API — The requests Library

Almost every real application eventually needs to talk to something outside itself — a payment processor, a weather service, an internal microservice owned by another team. That conversation almost always happens over HTTP, using the same request/response model a browser uses when it loads a page, except the response is data (usually JSON) instead of HTML meant for a human to read.

Python's standard library has a built-in way to make HTTP requests (urllib), but it is verbose and easy to get wrong. requests — a third-party package, installed with pip install requests — became the de facto standard years ago precisely because it makes the common case simple, and it is what you will find in the overwhelming majority of real Python codebases that talk to APIs.

A first GET request
import requests

response = requests.get("https://api.github.com/users/octocat")

print(response.status_code)   # 200
print(response.headers["content-type"])   # application/json; charset=utf-8
print(response.text[:80])     # the raw response body, as a string

requests.get() returns a Response object, not the data itself — it carries the status code, the headers, and the body, all together, so you can inspect exactly what came back before deciding how to use it. This is a deliberate design: unlike a plain function that either returns your data or throws, requests hands you the full picture and lets you decide what "success" means for your specific call, which matters a great deal once you reach Part 03.

// Part 02 — Params, Headers, and POST

Query Parameters, Custom Headers, and Sending Data

Query parameters

Rather than manually building a URL with a trailing ?key=value&key2=value2 string — easy to get wrong, especially once a value needs URL-encoding — pass a plain dictionary as the params argument, and requests builds the query string correctly for you.

Query parameters via params=
import requests

response = requests.get(
    "https://api.openweathermap.org/data/2.5/weather",
    params={"q": "Austin,TX,US", "units": "imperial", "appid": "YOUR_KEY"},
)

print(response.url)
# https://api.openweathermap.org/data/2.5/weather?q=Austin%2CTX%2CUS&units=imperial&appid=YOUR_KEY
# — note "Austin,TX,US" was automatically URL-encoded; you never had to think about it

Headers

Headers describe metadata about the request — what format you accept back, how you are authenticating (Part 04), or a custom header a specific API requires. Pass them as a dictionary too, via the headers argument.

Custom headers
response = requests.get(
    "https://api.example.com/orders",
    headers={"Accept": "application/json", "X-Client-Version": "3.2.1"},
)

POST requests — sending a JSON body

Creating or updating something on the server almost always means a POST (or PUT/PATCH) request with a body. Passing a Python dictionary as the json argument (not data) does two things at once — it serializes the dictionary to a JSON string, and it sets the Content-Type: application/json header automatically, which most modern APIs require in order to parse the body correctly at all.

POST with a JSON body — the pattern you will use constantly
import requests

payload = {"customer_id": 4471, "item": "wireless-mouse", "quantity": 2}

response = requests.post(
    "https://api.example.com/orders",
    json=payload,
    headers={"Authorization": "Bearer YOUR_TOKEN"},
)

print(response.status_code)   # 201, typically, for a successful creation
created_order = response.json()
🎯 Pro Tip
json= vs data=json=payload serializes a dict to JSON and sets the content type header for you; data=payload sends a plain form-urlencoded body instead, which is what older or non-JSON APIs (and HTML forms) actually expect. Sending data= to an API that requires JSON is a genuinely common source of confusing 400 errors — always check which one the API you are calling documents.
// Part 03 — Status Codes

Status Codes and raise_for_status() — Failing Loudly, Not Silently

requests does not raise an exception just because a server responded with an error status like 404 or 500 — as far as the HTTP transport is concerned, a 500 Internal Server Error is still a complete, successful response; it just happens to carry bad news in its status code. If your code doesn't explicitly check for this, it will happily treat an error page as if it were real data.

The silent failure — nothing here raises an exception
response = requests.get("https://api.example.com/orders/99999999")
print(response.status_code)   # 404 — the order doesn't exist

data = response.json()   # might raise its own error, or might return an
                          # {"error": "not found"} body that your code processes
                          # as if it were a real order, with no crash at all

response.raise_for_status() closes this gap: it inspects the status code and raises an requests.exceptions.HTTPError if it is 4xx or 5xx, doing nothing at all if it is 2xx. Calling it immediately after every request is one of the single highest-value habits in this entire module.

raise_for_status() — turning a silent bad response into a loud, catchable error
response = requests.get("https://api.example.com/orders/99999999")
response.raise_for_status()   # raises HTTPError here — code below never runs
data = response.json()

# requests.exceptions.HTTPError: 404 Client Error: Not Found for url:
# https://api.example.com/orders/99999999
The status code ranges worth knowing
2xx   Success — 200 OK, 201 Created, 204 No Content (success, empty body)
3xx   Redirection — requests follows these automatically by default
4xx   Client error — YOUR request was wrong (401 Unauthorized, 404 Not Found,
      429 Too Many Requests — you are being rate-limited)
5xx   Server error — the API itself failed; often worth retrying (Part 06)
// Part 04 — Authentication

API Keys and Bearer Tokens

Most real APIs require proving who you are on every request. The two patterns you will meet constantly are an API key (a fixed secret string identifying your application) and a bearer token (typically a short-lived token obtained after a login/OAuth step). Both are almost always sent as a header, not as a URL parameter — putting a secret in a URL means it ends up in server logs, browser history, and any proxy in between.

API key — commonly a custom header
response = requests.get(
    "https://api.example.com/data",
    headers={"X-API-Key": "sk_live_51H8..."},
)
Bearer token — the standard Authorization header format
response = requests.get(
    "https://api.example.com/account",
    headers={"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},
)

The exact header name and format (X-API-Key, Authorization: Bearer ..., Authorization: Token ...) varies by provider — always check the specific API's documentation rather than assuming.

⚠️ Important
Never hardcode a real API key or token directly in source code. It ends up in your git history permanently, even if you delete it in a later commit, and it is one of the single most common ways credentials leak in real incidents (automated scanners actively search public GitHub repos for exactly this pattern). Load secrets from environment variables (os.environ["API_KEY"]) or a dedicated secrets manager, and add any local .env file to .gitignore.
// Part 05 — The Missing-Timeout Trap

The Single Most Common Production Bug in requests Code

Here is a fact that catches an enormous number of engineers, often only after it causes a real incident: requests has no default timeout. If the server on the other end never responds — a network issue, an overloaded upstream service, a firewall silently dropping the connection — a call like requests.get(url) with no timeout argument will simply hang, waiting indefinitely, for as long as the process is alive.

The bug — this can hang forever, and there is nothing stopping it
import requests

# If this endpoint never responds, this line never returns.
# Not "eventually times out" — genuinely never, by default.
response = requests.get("https://api.example.com/slow-endpoint")

In a script run once from a terminal, a hang is merely annoying. In a production service, it is a genuinely serious failure mode: a worker thread or process blocked on a single hung request stops doing anything else, requests pile up behind it, and — depending on how the service is deployed — this can exhaust an entire worker pool and take the whole service down, triggered by one slow upstream dependency that never actually errored, just never answered.

The fix — always pass a timeout, on every call, no exceptions
response = requests.get(
    "https://api.example.com/slow-endpoint",
    timeout=5,   # seconds — raises requests.exceptions.Timeout if exceeded
)

# A tuple lets you set connect and read timeouts separately —
# genuinely useful, since a slow TCP handshake and a slow response body
# are different failure modes worth distinguishing:
response = requests.get(
    "https://api.example.com/slow-endpoint",
    timeout=(3.05, 10),   # (connect timeout, read timeout)
)
⚠️ Important
timeout is not optional, and there is no sane global default to fall back on. Treat a missing timeout= argument on any requests call as a bug, every time, in every code review. A reasonable habit: define a shared default (e.g. DEFAULT_TIMEOUT = 5) once per project and pass it everywhere, rather than re-deciding the number — or forgetting it — on every single call site.
// Part 06 — Parsing JSON and Handling Network Failures

What Can Actually Go Wrong, and Catching It Correctly

response.json() can itself fail

response.json() parses the response body as JSON and raises a requests.exceptions.JSONDecodeError if the body isn't valid JSON at all — which happens more often than it sounds, since a misconfigured proxy, a maintenance page, or a plain-text error message from a load balancer can all return a 200 or an error status with an HTML or plain-text body instead of JSON.

Defensive JSON parsing
try:
    response = requests.get(url, timeout=5)
    response.raise_for_status()
    data = response.json()
except requests.exceptions.JSONDecodeError:
    log_error(f"Non-JSON response from {url}: {response.text[:200]!r}")
    raise

The requests exception hierarchy

Every exception requests can raise inherits from requests.exceptions.RequestException, which makes it possible to catch broad network problems in one place while still handling specific cases (like a timeout) differently when it matters.

Catching the exceptions you will actually see in production
import requests

try:
    response = requests.get(url, timeout=5)
    response.raise_for_status()
except requests.exceptions.Timeout:
    log_error(f"Timed out calling {url}")
except requests.exceptions.ConnectionError:
    log_error(f"Could not connect to {url} — DNS failure, refused connection, or network is down")
except requests.exceptions.HTTPError as e:
    log_error(f"{url} returned an error status: {e}")
except requests.exceptions.RequestException as e:
    # catches anything else in the requests exception family —
    # a genuine safety net, without swallowing unrelated bugs the way a bare
    # "except:" would (see the Exception Handling module for why that matters)
    log_error(f"Unexpected requests error calling {url}: {e}")
    raise

Retries with backoff

A single transient failure — a brief network blip, a 503 while an upstream service restarts — is often worth retrying automatically rather than failing the whole operation immediately. urllib3 (which requests is built on) ships a Retry helper that can be attached to a session to retry automatically, with exponential backoff, on specific status codes.

Automatic retries with exponential backoff
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

session = requests.Session()
retry_strategy = Retry(
    total=3,                                 # retry up to 3 times
    backoff_factor=0.5,                      # 0.5s, 1s, 2s between attempts
    status_forcelist=[429, 500, 502, 503, 504],
)
session.mount("https://", HTTPAdapter(max_retries=retry_strategy))

response = session.get("https://api.example.com/data", timeout=5)
💡 Note
Retries are appropriate for transient failures (timeouts, 5xx, rate limiting) — never retry a 4xx client error like 400 or 404 automatically; the request itself was wrong, and retrying it will just fail identically every time while adding latency.
// Part 07 — Session Reuse

requests.Session() — Connection Pooling for Repeated Calls

Every plain requests.get(...) or requests.post(...) call, on its own, opens a fresh TCP connection (and, for HTTPS, redoes the full TLS handshake) — real, measurable overhead that adds up quickly if your code makes many calls to the same host, such as paginating through an API's results or calling several endpoints on the same service in a row.

Without a session — a new connection for every single call
import requests

for page in range(1, 11):
    response = requests.get(
        "https://api.example.com/orders",
        params={"page": page},
        timeout=5,
    )
    process(response.json())
# 10 separate TCP connections and TLS handshakes to the same host
With a session — connections are reused (keep-alive), genuinely faster
import requests

with requests.Session() as session:
    session.headers.update({"Authorization": "Bearer YOUR_TOKEN"})

    for page in range(1, 11):
        response = session.get(
            "https://api.example.com/orders",
            params={"page": page},
            timeout=5,
        )
        process(response.json())
# The underlying TCP connection is kept alive and reused across all 10 calls

A Session also lets you set default headers (like Authorization) once instead of repeating them on every call, which is both less error-prone and exactly what the retry configuration from Part 06 is typically attached to. For any code making more than a handful of calls to the same host, reaching for a Session over plain requests.get() calls is close to a default best practice.

// Part 08 — Real World
💼 What This Looks Like at Work

The Missing Timeout That Took Down an Austin Fintech's Checkout

Scenario — Payments startup, Austin · Production incident, 2:14am

A checkout service calls a third-party fraud-scoring API as one step in processing every order — a call written eight months earlier, working fine in every test and in production, right up until the night the fraud-scoring vendor had a partial outage: their servers accepted connections but simply stopped sending responses for a subset of requests.

What actually happened

The call responsible — written months earlier, never revisited
def check_fraud_score(order):
    response = requests.post(
        "https://fraud-api.vendor.com/score",
        json={"order_id": order.id, "amount": order.total},
        headers={"Authorization": f"Bearer {FRAUD_API_KEY}"},
    )
    response.raise_for_status()
    return response.json()["score"]

No timeout= argument, anywhere. When the vendor's servers stopped responding, every checkout worker thread that called check_fraud_score() simply hung — not erroring, not timing out, just waiting. Within about eleven minutes, every worker in the checkout service's thread pool was stuck on this exact call, and the entire checkout flow stopped processing orders for every customer, not just the ones whose fraud check happened to hit the affected vendor servers.

The fix, and what changed afterward

The immediate fix, deployed during the incident
def check_fraud_score(order):
    response = requests.post(
        "https://fraud-api.vendor.com/score",
        json={"order_id": order.id, "amount": order.total},
        headers={"Authorization": f"Bearer {FRAUD_API_KEY}"},
        timeout=(3, 5),
    )
    response.raise_for_status()
    return response.json()["score"]

The longer-term fix mattered more: the team added a lint rule — enforced in CI, exactly like the bare-except: rule mentioned in the Exception Handling module — that fails a build on any requests.get/post/put/patch/delete call missing an explicit timeout= argument. A single missing keyword argument, in one function, had been enough to take down checkout for every customer, for eleven minutes, because of one vendor's outage that should have only affected fraud scoring specifically.

// Part 09 — Misconceptions

Four Misconceptions About Working with APIs in Python

✕ ""requests will time out on its own eventually if a server doesn't respond""
There is no default timeout at all — a call with no timeout= argument can hang indefinitely, exactly as shown in the Real World incident above. Always pass an explicit timeout on every request.
✕ ""A 404 or 500 response will raise an exception automatically, just like a network failure would""
requests only raises an exception for genuine transport-level failures (a timeout, a connection refused) — an HTTP error status like 404 or 500 is still a complete, "successful" response as far as the transport is concerned. You must call raise_for_status() (or check response.status_code yourself) to turn an error status into an exception.
✕ ""json= and data= are basically interchangeable ways to send a request body""
json=payload serializes a dict to a JSON string and sets Content-Type: application/json automatically; data=payload sends form-urlencoded data instead, which many modern JSON APIs will reject outright, often with a confusing 400 error that doesn't obviously point back to this distinction.
✕ ""It doesn't matter whether you use a Session or plain requests.get() calls — the performance difference is negligible""
A Session reuses the underlying TCP connection (and TLS handshake) across multiple requests to the same host, which is a genuinely measurable speedup for anything making more than a handful of calls to the same API — pagination loops being the most common example.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

Why is it important to always pass a timeout to a requests call, and what happens if you don't?
requests has no default timeout — without one, a call can hang indefinitely if the server never responds, which is different from and worse than a normal error, since nothing fails loudly. In a production service, a single hung call can block a worker thread or process, and if enough calls hang simultaneously it can exhaust an entire worker pool, taking down functionality unrelated to the slow dependency. Always pass timeout= (ideally as a tuple of connect and read timeouts) on every request.
Does requests raise an exception for a 404 or 500 response? Why or why not?
No — a 4xx or 5xx status is still a complete, successful HTTP response from the transport's perspective; requests only raises for genuine transport failures like connection errors or timeouts. Calling response.raise_for_status() explicitly checks the status code and raises an HTTPError for 4xx/5xx responses, which is necessary because otherwise code can silently treat an error page as if it were valid data.
What is the difference between the json= and data= arguments to requests.post()?
json=payload serializes a Python dict to a JSON string body and automatically sets the Content-Type: application/json header. data=payload sends the payload as form-urlencoded data instead (or raw bytes/string if given directly), which is the wrong format for most modern JSON APIs and often produces a confusing 400 error rather than an obvious one.
Where should authentication credentials like API keys go in a request, and where should they never go?
Credentials should be sent as a header — commonly Authorization: Bearer <token> or a custom header like X-API-Key — and loaded at runtime from environment variables or a secrets manager, never hardcoded in source code. They should never be placed in a URL query parameter, since URLs are commonly logged by servers, proxies, and browser history, exposing the credential far more broadly than a header would.
What does requests.Session() provide that repeated plain requests.get() calls do not?
A Session reuses the underlying TCP connection (avoiding a fresh handshake, and for HTTPS a fresh TLS negotiation, on every call) when making multiple requests to the same host, which is a real, measurable performance improvement. It also lets you set default headers, cookies, and retry/adapter configuration once, applied automatically to every request made through that session, rather than repeating them on every call.
// Common Mistakes

API Integration Mistakes Engineers Make Constantly

Making a requests call with no timeout=
As shown in the Real World example, this can hang indefinitely and, in a threaded or process-pooled service, can exhaust the entire worker pool from a single unresponsive dependency. Always pass an explicit timeout.
Never calling raise_for_status() or checking response.status_code
A 4xx/5xx response does not raise an exception on its own — code that skips this check can silently process an error page or an empty/malformed body as if it were valid data.
Using data= when the API expects json=
data=payload sends form-urlencoded content, not JSON — many APIs will reject it with a 400 error that gives little indication the content type itself is the problem. Check the API's documentation for the expected body format.
Hardcoding an API key or token directly in source code
It ends up permanently in git history, even after being removed in a later commit, and is one of the most common real ways credentials leak. Load secrets from environment variables or a secrets manager instead.
Retrying every failed request the same way, including 4xx client errors
Retrying a 400 or 404 will simply fail identically every time — the request itself was wrong, not the network. Reserve automatic retries for transient failures: timeouts, connection errors, and 5xx / 429 responses.
// Error Library

Errors You Will Hit Working with APIs — And Exactly Why

requests.exceptions.ConnectTimeout: HTTPSConnectionPool(host='api.example.com', port=443): Max retries exceeded
Cause: The connection itself could not be established within the timeout window — the host may be down, unreachable, or a firewall is silently dropping the connection.
Fix: Confirm the URL and network path are correct (try curl -v against the same URL). If the endpoint is simply slow, increase the connect timeout deliberately, rather than removing the timeout entirely.
requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
Cause: The server accepted the connection but closed it before sending a response — often a server-side crash, a proxy timeout shorter than your client timeout, or a load balancer dropping idle connections.
Fix: Add retry logic for this specific transient condition (Part 06), and check whether the server enforces a shorter keep-alive window than your client assumes when reusing a Session.
requests.exceptions.HTTPError: 401 Client Error: Unauthorized for url: https://api.example.com/orders
Cause: The request was missing valid authentication — a missing, expired, or malformed Authorization header, or an API key that was revoked.
Fix: Confirm the credential is being loaded correctly (a common cause is an empty environment variable) and check the exact header name/format the API expects — this varies between "Bearer", "Token", and custom header names.
requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Cause: response.json() was called on a body that isn't valid JSON — commonly an empty body (e.g. a 204 No Content response), or an HTML error page returned by a proxy or load balancer instead of the expected API response.
Fix: Log response.text before parsing when this happens, to see what was actually returned. Check response.status_code and the body itself before assuming a 200-range response always contains valid JSON.
KeyError: 'score'
Cause: Code assumed a specific key would always be present in a parsed JSON response (e.g. data["score"]) — an assumption that breaks the moment the API's response shape changes, or an unexpected error-shaped body was returned instead of the expected success shape.
Fix: Use data.get("score") with a sensible default or explicit handling for a missing key, and validate the overall response shape before relying on it in code that runs against a real, evolving external API.

🎯 Key Takeaways

  • requests is the de facto standard library for HTTP calls in Python — install with pip install requests, use params= for query strings and json= for a JSON body.
  • Status codes are not exceptions by default. Call response.raise_for_status() (or check response.status_code) explicitly, or a 4xx/5xx response can be silently treated as valid data.
  • API keys and bearer tokens belong in headers, loaded from environment variables or a secrets manager — never hardcoded in source code and never placed in a URL.
  • requests has NO default timeout — a call without timeout= can hang indefinitely, and in a threaded/pooled service can exhaust the entire worker pool from one slow dependency. Always pass one.
  • response.json() can itself raise a JSONDecodeError if the body isn't valid JSON — a real possibility from proxies, maintenance pages, or malformed error responses.
  • Catch specific requests exceptions (Timeout, ConnectionError, HTTPError) before a broader RequestException, and only retry transient failures (timeouts, connection errors, 5xx/429) — never retry a 4xx client error automatically.
  • requests.Session() reuses the underlying TCP connection across multiple calls to the same host — a real performance win for pagination loops and any code making several calls to one API.

What comes next

Module 38 covers unit testing with pytest — fixtures, parametrization, mocking, and testing as a genuine habit rather than an afterthought bolted on at the end.

Module 38 → Unit Testing with pytest
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...