AI Tutorial
Home All Courses Blog Interview Prep ↗ 0%
1
Curious builder 0 XP earned · 300 to level 2
0 days Finish a lesson to begin
Badge collection
0 of 5 unlocked
small wins to finish your path
Next lesson →

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.

Last updated: 01 Nov, 2026 · requests

Core concepts

Definition · HTTP

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.

Definition · API

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.

Definition · Endpoint

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.

Definition · Status code

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.

HTTP
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

MethodPurposeHas a body?AI example
GETRead dataNoList available models, fetch a document
POSTCreate something or run an actionYesChat completion, create embeddings, upload a file
PUT / PATCHReplace / partially updateYesUpdate an assistant's settings
DELETERemoveUsually noDelete an uploaded file or vector store

Status codes you must know

CodeNameWhat it means for you
200OKSuccess — read the body.
201CreatedA resource (file, job) was created.
400Bad RequestYour JSON or parameters are invalid. Read the error message; do not retry unchanged.
401UnauthorizedMissing or wrong API key.
403ForbiddenKey is valid but lacks permission (or region/model access).
404Not FoundWrong URL or model name.
422Unprocessable EntityBody is valid JSON but fails validation.
429Too Many RequestsRate limit or quota hit. Wait and retry with backoff.
500 / 502 / 503Server errorsTemporary 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:

Terminal
python -m pip install requests
Note

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

Definition · 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.

Python
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"])
200 https://httpbin.org/get?q=vector+database&limit=5 application/json {'limit': '5', 'q': 'vector database'}

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 / methodGives you
r.status_codeThe status code as an int, e.g. 200.
r.okTrue if the status code is below 400.
r.headersResponse headers (a case-insensitive dict).
r.json()The body parsed from JSON into Python dicts and lists.
r.textThe body as a string — useful for logging errors.
r.contentThe raw body bytes — for images, audio, PDFs.
r.raise_for_status()Raises HTTPError for 4xx/5xx responses.
r.elapsedHow 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.

Python
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"])
application/json Explain embeddings in one line. ['demo', 'httpbin']
Watch out

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

Definition · Header

A key–value pair of metadata sent with a request or response, such as Content-Type, User-Agent, or Authorization.

Definition · Bearer token

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:

Python
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)
200 {'authenticated': True, 'token': 'test-token-123'} 401

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

Definition · Timeout

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.

Python
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")
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().

Python
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"))
Sample Slide Show HTTP 404 for https://httpbin.org/status/404 None response was not JSON None

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

Definition · 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.

Python
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"])
{'ok': True}
Note

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

Definition · Session

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.

Python
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()
0 200 True 1 200 True 2 200 True

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).

llm_client.py
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"])
An embedding is a list of numbers that represents the meaning of a piece of text, so that texts with similar meanings have nearby vectors. {'prompt_tokens': 27, 'completion_tokens': 29, 'total_tokens': 56}

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:

JSON response
{
  "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}
}
FieldMeaning
model (request)Which model to run.
messagesThe conversation so far — a list of {role, content} dicts.
temperatureRandomness of sampling: 0–0.3 for factual tasks, higher for creative ones.
max_tokensUpper limit on the length of the generated answer.
choices[0].message.contentThe generated text.
finish_reasonstop = finished naturally; length = cut off by max_tokens.
usageTokens consumed — what you are billed for. Log it for every call.
Note

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.

Python
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()
1, 2, 3, 4, 5.

Pagination

Definition · 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.

Python
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 (never verify=False in 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.
Try it
  1. Send a GET to https://httpbin.org/get with params {"topic": "agents", "page": 2} and print the final URL and the echoed args.
  2. Use fetch_json on https://httpbin.org/status/503 and confirm it is reported, not crashed on.
  3. Call post_with_retries against https://httpbin.org/status/503 with max_attempts=3 and watch the backoff messages.
  4. If you have an API key, run llm_client.py, then log usage["total_tokens"] for three different prompts.
Key takeaways
  • 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 and json= for JSON bodies — never build them by hand.
  • Send API keys in headers loaded from environment variables.
  • Always set timeout=, call raise_for_status(), and catch specific requests exceptions.
  • 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.content plus usage.