Skip to content

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

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:

{"items": [...], "total": 132, "limit": 20, "offset": 0, "next": "/v1/runs?limit=20&offset=20"}

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/offset or 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 (/docs in 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

Official Documentation

Last Updated

2026-09-06 — Initial version.