REST API Design¶
How to shape HTTP endpoints so they're predictable, discoverable, and easy to consume — and how to design inference endpoints for ML/AI models that fit the same conventions.
Table of Contents¶
- Installation / Setup
- Core Syntax
- Common Operations
- Practical Examples
- Common Patterns
- Best Practices
- Common Mistakes
- Troubleshooting
- Related Cheat Sheets
- Official Documentation
Installation / Setup¶
REST is a design style over HTTP, not a library. You implement it with a framework (FastAPI, Flask) and test it with curl, Postman, or requests.
Core Syntax¶
Resources & endpoints¶
A resource is a noun (users, orders, predictions); an endpoint is method + path:
GET /v1/users # list
POST /v1/users # create
GET /v1/users/42 # read one
PUT /v1/users/42 # replace
PATCH /v1/users/42 # partial update
DELETE /v1/users/42 # delete
| Rule | Example ✓ | Example ✗ |
|---|---|---|
| Nouns, not verbs in paths | /v1/predictions |
/v1/getPrediction |
| Plural resources | /v1/models |
/v1/model |
| Nested for relations | /v1/models/{id}/versions |
/v1/versions?model=1 |
| Actions as POST sub-resources | POST /v1/experiments/{id}/run |
GET /v1/runExperiment |
Methods & semantics¶
| Method | Meaning | Idempotent | Body? | Safe? |
|---|---|---|---|---|
GET |
read | yes | no | yes |
POST |
create / action | no | yes | no |
PUT |
full replace | yes | yes | no |
PATCH |
partial update | no* | yes | no |
DELETE |
remove | yes | usually not | no |
A PATCH to the same doc twice can differ if fields are relative; PUT is idempotent. Inference is an action* → POST /v1/predict (with retries you may get the same result, but the server performs work each call).
Parameters¶
| Kind | Location | Example | Use for |
|---|---|---|---|
| Path params | in the URL | /v1/models/{model_id} |
identifying a resource |
| Query params | ? after path |
/v1/records?limit=10&offset=20 |
filtering/pagination/options |
| Headers | request metadata | Authorization: Bearer … |
auth, content negotiation |
| Body | JSON payload | {"text": "…"} |
creating/updating, inference input |
# FastAPI
@app.get("/v1/models/{model_id}")
def get_model(model_id: int, include_weights: bool = False): ...
@app.post("/v1/predict")
def predict(req: PredictRequest): ... # body -> typed model
Common Operations¶
Status codes — use the whole range¶
| Code | When to return it |
|---|---|
200 OK |
successful GET/PUT/PATCH |
201 Created |
successful POST (return the resource + Location) |
202 Accepted |
async job queued (train, batch inference) |
204 No Content |
DELETE succeeded |
400 Bad Request |
malformed body / missing field |
401 Unauthorized |
missing/invalid credentials |
403 Forbidden |
authenticated but not allowed |
404 Not Found |
unknown resource |
409 Conflict |
duplicate create / state conflict |
422 Unprocessable Content |
validation failure (FastAPI default) |
429 Too Many Requests |
rate limited (set Retry-After) |
5xx |
server bugs — never for client mistakes |
Error handling contract¶
Every error should be a JSON document a client can parse:
{
"error": {
"code": "model_not_found",
"message": "No model with id 'v99'",
"details": {"available_models": ["v1", "v2"]}
}
}
# FastAPI — HTTPException with a stable, machine-readable body
from fastapi import HTTPException
@app.get("/v1/models/{model_id}")
def get_model(model_id: str):
if model_id not in registry:
raise HTTPException(status_code=404, detail={"code": "model_not_found",
"message": f"No model {model_id}"})
return registry[model_id]
Versioning¶
| Strategy | Example | Notes |
|---|---|---|
| URL prefix (most common) | /v1/predict, /v2/predict |
explicit; easy to route |
| Header | Accept-Version: v2 |
cleaner URL, hidden in logs |
| Query | ?version=2 |
simple, pollutes URLs |
Keep old versions alive until clients migrate; document breaking changes.
Pagination, filtering, sorting¶
@app.get("/v1/runs")
def list_runs(limit: int = 20, offset: int = 0,
status: str | None = None,
sort: str = "-created_at"):
...
Return a wrapper so clients see total + next page:
Practical Examples¶
1. ML inference endpoint (the canonical shape)¶
# POST /v1/predict — JSON in, JSON out, typed both ways
from pydantic import BaseModel, Field
class PredictRequest(BaseModel):
text: str = Field(min_length=1)
model: str = "v1"
class PredictResponse(BaseModel):
label: str
score: float = Field(ge=0, le=1)
latency_ms: float
@app.post("/v1/predict", response_model=PredictResponse)
def predict(req: PredictRequest):
t0 = time.perf_counter()
label, score = run_inference(req.text, req.model)
return PredictResponse(label=label, score=score,
latency_ms=(time.perf_counter() - t0) * 1000)
2. Async job for long training¶
# POST /v1/training-jobs -> 202 + job id
# GET /v1/training-jobs/{id} -> {status: "running"|"done"|"failed", metrics?}
3. Batch predictions¶
# POST /v1/batch-predict {"items": [{"text": "..."}, ...]}
# -> {"results": [{"index": 0, "label": "...", "score": 0.9}, ...]}
# Always echo the input index so clients can rejoin results to inputs.
Common Patterns¶
# Health + readiness endpoints (CI, orchestrators, probes)
@app.get("/health") # liveness — process up
@app.get("/ready") # readiness — model loaded, DB reachable
# Consistent envelope vs bare objects: pick one and document it
# Bare is idiomatic REST; envelopes help when adding metadata
# Idempotency keys for retries
# POST with header Idempotency-Key: <uuid> -> same result on retry
Best Practices¶
- Design resources around nouns, put actions on sub-resources, and let verbs stay HTTP methods.
- Validate input schemas (pydantic) — typed request/response models are your contract.
- Return precise status codes and parseable error JSON — never bare 500s with HTML.
- Version the URL from day one (
/v1/) — you will break something. - Pagination from the start (
limit/offsetor cursor) — even small lists grow. - Echo identifiers in batch responses so clients can align results.
- Auth on day one even for internal tools (see authentication section below).
- Document automatically: OpenAPI (
/docsin FastAPI) is free documentation. - Inference endpoints: POST, typed JSON, meaningful latency/timeout, and rate limits.
Common Mistakes¶
| Mistake | Consequence | Fix |
|---|---|---|
Verbs in URLs (/getUsers) |
Non-uniform, awkward | Nouns + methods |
| GET that mutates state | Caching/proxies replay it | POST for actions |
200 for everything |
Clients can't distinguish errors | Full status-code range |
| HTML/plain-text errors | Clients can't parse | JSON error contract |
| No pagination | Slow endpoints as data grows | Paginate from v1 |
| Breaking changes without versioning | Client breakage | /v1/, /v2/ |
Unbounded limit |
Memory/DoS | Cap (le=100) |
| Secrets in query strings | Leak in logs | Headers only |
| No timeouts documented | Client hangs | Document + enforce server timeouts |
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| 404 on valid path | Trailing slash / typo / route order | Check OpenAPI docs; explicit routes |
| 405 Method Not Allowed | Wrong verb | Use the documented method |
| 422 errors | Schema mismatch | Read detail; match field names/types exactly |
| Clients retry a POST blindly | Duplicate side effects | Idempotency keys or accept duplicate harm |
| Model slow → proxy timeout | Inference too long | Async job pattern (202) or longer timeout |
| CORS failures in browsers | Missing headers | Configure CORS middleware for allowed origins |
Related Cheat Sheets¶
- fastapi.md — build & document these endpoints in minutes
- flask.md — lightweight alternative
- python-requests.md · curl.md · postman.md — testing & consuming
- http.md · json.md — protocol & payload
- Home · Central Navigation Index
Official Documentation¶
- REST API tutorial (RESTfulAPI.net)
- Microsoft REST API guidelines
- OpenAPI Specification
- Zalando REST guidelines
Last Updated¶
2026-09-06 — Initial version.