Python for APIs: requests, httpx and secrets
Make HTTP requests with requests and httpx, read methods, status codes and headers, send API keys safely, validate responses, and handle timeouts and retries the way production code does.
Words in this lessonHTTP · Status code · HTTP header · Timeout · Exponential backoff · API · API key
- HTTP
- The set of rules programs use to send requests and responses over the web. Like a postal system with standard order slips and reply slips that every business understands.
- Status code
- A three-digit number at the top of every HTTP response saying what happened. Like the stamp on a reply letter: "Done", "We do not know you", "Too many orders today, wait".
- HTTP header
- A name and value attached to an HTTP request or response that describes the message. Like the details written on an envelope: sender, account number, language of the letter inside.
- Timeout
- The longest your code will wait for something before giving up. Like deciding you will wait five minutes at the counter, not all night.
- Exponential backoff
- Retrying a failed request with waits that double each time. Like calling back a busy shop after one minute, then two, then four, instead of redialling non-stop.
- API
- A published doorway that lets one program ask another program to do something, in an agreed format. Like a bakery counter: you do not walk into the kitchen; you order in a set way and get your order back.
- API key
- A long secret string that identifies your account when your code calls an API. Like the key to the shop's back door: anyone holding it gets in, and whatever they take goes on your tab.
On this page
- Arjun's first attempt
- The envelope and the reply slip
- Following one request there and back
- Sending data: POST with a JSON body
- Opening the hood: methods, status codes and headers
- The fixed version, step by step
- Retrying the temporary failures
- Many lookups at once, with async httpx
- Keeping keys safe on the wire
- Reading a real, messy response
- Where it breaks
- Arjun explains it to Meera
- Try it yourself
- Common mistakes
- Recap
- Go deeper
Diwali night, 8:40 p.m. Crumb’s delivery partner has finally given Crumb access to their rider-tracking API (application programming interface: a way for one program to ask another for data or actions). Arjun has wired it into the support bot. When a customer asks “where’s my order?”, the bot asks the partner’s API where the rider is and replies with an ETA.
At 8:41, the partner’s servers start struggling under festival traffic. They accept Crumb’s requests and then never reply. Arjun’s code waits. And waits. For eleven minutes, every “where’s my order?” message piles up behind the first stuck request, sixty-three customers in all, and the bot says nothing to any of them.
At 9:30, a second problem: the partner rotates API keys as part of their outage response. Arjun’s code crashes with KeyError: 'rider'. And when Priya opens the server logs to investigate, she finds Crumb’s partner API key printed in plain text, on every line.
“Three bugs,” Priya says the next morning. “All three come from not knowing what’s actually inside an HTTP request and response. Every AI API you’ll use in this course, Claude, OpenAI, Gemini, a vector database, is an HTTP API underneath. So let’s open the envelope.”
Important
The idea in one sentence: Calling an API from Python means sending an HTTP request (a method, an address, headers and maybe a JSON body) and getting back a response (a status code, headers and a body); robust code always sets a timeout, checks the status before trusting the body, sends keys in headers read from the environment, and validates what comes back.
Arjun’s first attempt
Here’s the core of Arjun’s Diwali-night code. (It points at a made-up address, so don’t run it.)
import requests
def rider_eta(order_id):
url = f"https://partner.example/v1/orders/{order_id}/rider?api_key={PARTNER_KEY}"
response = requests.get(url)
return response.json()["rider"]["eta_minutes"]
Four lines, three bugs:
- No timeout.
requests.getwaits forever by default. When the partner’s server accepted the connection but never answered, the bot hung. - No status check. When the key was rotated, the partner replied with status
401and a body of{"error": "invalid_api_key"}. The code reached straight for["rider"], which wasn’t there. - The key in the URL. Addresses get written into logs everywhere along the way, including Crumb’s own server logs. So the key got logged too.
The envelope and the reply slip
You met HTTP from the outside in the Developer Toolkit lesson on how the web talks. Now let’s see it from inside Python.
“Think of the old post office counter,” Priya says. “You hand over an envelope. What’s written on the outside?”
“The address. Maybe ‘urgent’ or ‘fragile’.”
“And inside?”
“The letter.”
“And when the reply comes, before you even open it, what’s stamped on the envelope?”
Arjun grins. “Delivered. Or ‘return to sender: no such address’.”
That’s HTTP (HyperText Transfer Protocol), the format almost every web API uses:
| Post office | HTTP request | Example |
|---|---|---|
| What you want done | Method | GET (fetch), POST (create or send) |
| The address | URL | https://partner.example/v1/orders/CR-2210/rider |
| Writing on the envelope | Headers | Authorization: Bearer ..., Content-Type: application/json |
| The letter inside | Body (optional) | {"order_id": "CR-2210", "reason": "crushed box"} |
| Post office | HTTP response | Example |
|---|---|---|
| The stamp on the reply | Status code | 200 (OK), 404 (not found), 503 (service unavailable) |
| Writing on the reply envelope | Headers | Content-Type: application/json, Retry-After: 2 |
| The reply letter | Body | {"rider": {"name": "Imran", "eta_minutes": 12}} |
Arjun’s code tore open every reply without looking at the stamp, and waited at the counter forever.
Following one request there and back
Let’s send a real request and look at every part. We’ll use httpbin.org, a free public testing service that echoes back whatever you send it, which makes it perfect for seeing what actually went over the wire. Install requests, the most widely used HTTP library in Python, with uv add requests.
import requests
response = requests.get(
"https://httpbin.org/get",
params={"order_id": "CR-2210", "store": "Baner"}, # added to the URL for you
headers={"X-Crumb-Client": "inbox-bot"}, # writing on the envelope
timeout=10, # give up after 10 seconds
)
print(response.request.method, response.request.path_url)
print(response.status_code)
print(response.headers["Content-Type"])
data = response.json() # parse the JSON body into a Python dict
print(data["args"])
print(data["headers"]["X-Crumb-Client"])
GET /get?order_id=CR-2210&store=Baner
200
application/json
{'order_id': 'CR-2210', 'store': 'Baner'}
inbox-bot
Trace it step by step:
| Step | What happened | Value |
|---|---|---|
| 1 | requests built the URL, turning params into a query string after ? |
/get?order_id=CR-2210&store=Baner |
| 2 | It added your header to the envelope, alongside some of its own | X-Crumb-Client: inbox-bot |
| 3 | It sent a GET request to httpbin.org and started a 10-second clock |
|
| 4 | httpbin replied before the clock ran out, stamped 200 |
response.status_code is 200 |
| 5 | The reply’s Content-Type header said the body is JSON |
application/json |
| 6 | response.json() parsed the body text into a dict |
{"args": {...}, "headers": {...}, ...} |
| 7 | We read the fields we wanted | data["args"]["order_id"] is "CR-2210" |
Every API call in this course, including every call to Claude, follows these seven steps. The SDKs hide steps 1 to 6 behind a friendly function, but they’re still happening.
Sending data: POST with a JSON body
To send data, such as a refund request, you usually POST a JSON body:
import requests
response = requests.post(
"https://httpbin.org/post",
json={"order_id": "CR-2210", "reason": "crushed box"}, # becomes the JSON body
timeout=10,
)
print(response.status_code)
print(response.request.headers["Content-Type"])
print(response.json()["json"]) # httpbin echoes the body it received
200
application/json
{'order_id': 'CR-2210', 'reason': 'crushed box'}
Passing json= does two jobs: it converts your dict to JSON text, and it sets the Content-Type: application/json header so the server knows how to read it. This is exactly what the Claude SDK does with your messages list.
Opening the hood: methods, status codes and headers
Methods say what you want done. Four cover nearly everything:
| Method | Meaning | Crumb example |
|---|---|---|
GET |
Fetch something; changes nothing | Get a rider’s ETA |
POST |
Create something, or send data to be processed | Create a refund; send a prompt to Claude |
PUT / PATCH |
Replace / partly update something | Change a delivery address |
DELETE |
Remove something | Cancel a scheduled order |
Status codes are three-digit stamps, grouped by their first digit. You don’t need to memorise them all, only the families and a handful of common ones:
| Code | Family | Meaning | What your code should do |
|---|---|---|---|
200 |
2xx: success | OK | Read the body |
201 |
2xx | Created | Read the body (often the new thing) |
400 |
4xx: your request is wrong | Bad request: malformed data | Fix the request; don’t retry as is |
401 |
4xx | Unauthorized: missing or invalid key | Check the key; don’t retry |
403 |
4xx | Forbidden: valid key, not allowed | Check permissions; don’t retry |
404 |
4xx | Not found | Handle “doesn’t exist” |
429 |
4xx | Too many requests: rate limited | Wait (see Retry-After), then retry |
500 |
5xx: their server failed | Internal server error | Retry after a pause, a few times |
503 |
5xx | Service unavailable: overloaded or down | Retry after a pause, a few times |
The rule of thumb: 4xx means “you did something wrong” (except 429, “slow down”), and retrying won’t help. 5xx means “they had a problem”, and a retry after a short wait often works.
Headers carry information about the message: what format the body is in (Content-Type), who you are (Authorization), how long to wait before retrying (Retry-After). Header names aren’t case-sensitive, so content-type and Content-Type are the same header.
Note
Pause and think: During the Diwali outage, the partner’s API returned four different status codes at different times: 401, 404, 429 and 503. For each, should the bot retry the same request automatically?
Show the answer
401(invalid key): no. The key is wrong, and it’ll be wrong on every retry. Alert a human to update the key.404(order not found): no. Tell the customer the partner doesn’t have the order yet, or check the order ID.429(rate limited): yes, after waiting, ideally as long as theRetry-Afterheader says.503(unavailable): yes, after a pause, a limited number of times, then give the customer a friendly “tracking is temporarily unavailable” message.
Retrying a 401 in a tight loop is a classic way to get your account temporarily blocked.
The fixed version, step by step
Now let’s rebuild the rider lookup properly. We can’t call the real partner from this lesson, so the first file is a stand-in: a pretend partner API that runs on your machine. It uses httpx, a modern HTTP library with almost the same interface as requests, plus async support. (uv add httpx.) Save this as fake_partner.py. You don’t need to understand its insides; it plays the partner, returning the same responses the real one did on Diwali night.
# fake_partner.py
"""A pretend delivery-partner API, so the examples run without the real one."""
import httpx
VALID_KEY = "test-key-123"
_attempts = {}
def _handler(request: httpx.Request) -> httpx.Response:
if request.headers.get("Authorization") != f"Bearer {VALID_KEY}":
return httpx.Response(401, json={"error": "invalid_api_key"})
order_id = request.url.path.split("/")[-2] # /v1/orders/<id>/rider
if order_id == "CR-3000": # busy on the first try only
_attempts[order_id] = _attempts.get(order_id, 0) + 1
if _attempts[order_id] == 1:
return httpx.Response(429, headers={"Retry-After": "1"}, json={"error": "rate_limited"})
if order_id == "CR-5000": # down completely
return httpx.Response(503, text="<html><h1>503 Service Unavailable</h1></html>")
if order_id in ("CR-2210", "CR-3000"):
return httpx.Response(200, json={
"order_id": order_id,
"status": "picked_up",
"rider": {"name": "Imran", "phone": "+91-98xxxxxx77", "eta_minutes": 12},
})
return httpx.Response(404, json={"error": "order_not_found"})
transport = httpx.MockTransport(_handler)
Add the key to your .env file, exactly as you learned in the files and environments lesson:
PARTNER_API_KEY=test-key-123
Now the real client code. Save it as partner_client.py:
# partner_client.py
import os
import httpx
from dotenv import load_dotenv
from pydantic import BaseModel
from fake_partner import transport
load_dotenv()
class Rider(BaseModel):
name: str
eta_minutes: int
class RiderStatus(BaseModel):
order_id: str
status: str
rider: Rider
class PartnerError(Exception):
"""Something went wrong talking to the delivery partner."""
class OrderNotFound(PartnerError):
pass
class PartnerUnavailable(PartnerError):
"""Temporary: rate limited or down. Worth retrying later."""
client = httpx.Client(
base_url="https://partner.example/v1",
headers={"Authorization": f"Bearer {os.environ['PARTNER_API_KEY']}"},
timeout=5.0, # never wait forever
transport=transport, # delete this line to talk to the real API
)
def rider_status(order_id: str) -> RiderStatus:
response = client.get(f"/orders/{order_id}/rider")
if response.status_code == 404:
raise OrderNotFound(f"Partner has no order {order_id}")
if response.status_code in (429, 500, 502, 503, 504):
raise PartnerUnavailable(f"Partner busy or down (HTTP {response.status_code})")
response.raise_for_status() # any other error status: a real bug, fail loudly
return RiderStatus.model_validate_json(response.text)
Line by line, here’s what each fix does:
base_urlandheaderson the client. Every request through this client starts with the same address and carries the sameAuthorizationheader. You set them once instead of on every call.Authorization: Bearer <key>. The standard way to send an API key: in a header, not in the URL. “Bearer” means “whoever carries this token is allowed in”, which is also why it must be kept secret.os.environ['PARTNER_API_KEY']. The key comes from the environment. Square brackets (not.get) make the program fail immediately at startup with aKeyErrorif the key is missing, rather than sendingBearer None.timeout=5.0. If the partner hasn’t replied within 5 seconds,httpxraiseshttpx.TimeoutExceptionand your code moves on. (httpxactually defaults to 5 seconds, unlikerequests, which defaults to waiting forever. Set it explicitly anyway, so readers know you chose it.)- Status checks before the body. 404 and the “try later” codes become clear custom exceptions, exactly as the errors lesson taught.
raise_for_status()turns any other error status into anhttpx.HTTPStatusError. RiderStatus.model_validate_json. The body is validated with Pydantic, as in the type hints lesson. If the partner renames a field next month, you get a precise error at this line instead of a mysteriousKeyErrorsomewhere else. Notice the model leaves out the rider’sphone: Pydantic ignores fields you didn’t declare, so personal data you don’t need never enters your system.
Now use it:
from partner_client import PartnerError, rider_status
for order_id in ["CR-2210", "CR-9999", "CR-5000"]:
try:
status = rider_status(order_id)
print(f"{order_id}: {status.rider.name} arrives in {status.rider.eta_minutes} min")
except PartnerError as error:
print(f"{order_id}: {type(error).__name__}: {error}")
CR-2210: Imran arrives in 12 min
CR-9999: OrderNotFound: Partner has no order CR-9999
CR-5000: PartnerUnavailable: Partner busy or down (HTTP 503)
Three different outcomes, each with a clear name. The bot can now reply sensibly to each: an ETA, “we can’t find that order with our delivery partner yet”, or “live tracking is busy right now; your order is on its way”.
Look at what CR-5000 returned: an HTML page, not JSON. That happens a lot when servers are in trouble, because a proxy in front of the real server answers with its own error page. Arjun’s original response.json() would have crashed with a JSONDecodeError. Checking the status first means we never try to parse it.
Retrying the temporary failures
Order CR-3000 is the rate-limited one: the partner says “429, try again in 1 second” on the first attempt, then answers normally. A small retry helper handles that:
import time
from partner_client import PartnerUnavailable, rider_status
def with_retries(order_id, attempts=3):
for attempt in range(1, attempts + 1):
try:
return rider_status(order_id)
except PartnerUnavailable as error:
if attempt == attempts:
raise # out of attempts: let it travel
wait = 2 ** (attempt - 1) # 1 s, then 2 s, then 4 s...
print(f"Attempt {attempt}: {error}. Waiting {wait}s")
time.sleep(wait)
status = with_retries("CR-3000")
print("Got it:", status.rider.eta_minutes, "min")
Attempt 1: Partner busy or down (HTTP 429). Waiting 1s
Got it: 12 min
The wait doubles after each failure: this is called exponential backoff, and it stops a crowd of clients from hammering a struggling server in lockstep. Only PartnerUnavailable is retried; a 404 or 401 fails immediately. Module 19 builds a proper version with jitter (a little randomness in the wait), respect for the Retry-After header, and fallbacks.
Many lookups at once, with async httpx
During the dinner rush, the bot needs ETAs for many orders at once. You know the pattern from the async lesson: an AsyncClient and asyncio.gather.
import asyncio
import os
import httpx
from dotenv import load_dotenv
from fake_partner import transport
load_dotenv()
async def eta(client, order_id):
response = await client.get(f"/orders/{order_id}/rider")
if response.status_code != 200:
return order_id, None
return order_id, response.json()["rider"]["eta_minutes"]
async def main():
async with httpx.AsyncClient(
base_url="https://partner.example/v1",
headers={"Authorization": f"Bearer {os.environ['PARTNER_API_KEY']}"},
timeout=5.0,
transport=transport,
) as client:
results = await asyncio.gather(*(eta(client, o) for o in ["CR-2210", "CR-9999", "CR-2210"]))
print(results)
asyncio.run(main())
[('CR-2210', 12), ('CR-9999', None), ('CR-2210', 12)]
httpx.AsyncClient has the same methods as httpx.Client, each awaited. That’s the main reason to prefer httpx over requests in AI apps: one library, both styles.
Keeping keys safe on the wire
Caution
Never put an API key in a URL, and never log it. URLs are recorded in server logs, browser history, proxy logs and error-tracking tools, all places where many people can read them. Send keys in the Authorization header (or the header the provider’s documentation names, such as x-api-key), read them from the environment, and if you log requests, log the method, the path and the status code, not the headers.
Note
Pause and think: The partner’s API uses HTTPS (the encrypted version of HTTP). Arjun argues: “HTTPS encrypts the whole request, URL included, so putting the key in the URL was safe.” What’s wrong with that argument?
Show the answer
He’s right that HTTPS encrypts the full URL while it’s travelling over the network. The problem is at the two ends. Once the request arrives, servers write URLs into their access logs as a matter of routine. Crumb’s own code logged the URL it was calling. Monitoring tools capture URLs of failed requests. Each of those copies holds the key in plain text, readable by everyone with access to those systems. That’s how the key ended up in Crumb’s logs. Headers are rarely logged by default, which is why keys go there.
Reading a real, messy response
The fake partner kept things tidy. Real APIs return big, nested JSON with dozens of fields you don’t need. Here’s a real one: GitHub’s public API, which needs no key, describing the repository that holds Python’s own source code:
import requests
response = requests.get("https://api.github.com/repos/python/cpython", timeout=10)
response.raise_for_status()
repo = response.json()
print(len(repo), "top-level fields")
print(repo["full_name"], "|", repo["language"], "|", repo["owner"]["login"])
print("Stars:", type(repo["stargazers_count"]).__name__)
print("Requests allowed per hour without a key:", response.headers["X-RateLimit-Limit"])
86 top-level fields
python/cpython | Python | python
Stars: int
Requests allowed per hour without a key: 60
Eighty-six fields, and you needed four. That’s normal. The skills from the data handling lesson, reading nested data carefully, using .get() for fields that might be missing, are exactly what you use here. And notice the rate limit arrives in a header: many APIs tell you how much of your allowance is left on every response (GitHub also sends X-RateLimit-Remaining). The exact number of fields may differ when you run this, because APIs add fields over time. That’s one more reason to validate only the fields you use.
Where it breaks
- No timeout. The Diwali-night bug.
requestswaits forever by default. Always passtimeout=. - Trusting the body before the status. Error responses have different bodies, or HTML instead of JSON. Check the status code first.
- Retrying requests that create things. If a
POSTthat creates a refund times out, did the refund happen? Maybe. Retrying blindly can refund twice. Good APIs accept an idempotency key (a unique ID you send with the request, so the server can recognise and ignore a duplicate); check the API’s docs before retrying anything that creates or charges. - Only reading the first page. APIs that return lists usually return them in pages (say, 100 items at a time) with a link or cursor to the next page. Code that reads one page silently misses the rest.
- Ignoring rate limits. Covered in the async lesson: respect
429s andRetry-After, and limit concurrency. - Logging whole requests. Headers carry keys, and bodies carry customer data. Log the minimum.
Arjun explains it to Meera
Arjun deploys the new partner client, and Priya tests it the cruel way: she points it at a test server that never replies. After five seconds, the bot tells the customer “Live tracking is busy right now; your order is on its way and we’ll message you when it’s close”, and moves on to the next message. The key now lives in the environment, travels in a header, and appears nowhere in the logs. The old key was revoked that morning.
“What actually went wrong on Diwali night?” Meera asks.
“Every time we ask the partner where a rider is, we send a letter and wait for a reply,” Arjun says. “Our code would wait forever, so when their system got overloaded, we stood at the counter for eleven minutes while sixty-three customers queued behind us. Now we wait five seconds at most, then give the customer a polite answer. Second, we used to open every reply without checking the stamp on the envelope. When the reply said ‘your key is wrong’, we tried to read a rider’s ETA from it and crashed. Now we check the stamp first. And our password was written on the outside of the envelope, where every sorting office kept a copy. Now it’s inside, in a header that doesn’t get logged.”
“And Claude? Is that the same?”
“Exactly the same. A letter to Anthropic’s servers, with our key in a header, and a JSON reply. The SDK writes the envelope for us.”
Try it yourself
Using fake_partner.py and partner_client.py from above, write a function reply_to_customer(order_id: str) -> str that returns the message the bot should send:
- On success:
"Imran is on the way! About 12 minutes."(using the real name and ETA) - On
OrderNotFound:"We can't find that order with our delivery partner yet. Could you check the order number?" - On
PartnerUnavailable(after one retry, usingwith_retrieswithattempts=2) or a timeout (httpx.TimeoutException):"Live tracking is busy right now. Your order is on its way!"
Test it with CR-2210, CR-9999 and CR-5000. Expected (the retry message for CR-5000 is printed by with_retries):
Imran is on the way! About 12 minutes.
We can't find that order with our delivery partner yet. Could you check the order number?
Attempt 1: Partner busy or down (HTTP 503). Waiting 1s
Live tracking is busy right now. Your order is on its way!
Show the answer
import time
import httpx
from partner_client import OrderNotFound, PartnerUnavailable, rider_status
def with_retries(order_id, attempts=3):
for attempt in range(1, attempts + 1):
try:
return rider_status(order_id)
except PartnerUnavailable as error:
if attempt == attempts:
raise
wait = 2 ** (attempt - 1)
print(f"Attempt {attempt}: {error}. Waiting {wait}s")
time.sleep(wait)
def reply_to_customer(order_id: str) -> str:
try:
status = with_retries(order_id, attempts=2)
except OrderNotFound:
return "We can't find that order with our delivery partner yet. Could you check the order number?"
except (PartnerUnavailable, httpx.TimeoutException):
return "Live tracking is busy right now. Your order is on its way!"
return f"{status.rider.name} is on the way! About {status.rider.eta_minutes} minutes."
for order_id in ["CR-2210", "CR-9999", "CR-5000"]:
print(reply_to_customer(order_id))
Imran is on the way! About 12 minutes.
We can't find that order with our delivery partner yet. Could you check the order number?
Attempt 1: Partner busy or down (HTTP 503). Waiting 1s
Live tracking is busy right now. Your order is on its way!
Every technical failure becomes a calm, useful sentence for the customer, while the details belong in your logs. Notice that with_retries re-raises after its last attempt, and reply_to_customer is the place that knows what to say about it: catching each error where something useful can be done with it.
Common mistakes
- Forgetting
timeout=withrequests. It waits forever by default. - Calling
.json()before checking the status. Error bodies have a different shape, or aren’t JSON at all. - Building query strings by hand.
f"...?q={text}"breaks on spaces and&. Passparams={...}and let the library encode it. - Creating a new client for every request. Reuse one
httpx.Client(orrequests.Session); it keeps connections open and is noticeably faster. - Hard-coding keys, or putting them in URLs. Environment variable in,
Authorizationheader out.
Recap
- An HTTP request has a method, a URL, headers and an optional body; a response has a status code, headers and a body, usually JSON.
- Status codes come in families: 2xx success, 4xx your request is wrong (retrying won’t help, except 429), 5xx their server failed (a later retry often works).
- Always set a timeout, check the status before reading the body, and validate the body with Pydantic before using it.
- Send API keys in headers, read them from the environment, and never put them in URLs or logs.
requestsis the classic library;httpxoffers the same interface plus async, which suits AI apps that make many calls at once.
That afternoon, Meera asks for something new. “The weekend inbox. Can the bot draft replies, not only sort the emails? And Priya says we should compare Claude with the others before we commit.” Arjun opens three providers’ documentation side by side, and sees three different shapes of JSON coming back for the same question. “Time to build an AI workflow,” Priya says, “that doesn’t care whose box the answer arrives in.”
Go deeper
- HTTPX documentation: clients, timeouts, async, and the mock transport used above.
- Developer Toolkit: How the web talks, if you’d like a refresher on HTTP and REST from first principles.
- Anthropic documentation: see the raw HTTP request behind the SDK in the API reference.