Chapter 3 · HTTP and Web APIs
Web APIs and REST: Reading Docs and Testing with curl
- Page 13 of 22
- 24 min read
An API (Application Programming Interface) is a contract that lets one program use another. A web API is that contract offered over HTTP: a list of URLs, the methods they accept, what to send and what comes back. Weather data, payments, maps, GitHub, and every hosted LLM (OpenAI, Anthropic, Google, Mistral, …) are used through web APIs. When you build an AI product, you will both consume web APIs and offer one.
This page puts the last two pages to work. You set up the BitByte Practice API on your own computer, read its documentation the way you would read OpenAI's or GitHub's, and use it with curl: public endpoints, API keys, bearer tokens, error responses and a rate limit. By the end, a new API's docs should feel like a map, not a wall of text.
What you will learn
- What makes an API "web" and "REST": resources, endpoints, base URLs and versions.
- How to run the Practice API and read its OpenAPI documentation at
/docs. - Public vs authenticated endpoints: API keys and bearer tokens, and the errors you get without them.
- curl in depth: the dozen options you will actually use, and exit codes for scripts.
- What usage limits look like (429 and
Retry-After), and which GUI clients exist.
API, web API, REST
Any defined way for programs to talk is an API: the functions of the json module are the API of that module. A web API is reached over the network with HTTP, so any language on any machine can use it. Most web APIs today follow the REST style (REpresentational State Transfer), which in practice means:
- The API is organised around resources, things with names: courses, reviews, users, messages. Paths are nouns, usually plural:
/v1/courses,/v1/courses/2. - The HTTP method says what to do with the resource (previous page): GET to read, POST to create, and so on. You do not see
/getCourseor/deleteReviewin a REST API. - Resources are sent as representations, nearly always JSON.
- Each request is stateless: it carries everything needed, including credentials.
An endpoint is one method + path combination, such as GET /v1/courses/{course_id}. Here are the Practice API's endpoints, grouped by what they are for:
| Endpoint | What it does | Auth |
|---|---|---|
GET /, GET /health | Name and version; "am I alive?" | None |
GET /v1/courses | List courses; query: level, page, per_page | None |
GET /v1/courses/{course_id} | One course, or 404 | None |
GET /v1/weather?city=… | Made-up weather for five Bangladeshi cities | None |
POST /v1/reviews | Create a review | API key in X-API-Key |
POST /v1/token | Log in, receive a bearer token | Username + password in the body |
GET /v1/me | Who am I? | Bearer token |
POST /v1/chat | A fake LLM with the same shape as real chat APIs | Bearer token |
GET /v1/limited | Rate-limited: 3 requests per 10 seconds per X-Client-Id | None |
GET /v1/flaky, GET /v1/slow | Fails a few times on purpose; answers slowly on purpose | None |
/v1/echo, GET /v1/status/{code} | Learning tools from the previous page | None |
Setting up the Practice API
The Practice API is one Python file built with FastAPI, the framework you will learn from Your First FastAPI App on. You do not need to understand it yet; you only need to run it. Make a project folder with a virtual environment (as on Environment Variables, .env Files and Virtual Environments) and install FastAPI:
mkdir -p ~/projects/practice-api
cd ~/projects/practice-api
python -m venv .venv
source .venv/bin/activate
pip install -q "fastapi[standard]"On Windows PowerShell, activate with .venv\Scripts\Activate.ps1 instead of source …. If you prefer uv: uv init, then uv add "fastapi[standard]".
Now create practice_api.py in that folder with exactly this content (copy it whole; it is the same file for everyone, so your outputs match the tutorial's):
"""BitByte Practice API: a small, offline web API for the Git, APIs and Backend tutorials.
Run it in its own terminal and leave it running:
pip install "fastapi[standard]"
fastapi run practice_api.py --port 8800
Then open http://localhost:8800/docs. Everything it returns is made up and the same
on every machine, so your output matches the tutorial's.
"""
import asyncio
import json
import time
from fastapi import FastAPI, Header, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
app = FastAPI(title="BitByte Practice API", version="1.0.0")
API_KEY = "practice-key-123"
USERS = {"nadia": "s3cret-pass"}
TOKENS = {"token-nadia-001": "nadia"}
COURSES = [
{"id": 1, "title": "How AI Works", "level": "beginner", "hours": 3, "rating": 4.7},
{"id": 2, "title": "Python for AI", "level": "beginner", "hours": 12, "rating": 4.8},
{"id": 3, "title": "Maths for AI", "level": "beginner", "hours": 8, "rating": 4.5},
{"id": 4, "title": "SQL and Data Handling", "level": "beginner", "hours": 10, "rating": 4.6},
{"id": 5, "title": "Git, APIs and Backend", "level": "beginner", "hours": 9, "rating": 4.6},
{"id": 6, "title": "Prompt Engineering", "level": "intermediate", "hours": 5, "rating": 4.4},
{"id": 7, "title": "LLM APIs", "level": "intermediate", "hours": 6, "rating": 4.7},
{"id": 8, "title": "RAG Systems", "level": "advanced", "hours": 10, "rating": 4.9},
{"id": 9, "title": "AI Agents", "level": "advanced", "hours": 12, "rating": 4.8},
{"id": 10, "title": "LLMOps", "level": "advanced", "hours": 8, "rating": 4.3},
]
WEATHER = {
"dhaka": {"city": "Dhaka", "temp_c": 31.5, "humidity": 78, "condition": "Humid"},
"chattogram": {"city": "Chattogram", "temp_c": 29.8, "humidity": 82, "condition": "Rain"},
"sylhet": {"city": "Sylhet", "temp_c": 27.4, "humidity": 85, "condition": "Rain"},
"khulna": {"city": "Khulna", "temp_c": 32.1, "humidity": 70, "condition": "Sunny"},
"rajshahi": {"city": "Rajshahi", "temp_c": 33.6, "humidity": 58, "condition": "Sunny"},
}
REVIEWS: list[dict] = []
RATE_WINDOW_SECONDS = 10
RATE_LIMIT = 3
_rate_hits: dict[str, list[float]] = {}
_flaky_calls: dict[str, int] = {}
class ReviewIn(BaseModel):
course_id: int = Field(ge=1)
rating: int = Field(ge=1, le=5)
comment: str = Field(min_length=3, max_length=500)
class ChatMessage(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str = "practice-llm-1"
messages: list[ChatMessage] = Field(min_length=1)
stream: bool = False
def require_api_key(x_api_key: str | None) -> None:
if x_api_key is None:
raise HTTPException(401, "Missing API key: send it in the X-API-Key header")
if x_api_key != API_KEY:
raise HTTPException(403, "Invalid API key")
def require_bearer(authorization: str | None) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "Missing bearer token", headers={"WWW-Authenticate": "Bearer"})
user = TOKENS.get(authorization.removeprefix("Bearer "))
if user is None:
raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"})
return user
@app.get("/")
def home():
return {"name": "BitByte Practice API", "version": "1.0.0", "docs": "/docs"}
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/v1/courses")
def list_courses(
level: str | None = None,
page: int = Query(1, ge=1),
per_page: int = Query(5, ge=1, le=20),
):
items = [c for c in COURSES if level is None or c["level"] == level]
start = (page - 1) * per_page
chunk = items[start:start + per_page]
return {
"page": page,
"per_page": per_page,
"total": len(items),
"next_page": page + 1 if start + per_page < len(items) else None,
"items": chunk,
}
@app.get("/v1/courses/{course_id}")
def get_course(course_id: int):
for course in COURSES:
if course["id"] == course_id:
return course
raise HTTPException(404, f"Course {course_id} not found")
@app.get("/v1/weather")
def weather(city: str):
data = WEATHER.get(city.lower())
if data is None:
raise HTTPException(404, f"No weather data for {city!r}")
return data
@app.post("/v1/reviews", status_code=201)
def create_review(review: ReviewIn, x_api_key: str | None = Header(None)):
require_api_key(x_api_key)
if not any(c["id"] == review.course_id for c in COURSES):
raise HTTPException(404, f"Course {review.course_id} not found")
saved = {"id": len(REVIEWS) + 1, **review.model_dump()}
REVIEWS.append(saved)
return saved
@app.post("/v1/token")
def token(form: dict):
username, password = form.get("username"), form.get("password")
if USERS.get(username) != password:
raise HTTPException(401, "Wrong username or password")
return {"access_token": "token-nadia-001", "token_type": "bearer", "expires_in": 3600}
@app.get("/v1/me")
def me(authorization: str | None = Header(None)):
user = require_bearer(authorization)
return {"username": user, "plan": "student"}
@app.get("/v1/limited")
def limited(x_client_id: str = Header("anonymous")):
"""Allows 3 requests per 10 seconds per X-Client-Id, then answers 429."""
now = time.monotonic()
hits = [t for t in _rate_hits.get(x_client_id, []) if now - t < RATE_WINDOW_SECONDS]
if len(hits) >= RATE_LIMIT:
retry_after = max(1, int(RATE_WINDOW_SECONDS - (now - hits[0])) + 1)
_rate_hits[x_client_id] = hits
return JSONResponse(
{"detail": "Too many requests"}, status_code=429, headers={"Retry-After": str(retry_after)}
)
hits.append(now)
_rate_hits[x_client_id] = hits
return {"ok": True, "remaining": RATE_LIMIT - len(hits)}
@app.get("/v1/flaky")
def flaky(key: str, fail_times: int = Query(2, ge=0, le=10)):
"""Fails with 503 for the first `fail_times` calls with this key, then succeeds."""
calls = _flaky_calls.get(key, 0) + 1
_flaky_calls[key] = calls
if calls <= fail_times:
return JSONResponse({"detail": "Service temporarily unavailable"}, status_code=503)
return {"ok": True, "attempt": calls}
@app.get("/v1/slow")
async def slow(seconds: float = Query(2.0, ge=0, le=30)):
await asyncio.sleep(seconds)
return {"waited_seconds": seconds}
@app.api_route("/v1/echo", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def echo(request: Request):
body = await request.body()
try:
parsed = json.loads(body) if body else None
except json.JSONDecodeError:
parsed = body.decode(errors="replace")
shown = {k: v for k, v in request.headers.items() if k in ("content-type", "authorization", "x-api-key", "user-agent", "accept")}
return {"method": request.method, "query": dict(request.query_params), "headers": shown, "json": parsed}
@app.get("/v1/status/{code}")
def status(code: int):
if not 200 <= code <= 599:
raise HTTPException(400, "Status code must be between 200 and 599")
return Response(status_code=code) if code in (204, 304) else JSONResponse({"status": code}, status_code=code)
def _fake_answer(messages: list[ChatMessage]) -> str:
question = messages[-1].content.strip().rstrip("?")
return f"This is a practice answer about: {question}. A real model would say more."
@app.post("/v1/chat")
async def chat(request: ChatRequest, authorization: str | None = Header(None)):
"""A fake LLM. Same shape as many real chat APIs; the answer is always predictable."""
require_bearer(authorization)
answer = _fake_answer(request.messages)
prompt_tokens = sum(len(m.content.split()) for m in request.messages)
completion_tokens = len(answer.split())
usage = {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens}
if not request.stream:
return {"model": request.model, "message": {"role": "assistant", "content": answer}, "usage": usage}
async def events():
for word in answer.split(" "):
yield f"data: {json.dumps({'delta': word + ' '})}\n\n"
await asyncio.sleep(0.05)
yield f"data: {json.dumps({'done': True, 'usage': usage})}\n\n"
return StreamingResponse(events(), media_type="text/event-stream")A quick tour, so the file is not a black box: COURSES and WEATHER are the made-up data; each function with @app.get(…) or @app.post(…) above it is one endpoint; HTTPException(404, …) is how it answers with an error status; require_api_key and require_bearer check credentials. Everything is kept in memory, so restarting the server resets reviews and rate limits.
Open a second terminal (the server needs one of its own and keeps running), go to the folder, activate the environment and start the server:
# second terminal: leave this running
cd ~/projects/practice-api
source .venv/bin/activate
fastapi run practice_api.py --port 8800FastAPI prints a short banner, and the last line says Uvicorn running on http://0.0.0.0:8800 (Press CTRL+C to quit). 0.0.0.0 means "listen on every network interface of this computer"; you connect to it as localhost. From now on that terminal shows one log line per request, such as "GET /health HTTP/1.1" 200 OK, which is a handy way to see that your requests arrive. Stop the server with Ctrl+C; start it again the same way whenever a page needs it.
Back in your first terminal, check it:
curl -s http://localhost:8800/health{"status":"ok"}"Connection refused" here means the server is not running (or is on another port). Look at the second terminal: did it start, or did it print an error such as "address already in use"?
Reading API documentation
Open http://localhost:8800/docs in your browser. This is Swagger UI, generated automatically from the code. Every endpoint is listed; click one to see its parameters, the request body it expects, and example responses. Try it out → fill in values → Execute sends a real request and shows the response, the status code and the equivalent curl command.
The page is drawn from a machine-readable description, the OpenAPI specification, at /openapi.json. OpenAPI is the standard way to describe REST APIs; most serious APIs publish one, and tools use it to generate client code, tests and documentation. Because it is JSON, you can read it with a program:
curl -s http://localhost:8800/openapi.json | python -c '
import json, sys
spec = json.load(sys.stdin)
print("OpenAPI", spec["openapi"], "|", spec["info"]["title"], spec["info"]["version"])
for path, operations in spec["paths"].items():
print(f"{path:26}", ", ".join(sorted(m.upper() for m in operations)))
'OpenAPI 3.1.0 | BitByte Practice API 1.0.0
/ GET
/health GET
/v1/courses GET
/v1/courses/{course_id} GET
/v1/weather GET
/v1/reviews POST
/v1/token POST
/v1/me GET
/v1/limited GET
/v1/flaky GET
/v1/slow GET
/v1/echo DELETE, GET, PATCH, POST, PUT
/v1/status/{code} GET
/v1/chat POST
When you meet a new API, read its docs in this order. It works for the Practice API, for GitHub, and for any LLM provider:
| Question | Where to look | Practice API |
|---|---|---|
| What is the base URL? | "Getting started" / "API reference" intro | http://localhost:8800 |
| How do I authenticate? | "Authentication" page | X-API-Key header or Authorization: Bearer … |
| Which endpoint does what I need? | The endpoint list | e.g. GET /v1/courses |
| Which parameters, where (path, query, body), required or optional? | The endpoint page / schema | city: query, required |
| What does success look like? What errors can happen? | Response examples, "Errors" page | 200 + JSON; 401/403/404/422/429 |
| What are the limits and the price? | "Rate limits", "Pricing", "Usage" | 3 requests / 10 s on /v1/limited |
| How do versions and changes work? | "Versioning", "Changelog" | /v1/ in the path |
Base URLs and versions
Every endpoint is relative to a base URL: http://localhost:8800 here, https://api.openai.com/v1 or https://api.anthropic.com/v1 for the big LLM providers, https://api.github.com for GitHub. Keep the base URL in one place (a constant or an environment variable) so you can point the same code at a test server, a staging copy or another provider.
APIs change, and clients written last year must keep working. So APIs are versioned. The two common styles: a version in the path (/v1/courses; OpenAI, Anthropic and the Practice API do this), or a version in a header (Anthropic also requires anthropic-version: 2023-06-01; GitHub uses X-GitHub-Api-Version). Within a version, providers only add things (a new optional field, a new endpoint); breaking changes get a new version and a deprecation period. That is why your JSON-reading code should ignore fields it does not know instead of failing on them.
JSON responses: single items and lists
A single resource comes back as an object. A list usually comes back wrapped in an object with paging information, not as a bare array, so the API can add fields later:
curl -s "http://localhost:8800/v1/courses?per_page=3" | python -m json.tool{
"page": 1,
"per_page": 3,
"total": 10,
"next_page": 2,
"items": [
{
"id": 1,
"title": "How AI Works",
"level": "beginner",
"hours": 3,
"rating": 4.7
},
{
"id": 2,
"title": "Python for AI",
"level": "beginner",
"hours": 12,
"rating": 4.8
},
{
"id": 3,
"title": "Maths for AI",
"level": "beginner",
"hours": 8,
"rating": 4.5
}
]
}This is pagination: 10 courses exist, you got 3, and next_page says which page to ask for next (it becomes null on the last page). Never assume one request returns everything; you will write a loop that follows next_page on the next page of the tutorial.
Public and authenticated endpoints
/v1/courses is public: anyone can call it. Creating a review is not. Real APIs authenticate almost every call, both to protect data and because every call costs them money. Two mechanisms cover most of what you will meet.
API keys
An API key is a long secret string that identifies your account or project. You create it in the provider's dashboard and send it with every request in a header. Watch the three ways a request to POST /v1/reviews can fail before it succeeds:
curl -s -w " -> %{http_code}\n" http://localhost:8800/v1/reviews \
--json '{"course_id": 2, "rating": 5, "comment": "Clear and practical"}'
curl -s -w " -> %{http_code}\n" http://localhost:8800/v1/reviews -H "X-API-Key: wrong-key" \
--json '{"course_id": 2, "rating": 5, "comment": "Clear and practical"}'
curl -s -w " -> %{http_code}\n" http://localhost:8800/v1/reviews -H "X-API-Key: practice-key-123" \
--json '{"course_id": 2, "rating": 9, "comment": "ok"}'{"detail":"Missing API key: send it in the X-API-Key header"} -> 401
{"detail":"Invalid API key"} -> 403
{"detail":[{"type":"less_than_equal","loc":["body","rating"],"msg":"Input should be less than or equal to 5","input":9,"ctx":{"le":5}},{"type":"string_too_short","loc":["body","comment"],"msg":"String should have at least 3 characters","input":"ok","ctx":{"min_length":3}}]} -> 422- 401: no key at all, "who are you?".
- 403: a key, but not a valid one. (APIs differ here: many answer 401 for a wrong key too. Read the error body, not only the code.)
- 422: the key is fine; the data is not. Both problems are listed, each with its location (
body→rating) and the rule it broke.
Never type a real key into a command: it lands in your shell history and in screenshots. Keep it in an environment variable (or a .env file that Git ignores, as on .gitignore: Keeping Secrets and Junk Out) and refer to the variable:
export PRACTICE_API_KEY=practice-key-123
curl -s -i http://localhost:8800/v1/reviews -H "X-API-Key: $PRACTICE_API_KEY" \
--json '{"course_id": 2, "rating": 5, "comment": "Clear and practical"}' | grep -v '^date' | tr -d '\r'HTTP/1.1 201 Created
server: uvicorn
content-length: 65
content-type: application/json
{"id":1,"course_id":2,"rating":5,"comment":"Clear and practical"}201 Created, and the response contains the new review with the id the server gave it. (The practice key is not secret, but the habit is what matters: with a real LLM key, every leaked request is billed to you.)
Bearer tokens
The second pattern: you log in once, the server gives you a token, and you send that token in the standard Authorization header as Bearer <token> ("whoever bears this token"). Tokens usually expire (expires_in is in seconds), so a stolen one is useful only for a while. Many LLM providers use the same header for API keys: OpenAI expects Authorization: Bearer sk-….
curl -s http://localhost:8800/v1/token --json '{"username": "nadia", "password": "s3cret-pass"}'{"access_token":"token-nadia-001","token_type":"bearer","expires_in":3600}Without a token, /v1/me refuses, and the www-authenticate header tells you which scheme it wants:
curl -si http://localhost:8800/v1/me | grep -v '^date' | tr -d '\r'HTTP/1.1 401 Unauthorized
server: uvicorn
www-authenticate: Bearer
content-length: 33
content-type: application/json
{"detail":"Missing bearer token"}Put it together: log in, pull the token out of the JSON with a one-line Python program, keep it in a shell variable, and use it:
TOKEN=$(curl -s http://localhost:8800/v1/token \
--json '{"username": "nadia", "password": "s3cret-pass"}' \
| python -c 'import json, sys; print(json.load(sys.stdin)["access_token"])')
curl -s http://localhost:8800/v1/me -H "Authorization: Bearer $TOKEN"{"username":"nadia","plan":"student"}This login is deliberately simple. Real "log in with Google/GitHub" flows (OAuth 2.0 with PKCE) and how to build login safely in your own API are covered in the advanced tutorial; the part you use as a client, Authorization: Bearer …, stays exactly like this.
Common API errors and what to do
| You see | Likely cause | What to do |
|---|---|---|
| curl: "Couldn't connect to server" / "Connection refused" | Server not running, wrong host or port | Check the URL; start the server; check the firewall/VPN |
401 Unauthorized | Missing, mistyped or expired key/token; wrong header name | Print (part of) the variable; check X-API-Key vs Authorization: Bearer; log in again |
403 Forbidden | Valid identity, no permission (plan, scope, project) | Check the key's permissions or plan in the dashboard |
404 Not Found | Wrong path, wrong id, missing /v1, unknown model name | Compare the URL with the docs character by character |
405 Method Not Allowed | Right path, wrong method | Check the method in the docs (POST vs GET) |
422 / 400 | Body or parameters invalid: missing field, wrong type, out of range | Read the error body; it names the field |
429 Too Many Requests | Rate limit or quota | Wait Retry-After seconds, slow down, retry; check your quota |
5xx | Server or provider problem | Retry with a wait; check the provider's status page |
| A timeout | Slow server or network; very long LLM generation | Set a timeout always; retry idempotent requests; stream long generations |
curl in depth
You have already used most of curl's important options. Here they are together:
| Option | Does |
|---|---|
-s / -S | Silent (no progress meter) / but still show errors. -sS is a good default in scripts. |
-i / -v | Include the response status line and headers / show the whole conversation. |
-X METHOD | Choose the method (-X DELETE). |
-H "Name: value" | Add a header. Repeat for more. |
-d DATA / --json DATA | Send a body (as a form) / send JSON with the right headers. -d @file.json reads the body from a file. |
-G + --data-urlencode | Put encoded values in the query string. |
-F "file=@notes.pdf" | Multipart upload. |
-o FILE / -O | Save the body to a file / to a file named after the URL. |
-L | Follow redirects (3xx). |
-w FORMAT | Print details after the transfer: %{http_code}, %{content_type}, %{time_total}. |
--fail-with-body | Exit with code 22 on 4xx/5xx (still printing the body), so scripts notice failures. |
-m SECONDS | Give up after this many seconds in total. |
--retry N | Retry temporary failures (including 429 and 503) up to N times. |
Exit codes: making failures visible to scripts
By default curl exits with 0 ("success") whenever it got any HTTP response, even a 404, because from curl's point of view the transfer worked. In a script, that hides failures. $? holds the exit code of the last command:
curl -s http://localhost:8800/v1/courses/99 > /dev/null
echo "without --fail-with-body: exit code $?"
curl -s --fail-with-body http://localhost:8800/v1/courses/99
echo " with --fail-with-body: exit code $?"without --fail-with-body: exit code 0
{"detail":"Course 99 not found"} with --fail-with-body: exit code 22Exit code 22 means "the server returned an HTTP error". The other one you will see is 28, "timed out". /v1/slow waits as long as you ask before answering; give curl only one second:
curl -s -m 1 "http://localhost:8800/v1/slow?seconds=3"
echo "exit code $?"exit code 28A timeout is not a status code: no response arrived at all. Every program that calls an API should set one. A hanging request in a web server holds a worker hostage; in a data pipeline it can stall a job for hours.
Saving responses
-o writes the body to a file, and -w can still tell you how it went. That is how you keep a sample response next to your code, for tests or for exploring with pandas:
curl -s -o dhaka.json -w "%{http_code} %{content_type}\n" "http://localhost:8800/v1/weather?city=Dhaka"
python -m json.tool dhaka.json200 application/json
{
"city": "Dhaka",
"temp_c": 31.5,
"humidity": 78,
"condition": "Humid"
}Usage limits
Every real API limits how much you can use it: so many requests per minute (RPM), and for LLMs also so many tokens per minute (TPM) and a spending budget. Limits protect the service from overload and from one client starving the others. When you go over, you get 429 Too Many Requests, usually with a Retry-After header saying how many seconds to wait. LLM providers also send headers showing how much of your limit remains, which good clients watch.
The Practice API's /v1/limited allows 3 requests per 10 seconds for each client (identified by the X-Client-Id header). Send four quickly:
for i in 1 2 3 4; do
curl -s -w " -> %{http_code}\n" -H "X-Client-Id: nadia-laptop" http://localhost:8800/v1/limited
done
curl -si -H "X-Client-Id: nadia-laptop" http://localhost:8800/v1/limited | grep -i -E '^HTTP|^retry-after' | tr -d '\r'{"ok":true,"remaining":2} -> 200
{"ok":true,"remaining":1} -> 200
{"ok":true,"remaining":0} -> 200
{"detail":"Too many requests"} -> 429
HTTP/1.1 429 Too Many Requests
retry-after: 10The fourth and fifth requests were refused, and the server says: try again in 10 seconds. Another client has its own allowance:
curl -s -H "X-Client-Id: rafi-laptop" http://localhost:8800/v1/limited{"ok":true,"remaining":2}The right response to a 429 is to wait and retry, not to retry immediately in a tight loop (which only extends the block, and on some APIs gets your key suspended). In Python, two pages from now, you will write a retry function that reads Retry-After.
API clients with a window
curl is everywhere and scriptable, which is why this tutorial uses it. For exploring a big API by hand, many developers also use a GUI or a friendlier CLI:
| Tool | What it is | Good to know |
|---|---|---|
Swagger UI (/docs) | Interactive docs generated from OpenAPI | Comes free with FastAPI; perfect for your own APIs. |
| Bruno | Open-source desktop API client | Stores requests as plain files in your repository, so they go through Git and code review like code. |
| Postman | The best-known API platform | Collections, environments, team sharing; cloud features need an account. |
| HTTPie | A friendlier command-line client (http :8800/health) | Coloured, readable output; JSON by default. |
| Editor extensions | e.g. "REST Client" for VS Code | Write requests in a .http file next to your code and click to send. |
Whatever you choose, keep keys in the tool's environment/secret settings, not inside saved requests that you commit or share.
Where you will use this
- Trying an LLM API for the first time. The quickest check that a key works is one curl command from the provider's docs, with the key in an environment variable. If curl works and your Python does not, the problem is in your code, not your account.
- Reading provider docs. Base URL, auth header, endpoint, body schema, errors, rate limits, versioning: the same checklist works for OpenAI, Anthropic, Hugging Face, a vector database or a payment API.
- Your own API's documentation. FastAPI will give your backend the same
/docsand/openapi.jsonfor free, which your front-end colleagues and your tests will use. - Bug reports. A failing request reduced to one curl command (key removed) is the most useful thing you can put in an issue.
Common mistakes
- Keys in commands, URLs, screenshots or commits. Use an environment variable,
-H "X-API-Key: $PRACTICE_API_KEY", and.envin.gitignore. If a real key leaks, revoke it in the dashboard first, then clean up. - The wrong auth header.
Authorization: sk-…withoutBearer, or a key inAuthorizationwhen the API wantsx-api-key, gives a 401 that looks like a bad key. Copy the header name and format from the docs. - Trusting curl's exit code 0. curl reports success for 404 and 500 responses. In scripts use
--fail-with-body(or check-w "%{http_code}"). - No timeout. One slow upstream request can hang a script forever. Use
-min curl andtimeout=in Python, always. - Hammering after a 429. Retrying instantly in a loop keeps you blocked and wastes quota. Wait at least
Retry-Afterseconds, and slow your overall request rate.
Try it yourself
- Easy: Using the OpenAPI spec, print the parameters of
GET /v1/courses(name, where it goes, and whether it is required). - Medium: With the API key in
PRACTICE_API_KEY, post a review for course 99. Which status do you expect, and why is it not 422? - Hard: Write a small bash loop that calls
/v1/limitedas clientbatch-jobfive times and prints, for each call, the status code and (only when it is 429) theRetry-Aftervalue.
Answers
# 1
curl -s http://localhost:8800/openapi.json | python -c '
import json, sys
spec = json.load(sys.stdin)
for p in spec["paths"]["/v1/courses"]["get"]["parameters"]:
print(p["name"], p["in"], "required" if p.get("required") else "optional")
'level query optional
page query optional
per_page query optional# 2
curl -s -w " -> %{http_code}\n" http://localhost:8800/v1/reviews -H "X-API-Key: $PRACTICE_API_KEY" \
--json '{"course_id": 99, "rating": 4, "comment": "Looking forward to it"}'{"detail":"Course 99 not found"} -> 404The body is valid (99 is an integer ≥ 1, so validation passes), but there is no course 99 to review, so the server answers 404. Validation checks the shape of the data; the 404 comes from checking the data against what exists.
# 3
for i in 1 2 3 4 5; do
headers=$(curl -s -D - -o /dev/null -H "X-Client-Id: batch-job" http://localhost:8800/v1/limited | tr -d '\r')
code=$(echo "$headers" | head -n 1 | cut -d ' ' -f 2)
if [ "$code" = "429" ]; then
wait=$(echo "$headers" | grep -i '^retry-after:' | cut -d ' ' -f 2)
echo "call $i: $code, retry after $wait s"
else
echo "call $i: $code"
fi
donecall 1: 200
call 2: 200
call 3: 200
call 4: 429, retry after 10 s
call 5: 429, retry after 10 s-D - writes the response headers to standard output, and -o /dev/null throws the body away, so headers holds just the status line and headers. Doing this in bash is possible but clumsy; the next page does the same job far more cleanly in Python.
Summary
- A web API is a contract over HTTP. REST organises it around resources (nouns in paths) and uses HTTP methods as verbs; an endpoint is one method + path.
- Run the Practice API in a second terminal with
fastapi run practice_api.py --port 8800. Its docs are at/docs, generated from the OpenAPI spec at/openapi.json. - Read any API's docs for: base URL, authentication, endpoints, parameters, responses and errors, limits and pricing, versioning. Versions live in the path (
/v1) or a header. - API keys go in a header such as
X-API-Key; tokens go inAuthorization: Bearer …. 401 = who are you, 403 = not allowed, 422 = bad data. Keep secrets in environment variables. - curl's key options:
-s -i -v -X -H --json -G --data-urlencode -o -w -L -m --fail-with-body. Exit code 22 = HTTP error, 28 = timeout. A 429 comes withRetry-After: wait, then retry.
Next: Calling APIs from Python with requests. Everything you did with curl, now in Python: parameters, headers, JSON, errors, timeouts, keys from the environment, and pagination.