HTTP requests and APIs
Every hosted model — OpenAI, Anthropic, Gemini, Groq, or your own server — is reached through an HTTP API. SDKs are thin wrappers around the same requests you will learn to send by hand here. Once you understand methods, headers, JSON bodies, status codes, timeouts, and retries, you can call any AI service and debug it when something goes wrong.
Core concepts
HyperText Transfer Protocol — the request/response protocol of the web. A client (your Python script) sends a request to a server, and the server sends back exactly one response. HTTPS is HTTP encrypted with TLS; always use it for APIs.
Application Programming Interface — a documented contract describing which requests a service accepts and what it returns. A web API exposes that contract over HTTP, usually exchanging JSON.
A specific URL that performs one operation, such as https://api.openai.com/v1/chat/completions. The part after the domain (/v1/chat/completions) is the path.
A three-digit number at the top of every response that summarises the outcome: 2xx success, 4xx a problem with your request, 5xx a problem on the server.
Anatomy of a request and a response
Below is what actually travels over the network when you call a chat API. Requests and responses both have a start line, headers (key–value metadata), and an optional body.
POST /v1/chat/completions HTTP/1.1 <- method, path, version
Host: api.openai.com <- headers
Authorization: Bearer sk-...
Content-Type: application/json
{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Hi"}]} <- body
HTTP/1.1 200 OK <- status line
Content-Type: application/json
x-ratelimit-remaining-requests: 4999
{"id": "chatcmpl-...", "choices": [{"message": {"role": "assistant", "content": "Hello!"}}]}
HTTP methods
| Method | Purpose | Has a body? | AI example |
|---|---|---|---|
GET | Read data | No | List available models, fetch a document |
POST | Create something or run an action | Yes | Chat completion, create embeddings, upload a file |
PUT / PATCH | Replace / partially update | Yes | Update an assistant's settings |
DELETE | Remove | Usually no | Delete an uploaded file or vector store |
Status codes you must know
| Code | Name | What it means for you |
|---|---|---|
200 | OK | Success — read the body. |
201 | Created | A resource (file, job) was created. |
400 | Bad Request | Your JSON or parameters are invalid. Read the error message; do not retry unchanged. |
401 | Unauthorized | Missing or wrong API key. |
403 | Forbidden | Key is valid but lacks permission (or region/model access). |
404 | Not Found | Wrong URL or model name. |
422 | Unprocessable Entity | Body is valid JSON but fails validation. |
429 | Too Many Requests | Rate limit or quota hit. Wait and retry with backoff. |
500 / 502 / 503 | Server errors | Temporary provider problem. Retry with backoff. |
Installing requests
Python ships with urllib, but the third-party requests library is far friendlier and is the standard for scripts. Install it inside your activated virtual environment:
python -m pip install requests
The examples below use httpbin.org, a free testing service that echoes back whatever you send. It lets you practise every HTTP feature without an API key. Some fields, such as your IP address or a request id, will differ on your machine.
GET requests and query parameters
Key–value pairs appended to a URL after ?, separated by &: /search?q=rag&limit=5. They are used to filter, search, and paginate GET requests.
Pass them as a dict with params= and let requests build and encode the URL — never concatenate them by hand.
import requests
response = requests.get(
"https://httpbin.org/get",
params={"q": "vector database", "limit": 5},
timeout=30,
)
print(response.status_code)
print(response.url)
print(response.headers["Content-Type"])
print(response.json()["args"])
The space in vector database was URL-encoded as + automatically. The server received every value as a string — query parameters are always text.
The Response object
| Attribute / method | Gives you |
|---|---|
r.status_code | The status code as an int, e.g. 200. |
r.ok | True if the status code is below 400. |
r.headers | Response headers (a case-insensitive dict). |
r.json() | The body parsed from JSON into Python dicts and lists. |
r.text | The body as a string — useful for logging errors. |
r.content | The raw body bytes — for images, audio, PDFs. |
r.raise_for_status() | Raises HTTPError for 4xx/5xx responses. |
r.elapsed | How long the request took. |
POST requests with a JSON body
To send data, use POST with json=. requests serialises the dict for you and sets the Content-Type: application/json header automatically.
import requests
payload = {
"prompt": "Explain embeddings in one line.",
"max_tokens": 60,
"tags": ["demo", "httpbin"],
}
r = requests.post("https://httpbin.org/post", json=payload, timeout=30)
r.raise_for_status()
echo = r.json()
print(echo["headers"]["Content-Type"])
print(echo["json"]["prompt"])
print(echo["json"]["tags"])
json=payload and data=payload are different. data= sends form-encoded fields (prompt=Explain...&max_tokens=60), which JSON APIs reject with a 400 or 415. For AI APIs you almost always want json=.
Headers and authentication
A key–value pair of metadata sent with a request or response, such as Content-Type, User-Agent, or Authorization.
The most common API authentication scheme: you send your API key in the header Authorization: Bearer <key>. Whoever “bears” the token is treated as you — so protect it like a password.
Read the key from an environment variable (see the setup lesson) and put it in the headers dict:
import os
import requests
token = os.getenv("DEMO_TOKEN", "test-token-123")
r = requests.get(
"https://httpbin.org/bearer",
headers={"Authorization": f"Bearer {token}", "User-Agent": "ai-lab/1.0"},
timeout=30,
)
print(r.status_code, r.json())
missing = requests.get("https://httpbin.org/bearer", timeout=30)
print(missing.status_code)
Some providers use a different header — Anthropic, for example, expects x-api-key: <key> plus an anthropic-version header. Always check the provider's API reference.
Timeouts
The maximum time your program will wait for the server. Without one, requests waits forever if a server stops responding, freezing your script or worker.
Pass timeout= on every request. A tuple sets two separate limits: the connect timeout (how long to wait to establish the connection) and the read timeout (how long to wait between bytes of the response). LLM calls can legitimately take a long time to generate, so give them a generous read timeout.
import requests
try:
requests.get("https://httpbin.org/delay/5", timeout=(3.05, 2))
except requests.Timeout:
print("gave up after waiting 2 seconds for a response")
Handling errors properly
Two very different things can go wrong. The request may never get a response (DNS failure, no network, timeout), which raises an exception immediately. Or it may get a response with an error status, which does not raise anything until you call raise_for_status().
import requests
def fetch_json(url: str) -> dict | None:
try:
r = requests.get(url, timeout=10)
r.raise_for_status()
return r.json()
except requests.HTTPError as err:
print(f"HTTP {err.response.status_code} for {url}")
except requests.Timeout:
print("timed out")
except requests.ConnectionError:
print("could not connect")
except requests.JSONDecodeError:
print("response was not JSON")
return None
print(fetch_json("https://httpbin.org/json")["slideshow"]["title"])
print(fetch_json("https://httpbin.org/status/404"))
print(fetch_json("https://httpbin.org/html"))
When an AI API returns 4xx, the body almost always explains why — for example {"error": {"message": "Invalid model"}}. Log err.response.text (never the request headers, which contain your key) to debug quickly.
Retries with exponential backoff
A retry strategy where you wait longer after each failure — 1 s, 2 s, 4 s, 8 s… — usually with a little random jitter so many clients do not retry at the same instant. It gives an overloaded or rate-limited server time to recover.
Only retry errors that are temporary: timeouts, connection errors, 429, and 5xx. Never retry 400, 401, or 404 — the same request will fail the same way. If the server sends a Retry-After header, respect it.
import random
import time
import requests
RETRYABLE = {429, 500, 502, 503, 504}
def post_with_retries(url, payload, headers=None, max_attempts=5):
for attempt in range(1, max_attempts + 1):
try:
r = requests.post(url, json=payload, headers=headers, timeout=(5, 60))
except (requests.Timeout, requests.ConnectionError) as err:
reason = type(err).__name__
else:
if r.status_code not in RETRYABLE:
r.raise_for_status() # success, or a non-retryable error
return r.json()
reason = f"HTTP {r.status_code}"
retry_after = r.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
time.sleep(int(retry_after))
continue
if attempt == max_attempts:
raise RuntimeError(f"failed after {max_attempts} attempts ({reason})")
delay = min(2 ** (attempt - 1), 30) + random.uniform(0, 0.5)
print(f"attempt {attempt} failed ({reason}); retrying in {delay:.1f}s")
time.sleep(delay)
print(post_with_retries("https://httpbin.org/post", {"ok": True})["json"])
Official SDKs (for example openai and anthropic) already retry 429 and 5xx errors with backoff, configurable via a max_retries option. Write your own only when calling APIs directly with requests.
Sessions: reuse connections and settings
A requests.Session object keeps the underlying TCP/TLS connection open between requests (connection pooling) and stores default headers. Calling the same API many times through a session is faster and keeps code tidy.
import requests
session = requests.Session()
session.headers.update({"Authorization": "Bearer test-token-123", "User-Agent": "ai-lab/1.0"})
for i in range(3):
r = session.get("https://httpbin.org/bearer", timeout=30)
print(i, r.status_code, r.json()["authenticated"])
session.close()
Calling a real LLM API
Now the payoff. Most providers offer an endpoint compatible with OpenAI's Chat Completions format, so the code below works with OpenAI and, by changing BASE_URL and the model name, with many others (Groq, Together, OpenRouter, a local Ollama or vLLM server).
import os
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_URL = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1")
API_KEY = os.environ["OPENAI_API_KEY"] # fail fast if missing
MODEL = os.getenv("MODEL_NAME", "gpt-4o-mini")
def chat(messages: list[dict], temperature: float = 0.2, max_tokens: int = 300) -> dict:
r = requests.post(
f"{BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": MODEL,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
},
timeout=(5, 120),
)
if not r.ok:
raise RuntimeError(f"LLM error {r.status_code}: {r.text[:300]}")
return r.json()
data = chat([
{"role": "system", "content": "You are a concise AI tutor."},
{"role": "user", "content": "Define an embedding in one sentence."},
])
print(data["choices"][0]["message"]["content"])
print(data["usage"])
The model's wording and the token counts will differ on every run — that is the nature of generative models. The structure of the response is what you depend on:
{
"id": "chatcmpl-AbC123",
"object": "chat.completion",
"model": "gpt-4o-mini-2024-07-18",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "An embedding is ..."},
"finish_reason": "stop"
}
],
"usage": {"prompt_tokens": 27, "completion_tokens": 29, "total_tokens": 56}
}
| Field | Meaning |
|---|---|
model (request) | Which model to run. |
messages | The conversation so far — a list of {role, content} dicts. |
temperature | Randomness of sampling: 0–0.3 for factual tasks, higher for creative ones. |
max_tokens | Upper limit on the length of the generated answer. |
choices[0].message.content | The generated text. |
finish_reason | stop = finished naturally; length = cut off by max_tokens. |
usage | Tokens consumed — what you are billed for. Log it for every call. |
In production you will usually use the provider's official SDK (pip install openai), which handles retries, streaming, and typed responses. Knowing the raw HTTP shape means you can debug the SDK, switch providers, and call services that have no SDK at all.
Streaming responses
For chat interfaces, waiting 10 seconds for a complete answer feels slow. With "stream": true, the server sends the answer in small pieces using Server-Sent Events — lines that start with data: — and you print each piece as it arrives.
import json
import os
import requests
with requests.post(
"https://api.openai.com/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
json={
"model": "gpt-4o-mini",
"stream": True,
"messages": [{"role": "user", "content": "Count from 1 to 5."}],
},
stream=True, # do not download the whole body at once
timeout=(5, 120),
) as r:
r.raise_for_status()
for line in r.iter_lines(decode_unicode=True):
if not line or not line.startswith("data: "):
continue
chunk = line.removeprefix("data: ")
if chunk == "[DONE]":
break
delta = json.loads(chunk)["choices"][0]["delta"]
print(delta.get("content", ""), end="", flush=True)
print()
Pagination
Splitting a large result set across multiple responses. The API returns one page plus a pointer to the next — a page number, an offset, or a cursor/next token — and you keep requesting until there is no next page.
import requests
def fetch_all(url: str, page_size: int = 100) -> list[dict]:
items, cursor = [], None
while True:
params = {"limit": page_size}
if cursor:
params["after"] = cursor
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
page = r.json()
items.extend(page["data"])
if not page.get("has_more"):
return items
cursor = page["data"][-1]["id"]
This cursor pattern (data, has_more, after) is the one OpenAI uses for listing files, batches, and fine-tuning jobs; other APIs use the same idea with different field names.
Security checklist for API code
- Load keys from environment variables or a secrets manager; never hard-code or commit them.
- Never log request headers or full exception objects that might include them.
- Always use
https://and keep TLS verification on (neververify=Falsein production). - Set a timeout on every request and a maximum number of retries.
- Set a spending limit or budget alert in your provider's dashboard before running large batch jobs.
- Treat API responses — especially model output — as untrusted input: validate before using them in file paths, SQL, or shell commands.
- Send a GET to
https://httpbin.org/getwith params{"topic": "agents", "page": 2}and print the final URL and the echoedargs. - Use
fetch_jsononhttps://httpbin.org/status/503and confirm it is reported, not crashed on. - Call
post_with_retriesagainsthttps://httpbin.org/status/503withmax_attempts=3and watch the backoff messages. - If you have an API key, run
llm_client.py, then logusage["total_tokens"]for three different prompts.
- An HTTP request has a method, URL, headers, and an optional body; a response has a status code, headers, and a body.
- Use
params=for query strings andjson=for JSON bodies — never build them by hand. - Send API keys in headers loaded from environment variables.
- Always set
timeout=, callraise_for_status(), and catch specificrequestsexceptions. - Retry only 429, 5xx, timeouts, and connection errors, with exponential backoff and jitter.
- Chat APIs take a list of messages and return
choices[0].message.contentplususage.