অধ্যায় 2 · API ডিজাইন ও API ক্লায়েন্ট
HTTP গভীরে: ইডেমপোটেন্সি, ক্যাশিং ও অথ
- পৃষ্ঠা 4 / 22
- 27 মিনিট পড়া
বিগিনার টিউটোরিয়ালে আপনি রিকোয়েস্ট পাঠিয়েছেন, স্ট্যাটাস কোড পড়েছেন, আর API কি ও বেয়ারার টোকেন দিয়ে API কল করেছেন। একটা API ব্যবহার করার জন্য সেটুকু যথেষ্ট। কিন্তু একটা API ডিজাইন বা চালানোর জন্য যথেষ্ট নয়। কেন একটা রিকোয়েস্ট নিরাপদে রিট্রাই করা যায়, আর আরেকটা যায় না? ঢাকায় বসে একটা মোবাইল অ্যাপ কেন প্রতিবার চালু হলে ২ MB-র একটা মডেল কার্ড নতুন করে নামায়, অথচ সার্ভার মাত্র ২০০ বাইটে বলে দিতে পারত "কিছু বদলায়নি"? কেন 401 আর 403 এক জিনিস নয়, আর "Sign in with Google" বললে আসলে কী ঘটে? কোনো AI মডেলকে API-র পেছনে বসালেই এই প্রশ্নগুলো ফিরে আসে, কারণ মডেল কল ধীর, ব্যয়বহুল, আর প্রায়ই রিট্রাই করতে হয়।
এই পাতা একটা ছোট FastAPI অ্যাপ দিয়ে উত্তরগুলো দেয়, যেটা আপনি নিজে চালাবেন আর curl দিয়ে খোঁচা দিয়ে দেখবেন: মেথড ও ইডেমপোটেন্সি, স্ট্যাটাস কোড, কনটেন্ট নেগোশিয়েশন, ETag ও 304 দিয়ে ক্যাশিং, অথেনটিকেশন ও অথরাইজেশন, API কি বনাম বেয়ারার টোকেন, এবং OAuth 2.0 / OpenID Connect-এর ফ্লো, সাথে PKCE যাচাই করে এমন একটা ছোট কাজ-করা অথরাইজেশন সার্ভার।
যা শিখবেন
- কোন HTTP মেথড safe আর কোনটা idempotent, কেন সেটা ঠিক করে দেয় ক্লায়েন্ট কী রিট্রাই করতে পারবে, আর কখন PUT আর কখন PATCH বেছে নেবেন।
- বাস্তবে গুরুত্বপূর্ণ স্ট্যাটাস কোডগুলো, আর প্রতিটার ক্ষেত্রে ক্লায়েন্টের কী করা উচিত।
Acceptদিয়ে কনটেন্ট নেগোশিয়েশন, আরCache-Control,ETag,If-None-Matchও304দিয়ে ক্যাশিং, FastAPI-তে বানিয়ে।- অথেনটিকেশন বনাম অথরাইজেশন (401 বনাম 403), API কি বনাম বেয়ারার টোকেন, আর স্কোপ।
- OAuth 2.0 ও OpenID Connect: PKCE সহ authorization code, client credentials, আর কেন password grant ব্যবহার করা চলবে না।
একটা রিকোয়েস্ট, শুরু থেকে শেষ
এই পাতার সবকিছু একটা রিকোয়েস্ট আর তার রেসপন্সের ভেতরেই ঘটে। ছবিটা মনে রাখুন; প্রতিটা সেকশন একটা করে তীরে জুম করবে।
client server
| 1. DNS lookup, TCP connect, TLS handshake |
|------------------------------------------->|
| 2. METHOD /path?query HTTP/1.1 | method: what to do
| Host, Accept, Authorization, If-None-Match headers: who, which format, which version
| body (JSON) |
|------------------------------------------->|
| 3. authenticate (who?) | 401 if unknown
| 4. authorise (allowed?) | 403 if not allowed
| 5. validate + run | 400/404/409/422
| 6. status, headers (ETag, Cache-Control), |
| body |
|<-------------------------------------------|
| 7. client caches, parses, retries or fails |ল্যাব অ্যাপ
একটা প্রজেক্ট আর চারটা ছোট ফাইল বানান। এখন চোখ বুলিয়ে নিন; প্রতিটা অংশ যে সেকশনে কাজে লাগবে, সেখানে ব্যাখ্যা করা হয়েছে। main.py-তে মডেল রিসোর্স, caching.py-তে নেগোশিয়েট ও ক্যাশ করা রিড এন্ডপয়েন্ট, auth.py-তে 401/403-এর যুক্তি, আর oauth.py-তে একটা খেলনা অথরাইজেশন সার্ভার (আসল সার্ভার, যেমন Keycloak, Auth0 বা ক্লাউডের আইডেন্টিটি সার্ভিস, একটা প্রোডাক্ট যা আপনি নিজে লিখবেন না; এটা শুধু প্রোটোকলটা চোখে দেখার জন্য)।
mkdir -p ~/projects/http-lab
cd ~/projects/http-lab
uv init --bare -q
uv add -q "fastapi[standard]"
# uv-এর বদলে pip দিয়ে:
# python -m venv .venv && source .venv/bin/activate
# pip install -q "fastapi[standard]"
lspyproject.toml
uv.lock# file: main.py
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException, Response
from pydantic import BaseModel
import auth
import caching
import oauth
from caching import MODELS, etag_of, model_body
app = FastAPI(title="HTTP lab")
app.include_router(caching.router)
app.include_router(auth.router)
app.include_router(oauth.router)
JOBS = {"count": 0}
class ModelIn(BaseModel):
dim: int
status: str
class ModelPatch(BaseModel):
dim: int | None = None
status: str | None = None
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/jobs", status_code=201)
def create_job(response: Response):
JOBS["count"] += 1 # every call creates something new
response.headers["Location"] = f"/jobs/{JOBS['count']}"
return {"job": JOBS["count"]}
@app.put("/models/{name}")
def put_model(
name: str,
item: ModelIn,
response: Response,
if_match: Annotated[str | None, Header()] = None,
):
created = name not in MODELS
if if_match is not None and (created or if_match != etag_of(model_body(name), "json")):
raise HTTPException(412, "the model changed since you read it")
MODELS[name] = item.model_dump() # replace the whole resource
response.status_code = 201 if created else 200
return model_body(name)
@app.patch("/models/{name}")
def patch_model(name: str, item: ModelPatch):
if name not in MODELS:
raise HTTPException(404, "model not found")
MODELS[name].update(item.model_dump(exclude_unset=True)) # change only what was sent
return model_body(name)
@app.delete("/models/{name}", status_code=204)
def delete_model(name: str):
if MODELS.pop(name, None) is None:
raise HTTPException(404, "model not found")# file: caching.py
import hashlib
import json
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, Request, Response
from fastapi.responses import JSONResponse, PlainTextResponse
router = APIRouter()
MODELS = {"embed-small": {"dim": 384, "status": "active"}}
def model_body(name: str) -> dict:
return {"name": name, **MODELS[name]}
def etag_of(body: dict, fmt: str) -> str:
"""A strong validator: changes whenever the representation changes."""
digest = hashlib.sha256(json.dumps([fmt, body], sort_keys=True).encode()).hexdigest()
return f'"{digest[:12]}"'
@router.get("/models/{name}")
def get_model(name: str, request: Request, accept: Annotated[str, Header()] = "*/*"):
if name not in MODELS:
raise HTTPException(404, "model not found")
if "application/json" in accept or "*/*" in accept:
fmt = "json"
elif "text/plain" in accept:
fmt = "text"
else:
raise HTTPException(406, "available: application/json, text/plain")
body = model_body(name)
headers = {"ETag": etag_of(body, fmt), "Cache-Control": "no-cache", "Vary": "Accept"}
if request.headers.get("if-none-match") == headers["ETag"]:
return Response(status_code=304, headers=headers)
if fmt == "text":
return PlainTextResponse("\n".join(f"{k}={v}" for k, v in body.items()), headers=headers)
return JSONResponse(body, headers=headers)
@router.get("/catalog")
def catalog(response: Response):
response.headers["Cache-Control"] = "public, max-age=300"
return {"models": sorted(MODELS)}# file: auth.py
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException
from caching import MODELS, model_body
router = APIRouter(prefix="/secure")
# Bearer tokens (the OAuth server in oauth.py adds more) and API keys for services.
TOKENS = {"tok-read": {"sub": "nadia", "scopes": ["models:read"]}}
API_KEYS = {"key-ingest-1": "ingest-service"}
def bearer(authorization: Annotated[str | None, Header()] = None) -> dict:
"""Authentication: who is calling? Failure is 401 and says how to authenticate."""
if authorization is None or not authorization.startswith("Bearer "):
raise HTTPException(401, "missing bearer token", headers={"WWW-Authenticate": "Bearer"})
token = TOKENS.get(authorization.removeprefix("Bearer "))
if token is None:
raise HTTPException(401, "invalid token", headers={"WWW-Authenticate": 'Bearer error="invalid_token"'})
return token
def need(scope: str):
"""Authorisation: is this caller allowed to do this? Failure is 403."""
def check(token: Annotated[dict, Depends(bearer)]) -> dict:
if scope not in token["scopes"]:
raise HTTPException(403, f"requires scope {scope}")
return token
return check
@router.get("/models")
def list_models(token: Annotated[dict, Depends(need("models:read"))]):
return {"user": token["sub"], "models": sorted(MODELS)}
@router.post("/models/{name}/retire")
def retire(name: str, token: Annotated[dict, Depends(need("models:write"))]):
if name not in MODELS:
raise HTTPException(404, "model not found")
MODELS[name]["status"] = "retired"
return {**model_body(name), "by": token["sub"]}
@router.post("/ingest")
def ingest(x_api_key: Annotated[str | None, Header()] = None):
service = API_KEYS.get(x_api_key or "")
if service is None:
raise HTTPException(401, "invalid or missing API key")
return {"accepted": True, "service": service}# file: oauth.py
import hashlib
import itertools
from base64 import urlsafe_b64encode
from typing import Annotated
from fastapi import APIRouter, Form, HTTPException
from fastapi.responses import JSONResponse, RedirectResponse
from auth import TOKENS
router = APIRouter(prefix="/oauth")
CLIENTS = {
# a confidential client (a backend job) can keep a secret
"batch-job": {"secret": "batch-secret", "scopes": ["models:read", "models:write"]},
# a public client (a browser or mobile app) cannot: it relies on PKCE instead
"web-app": {"secret": None, "redirect_uri": "http://localhost:3000/callback", "scopes": ["models:read"]},
}
CODES: dict[str, dict] = {}
code_numbers = itertools.count(1)
token_numbers = itertools.count(2) # tok-read is token 1
def s256(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode()).digest()
return urlsafe_b64encode(digest).rstrip(b"=").decode()
def issue(sub: str, scopes: list[str]) -> JSONResponse:
token = f"tok-{next(token_numbers)}"
TOKENS[token] = {"sub": sub, "scopes": scopes}
body = {"access_token": token, "token_type": "Bearer", "expires_in": 600, "scope": " ".join(scopes)}
return JSONResponse(body, headers={"Cache-Control": "no-store"})
def error(code: str, status: int = 400) -> JSONResponse:
return JSONResponse({"error": code}, status_code=status, headers={"Cache-Control": "no-store"})
@router.get("/authorize")
def authorize(
response_type: str, client_id: str, redirect_uri: str, scope: str, state: str,
code_challenge: str, code_challenge_method: str,
):
"""Normally a login + consent page. Here Nadia has already said yes."""
client = CLIENTS.get(client_id)
if client is None or client.get("redirect_uri") != redirect_uri:
raise HTTPException(400, "unknown client or unregistered redirect_uri") # never redirect there
if response_type != "code" or code_challenge_method != "S256":
return RedirectResponse(f"{redirect_uri}?error=invalid_request&state={state}", status_code=302)
code = f"code-{next(code_numbers)}"
CODES[code] = {"client_id": client_id, "challenge": code_challenge, "scopes": scope.split(), "sub": "nadia"}
return RedirectResponse(f"{redirect_uri}?code={code}&state={state}", status_code=302)
@router.post("/token")
def token(
grant_type: Annotated[str, Form()],
client_id: Annotated[str, Form()],
client_secret: Annotated[str | None, Form()] = None,
scope: Annotated[str, Form()] = "",
code: Annotated[str | None, Form()] = None,
code_verifier: Annotated[str | None, Form()] = None,
):
client = CLIENTS.get(client_id)
if client is None:
return error("invalid_client", 401)
if grant_type == "client_credentials":
if client["secret"] is None or client["secret"] != client_secret:
return error("invalid_client", 401)
wanted = scope.split() or client["scopes"]
if not set(wanted) <= set(client["scopes"]):
return error("invalid_scope")
return issue(client_id, wanted)
if grant_type == "authorization_code":
grant = CODES.pop(code or "", None) # a code works once
if grant is None or grant["client_id"] != client_id or s256(code_verifier or "") != grant["challenge"]:
return error("invalid_grant")
return issue(grant["sub"], grant["scopes"])
return error("unsupported_grant_type")ব্যাকগ্রাউন্ডে সার্ভার চালু করুন, আউটপুট ফাইলে পাঠিয়ে, তারপর সাড়া না দেওয়া পর্যন্ত অপেক্ষা করুন। নিচের রিকোয়েস্টগুলোতে একটা ছোট র্যাপার স্ক্রিপ্ট ./call ব্যবহার হয়েছে, যা curl -s চালিয়ে বডির একই লাইনে স্ট্যাটাস কোড জুড়ে দেয়।
uv run fastapi run main.py --port 8000 > server.log 2>&1 &
export SERVER_PID=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health
cat > call <<'EOF'
#!/bin/sh
exec curl -s -w ' -> HTTP %{http_code}\n' "$@"
EOF
chmod +x call
export BASE=http://localhost:8000{"status":"ok"}Safe ও idempotent মেথড
HTTP মেথডের দুটো বৈশিষ্ট্য ঠিক করে দেয় ক্লায়েন্ট, প্রক্সি, ব্রাউজার আর রিট্রাই লাইব্রেরি আপনাকে না জিজ্ঞেস করেই কী করতে পারবে।
- Safe: রিকোয়েস্ট সার্ভারে কিছু বদলায় না (শুধু পড়ে)। Safe রিকোয়েস্ট যখন-তখন প্রিফেচ, ক্যাশ ও পুনরাবৃত্তি করা যায়।
- Idempotent: রিকোয়েস্টটা একবার পাঠান বা দশবার, সার্ভারের অবস্থা একই থাকে। টাইমআউটের পর Idempotent রিকোয়েস্ট নির্ভয়ে রিট্রাই করা যায়, কারণ প্রথমটা পৌঁছেছিল কি না আপনি জানেন না।
| মেথড | অর্থ | Safe | Idempotent | টাইমআউটের পর রিট্রাই? |
|---|---|---|---|---|
GET, HEAD, OPTIONS | পড়া | হ্যাঁ | হ্যাঁ | হ্যাঁ |
PUT | একটা রিসোর্সকে এই রূপ দিয়ে প্রতিস্থাপন | না | হ্যাঁ | হ্যাঁ |
DELETE | রিসোর্স মুছে ফেলা | না | হ্যাঁ | হ্যাঁ |
POST | কিছু তৈরি / কোনো কাজ চালানো | না | না | শুধু idempotency key থাকলে (পরের পাতা) |
PATCH | রিসোর্সের একটা অংশ বদলানো | না | প্যাচের ওপর নির্ভর করে | প্যাচটা যে idempotent তা জানলেই শুধু |
"Idempotent" কথাটা সার্ভারের অবস্থা নিয়ে, রেসপন্স নিয়ে নয়: দ্বিতীয় DELETE হয়তো 404 দেবে, কিন্তু মডেলটা তখনও নেই। হাতে-কলমে দেখুন। আগে তিনবার POST, তারপর দুবার PUT:
./call -X POST $BASE/jobs
./call -X POST $BASE/jobs
./call -X POST $BASE/jobs
echo "--- PUT twice"
./call -X PUT $BASE/models/embed-large -H 'Content-Type: application/json' -d '{"dim": 1024, "status": "active"}'
./call -X PUT $BASE/models/embed-large -H 'Content-Type: application/json' -d '{"dim": 1024, "status": "active"}'{"job":1} -> HTTP 201
{"job":2} -> HTTP 201
{"job":3} -> HTTP 201
--- PUT twice
{"name":"embed-large","dim":1024,"status":"active"} -> HTTP 201
{"name":"embed-large","dim":1024,"status":"active"} -> HTTP 200তিনটা POST তিনটা জব বানাল। দ্বিতীয়টায় টাইমআউট হয়ে আপনার ক্লায়েন্ট রিট্রাই করলে আপনি জানতেন না এখন দুটো জব, নাকি তিনটা; আর "ফাইন-টিউনিং রান শুরু করো" বা "কার্ডে চার্জ করো" জাতীয় কাজে সেটা সত্যিকারের বাগ। দুটো PUT-এ একটাই embed-large থেকেছে: প্রথমটা সেটা বানিয়েছে (201), দ্বিতীয়টা একই বিষয়বস্তু দিয়ে প্রতিস্থাপন করেছে (200)। এ কারণেই HTTP লাইব্রেরি GET আর PUT নিজে থেকে রিট্রাই করে, কিন্তু POST কখনো নয়।
PUT নাকি PATCH?
PUT পুরো নতুন রিসোর্স পাঠায়, তাই কোনো ফিল্ড বাদ পড়লে সেটা ভুল (বা ডিফল্টে ফিরে যায়)। PATCH শুধু পরিবর্তনটুকু পাঠায়।
./call -X PUT $BASE/models/embed-large -H 'Content-Type: application/json' -d '{"status": "retired"}'
./call -X PATCH $BASE/models/embed-large -H 'Content-Type: application/json' -d '{"status": "retired"}'
./call -X DELETE $BASE/models/embed-large
./call -X DELETE $BASE/models/embed-large{"detail":[{"type":"missing","loc":["body","dim"],"msg":"Field required","input":{"status":"retired"}}]} -> HTTP 422
{"name":"embed-large","dim":1024,"status":"retired"} -> HTTP 200
-> HTTP 204
{"detail":"model not found"} -> HTTP 404dim ছাড়া PUT 422 দিয়ে ফেরত গেছে; শুধু status দিয়ে PATCH কাজ করেছে। প্রথম DELETE 204 (সফল, বডি নেই) দিয়েছে; দ্বিতীয়টা 404, তবু শেষ অবস্থা একই, তাই DELETE idempotent। {"status": "retired"}-এর মতো PATCH idempotent; কিন্তু "quota-র সাথে ১০ যোগ করো" ধরনের প্যাচ নয়, আর ক্লায়েন্ট সেটা অন্ধভাবে রিট্রাই করবে না। প্যাচের একটা প্রমিত ফরম্যাট আছে JSON Patch (RFC 6902); অনেক API আমাদের মতোই শুধু বদলানো ফিল্ডগুলোর একটা JSON অবজেক্ট ব্যবহার করে (merge patch ধরন)।
বাস্তবে স্ট্যাটাস কোড
বিগিনার টিউটোরিয়ালে পরিবারগুলো আপনি চিনেছেন। বাস্তবে গুরুত্বপূর্ণ হলো পরে ক্লায়েন্ট কী করবে:
| কোড | কখন ব্যবহার করবেন | ক্লায়েন্টের করণীয় |
|---|---|---|
200, 201 + Location, 204 | ঠিক আছে / তৈরি হয়েছে / বডি ছাড়া সম্পন্ন | ফলাফল ব্যবহার করা |
202 | গ্রহণ করা হয়েছে, শেষ হয়নি (দীর্ঘ জব) | স্ট্যাটাস URL পোল করা (পরের পাতা) |
301, 308 | স্থায়ীভাবে সরে গেছে | অনুসরণ করা, সংরক্ষিত URL হালনাগাদ করা |
304 | বদলায়নি (শর্তসাপেক্ষ GET) | নিজের ক্যাশ করা কপি ব্যবহার করা |
400, 422 | ভুল ফরম্যাটের রিকোয়েস্ট / সঠিক JSON কিন্তু অবৈধ ডেটা | রিকোয়েস্ট ঠিক করা; না বদলে রিট্রাই নয় |
401 | অথেনটিকেটেড নয় (ক্রেডেনশিয়াল নেই বা ভুল) | লগইন বা টোকেন রিফ্রেশ করে একবার রিট্রাই |
403 | পরিচয় জানা আছে, কিন্তু অনুমতি নেই | রিট্রাই নয়; অ্যাক্সেস চান |
404, 410 | নেই / চিরতরে চলে গেছে | রিট্রাই নয় |
405, 406, 415 | ভুল মেথড / Accept করা ফরম্যাট দেওয়া যাচ্ছে না / বডির ধরন সমর্থিত নয় | রিকোয়েস্ট ঠিক করা |
409, 412 | বর্তমান অবস্থার সাথে দ্বন্দ্ব / পূর্বশর্ত (If-Match) মেলেনি | আবার পড়ুন, মিলিয়ে নিন, আবার চেষ্টা করুন |
429 | অনেক বেশি রিকোয়েস্ট | Retry-After পর্যন্ত থামুন, তারপর রিট্রাই |
500 | সার্ভারের বাগ | idempotent কলগুলো সতর্কভাবে রিট্রাই |
502, 503, 504 | খারাপ গেটওয়ে / অনুপলব্ধ / গেটওয়ে টাইমআউট | ব্যাকঅফসহ রিট্রাই (idempotent কল) |
এর তিনটা এখনই ঘটিয়ে দেখতে পারেন। 405 যে Allow হেডার পাঠাতে বাধ্য, সেটা লক্ষ করুন:
./call $BASE/models/does-not-exist
curl -s -i -X DELETE $BASE/health | tr -d '\r' | grep -i -E '^(HTTP|allow)'
./call -X PUT $BASE/models/x -H 'Content-Type: application/json' -d '{"dim": "big", "status": "active"}'{"detail":"model not found"} -> HTTP 404
HTTP/1.1 405 Method Not Allowed
allow: GET
{"detail":[{"type":"int_parsing","loc":["body","dim"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"big"}]} -> HTTP 422ভালো API স্ট্যাটাস কোড দিয়ে বলে ভুলটা কার, আর আবার চেষ্টা করলে লাভ আছে কি না: 4xx মানে "আপনাকে কিছু বদলাতে হবে", 5xx মানে "আমি ব্যর্থ, পরে চেষ্টা করুন"। বডিতে {"error": ...} রেখে 200 ফেরত দিলে স্ট্যাটাস লাইনের ওপর ভরসা করা প্রতিটা ক্লায়েন্ট, প্রক্সি আর মনিটরিং ড্যাশবোর্ড ভেঙে যায়।
কনটেন্ট নেগোশিয়েশন
একটা URL, কয়েকটা রূপ। ক্লায়েন্ট Accept দিয়ে জানায় সে কী পড়তে পারে; সার্ভার একটা বেছে নিয়ে Content-Type দিয়ে লেবেল করে, নয়তো 406 Not Acceptable দেয়। Vary: Accept হেডার ক্যাশকে জানায় যে রেসপন্সটা ওই হেডারের ওপর নির্ভরশীল। একই ধারণা Accept-Language (বাংলা বা ইংরেজি) আর Accept-Encoding-এও (gzip, যা সার্ভার ও ক্লায়েন্ট আপনার হয়ে সামলায়) খাটে।
./call $BASE/models/embed-small
./call -H 'Accept: text/plain' $BASE/models/embed-small
./call -H 'Accept: image/png' $BASE/models/embed-small
curl -s -i -H 'Accept: text/plain' $BASE/models/embed-small | tr -d '\r' | grep -i -E '^(content-type|vary)'{"name":"embed-small","dim":384,"status":"active"} -> HTTP 200
name=embed-small
dim=384
status=active -> HTTP 200
{"detail":"available: application/json, text/plain"} -> HTTP 406
vary: Accept
content-type: text/plain; charset=utf-8কোডে get_model Accept দেখে ফরম্যাট বেছে নেয়, বাকিগুলো ফেরত দেয়। রিকোয়েস্ট বডির বেলায় উল্টোটা: Content-Type বলে ক্লায়েন্ট কী পাঠাচ্ছে, আর সার্ভার পার্স করতে না পারলে 415 Unsupported Media Type দেয়। আসল সার্ভার কোয়ালিটি ভ্যালুও (Accept: text/plain;q=0.5, application/json) মানে; ফ্রেমওয়ার্ক ও লাইব্রেরি সেটা আপনার হয়ে করে, তাই আমরা ইচ্ছে করেই যুক্তিটা মোটা দাগের রেখেছি। অনেক API এভাবে ভার্সনও নেগোশিয়েট করে (Accept: application/vnd.example.v2+json); পরের পাতায় ভার্সনিংয়ের কৌশলগুলো তুলনা করা হয়েছে।
Cache-Control, ETag ও 304 দিয়ে ক্যাশিং
সবচেয়ে দ্রুত রিকোয়েস্ট সেটাই যা কখনো পাঠানো হয়নি, আর সবচেয়ে সস্তা রেসপন্স সেটাই যা ছোট। HTTP-তে দুটো কৌশল আছে, ভালো API দুটোই ব্যবহার করে।
- Freshness (
Cache-Control: max-age=…): সার্ভার বলে "এটা N সেকেন্ড বৈধ"; ক্লায়েন্ট ও শেয়ার করা ক্যাশ কোনো রিকোয়েস্ট ছাড়াই সেটা ব্যবহার করে। - Validation (
ETag+If-None-Match): কপিটা পুরনো হলে ক্লায়েন্ট জিজ্ঞেস করে "আমার কাছে"abc"ভার্সন আছে; এটা কি এখনো চলছে?" সার্ভার বডি ছাড়া304 Not Modifiedদেয়, নয়তো নতুন ভার্সনসহ পুরো200।
আমাদের এন্ডপয়েন্ট কী পাঠায় দেখুন:
curl -s -i $BASE/models/embed-small | tr -d '\r' | grep -i -E '^(HTTP|etag|cache-control)'
curl -s -i $BASE/catalog | tr -d '\r' | grep -i -E '^(HTTP|etag|cache-control)'HTTP/1.1 200 OK
etag: "3c66bb0b1a1d"
cache-control: no-cache
HTTP/1.1 200 OK
cache-control: public, max-age=300ক্যাটালগ আবার জিজ্ঞেস না করেই পাঁচ মিনিট ব্যবহার করা যাবে (public, max-age=300)। মডেল কার্ডে no-cache আছে, যার মানে "ক্যাশ করবেন না" নয়: মানে "রাখতে পারেন, কিন্তু ব্যবহারের আগে আমার কাছে যাচাই করে নিতে হবে"। এবার শর্তসাপেক্ষ রিকোয়েস্ট:
ETAG=$(curl -s -i $BASE/models/embed-small | tr -d '\r' | grep -i '^etag:' | cut -d' ' -f2)
echo "validator: $ETAG"
curl -s -o /dev/null -w 'first fetch: HTTP %{http_code}, %{size_download} bytes\n' $BASE/models/embed-small
curl -s -o /dev/null -w 'revalidation: HTTP %{http_code}, %{size_download} bytes\n' -H "If-None-Match: $ETAG" $BASE/models/embed-small
./call -X PATCH $BASE/models/embed-small -H 'Content-Type: application/json' -d '{"dim": 768}'
curl -s -o /dev/null -w 'after change: HTTP %{http_code}, %{size_download} bytes\n' -H "If-None-Match: $ETAG" $BASE/models/embed-smallvalidator: "3c66bb0b1a1d"
first fetch: HTTP 200, 50 bytes
revalidation: HTTP 304, 0 bytes
{"name":"embed-small","dim":768,"status":"active"} -> HTTP 200
after change: HTTP 200, 50 bytesETag হলো রূপটার একটা আঙুলের ছাপ (এখানে হ্যাশ; ডেটাবেস অ্যাপে হতে পারে রো-র ভার্সন)। ক্লায়েন্ট বডি আর ETag দুটোই রাখে; পরের বার ব্যবহারের সময় If-None-Match পাঠায়। না বদলালে: 304, বডির শূন্য বাইট, JSON পার্স করার ঝামেলা নেই। বদলালে: 200 আর নতুন বডি। FastAPI-তে এই যুক্তিটা আপনাকেই লিখতে হয়, যেমন get_model-এ (কিছু ফ্রেমওয়ার্ক ও CDN নিজে ETag বানায়)। কয়েকটা কথা:
| ডিরেক্টিভ / হেডার | অর্থ |
|---|---|
max-age=N | N সেকেন্ড fresh; রিকোয়েস্ট লাগে না |
no-cache | রাখা যায়, কিন্তু প্রতিবার যাচাই করতে হবে |
no-store | কখনো রাখা যাবে না: টোকেন, ব্যক্তিগত ডেটা, ব্যবহারকারীভেদে LLM উত্তরের জন্য |
public / private | শেয়ার করা ক্যাশ (CDN) রাখতে পারে / শুধু ব্যবহারকারীর নিজের ব্রাউজার রাখতে পারে |
s-maxage=N | max-age-এর মতো, কিন্তু শুধু শেয়ার করা ক্যাশের জন্য |
immutable | কখনো বদলায় না: হ্যাশ দিয়ে নাম দেওয়া ফাইলের জন্য ভালো (model-3f9a.bin) |
stale-while-revalidate=N | ব্যাকগ্রাউন্ডে রিফ্রেশ করার সময় পুরনো কপি দেখানো |
Vary: Accept, Authorization | রেসপন্স এই রিকোয়েস্ট হেডারগুলোর ওপর নির্ভর করে; ক্যাশকে এগুলো ধরে কি বানাতে হবে |
Weak ETag W/"…" | "অর্থে সমান"; strong ETag মানে বাইট-বাই-বাইট সমান |
একই যাচাই-চিহ্ন লেখার সময়ও রক্ষা করে। দুজন একই মডেল কার্ড এডিট করলে দ্বিতীয়জনের সেভ চুপচাপ প্রথমজনেরটা মুছে দেবে ("lost update")। If-Match দিলে সার্ভার লেখাটা তখনই নেয় যখন রিসোর্স এখনো আপনার পড়া ভার্সনেই আছে; নইলে 412 Precondition Failed:
ETAG=$(curl -s -i $BASE/models/embed-small | tr -d '\r' | grep -i '^etag:' | cut -d' ' -f2)
./call -X PATCH $BASE/models/embed-small -H 'Content-Type: application/json' -d '{"status": "testing"}'
./call -X PUT $BASE/models/embed-small -H "If-Match: $ETAG" -H 'Content-Type: application/json' -d '{"dim": 384, "status": "active"}'{"name":"embed-small","dim":768,"status":"testing"} -> HTTP 200
{"detail":"the model changed since you read it"} -> HTTP 412সহকর্মী B-র PATCH আমরা পড়ার পরে মডেলটা বদলে দিয়েছে, তাই পুরনো ETag সহ আমাদের PUT 412 দিয়ে ফেরত গেছে। আমাদের আবার পড়তে হবে, কী বদলেছে দেখতে হবে, তারপর সিদ্ধান্ত নিতে হবে। একাধিক মানুষ বা জব যে কনফিগ, প্রম্পট আর ডেটাসেট হালনাগাদ করে, তাদের জন্য এটা ব্যবহার করুন।
অথেনটিকেশন ও অথরাইজেশন
দুটো আলাদা প্রশ্ন, এই ক্রমে:
| অথেনটিকেশন (authn) | অথরাইজেশন (authz) | |
|---|---|---|
| প্রশ্ন | আপনি কে? | এটা করার অনুমতি আপনার আছে? |
| ব্যর্থ হলে | 401 Unauthorized (নামটা ভুল: মানে unauthenticated) | 403 Forbidden |
| রেসপন্সে অবশ্যই থাকবে | WWW-Authenticate: কীভাবে অথেনটিকেট করবেন | বিশেষ কিছু নয় |
| ঠিক হয় | বৈধ ক্রেডেনশিয়াল পাঠালে | রোল, স্কোপ বা পারমিশন পেলে |
| আমাদের কোডে | bearer() | need("models:write") |
চারটা ফলাফলই চালিয়ে দেখুন: টোকেন নেই, ভালো টোকেন, ভালো টোকেন কিন্তু অনুমতি নেই, আর ভুল টোকেন।
curl -s -i $BASE/secure/models | tr -d '\r' | grep -i -E '^(HTTP|www-authenticate)'
./call -H 'Authorization: Bearer tok-read' $BASE/secure/models
./call -X POST -H 'Authorization: Bearer tok-read' $BASE/secure/models/embed-small/retire
./call -H 'Authorization: Bearer wrong' $BASE/secure/modelsHTTP/1.1 401 Unauthorized
www-authenticate: Bearer
{"user":"nadia","models":["embed-small"]} -> HTTP 200
{"detail":"requires scope models:write"} -> HTTP 403
{"detail":"invalid token"} -> HTTP 401tok-read-এর অথেনটিকেশন সফল হয়েছে (সে Nadia), কিন্তু মডেল retire করতে models:write লাগে, তাই 403। অথরাইজেশন স্কোপের চেয়েও সূক্ষ্ম হতে পারে: Nadia এই বিশেষ মডেলটা ছুঁতে পারবে কি না, সেটা প্রতিটা অবজেক্টের আলাদা পরীক্ষা, আর এটা ভুলে যাওয়াই API-র এক নম্বর দুর্বলতা (BOLA; পরের একটা পাতায়)। কোনো অবজেক্ট আছে সেটা জানানোই যেখানে তথ্য ফাঁস করে, সেখানে 403-এর বদলে 404 ফেরত দিন।
API কি বনাম বেয়ারার টোকেন
দুটোই হেডারে যায়, আর দুটোই "যার হাতে আছে, সে-ই"। পার্থক্য ইস্যু, স্কোপ আর মেয়াদ শেষ করার ধরনে:
| API কি | বেয়ারার (অ্যাক্সেস) টোকেন | |
|---|---|---|
| কাকে চেনায় | একটা অ্যাপ্লিকেশন বা প্রজেক্ট | নির্দিষ্ট স্কোপ নিয়ে কাজ করা ব্যবহারকারী বা অ্যাপ |
| মেয়াদ | বাতিল না করা পর্যন্ত দীর্ঘ | ছোট (মিনিট); রিফ্রেশ টোকেন দিয়ে নবায়ন |
| কে দেয় | ড্যাশবোর্ড থেকে, একবার | লগইন বা OAuth ফ্লো, চাহিদামতো |
| সাধারণ হেডার | X-API-Key: … (LLM প্রোভাইডাররা দীর্ঘমেয়াদি কি-র জন্য Authorization: Bearer sk-… ব্যবহার করে) | Authorization: Bearer … |
| সবচেয়ে ভালো কাজে | সার্ভার-টু-সার্ভার কল, সহজ পাবলিক API, প্রজেক্টভেদে রেট লিমিট | ব্যবহারকারীর হয়ে কাজ; সীমিত স্কোপে তৃতীয় পক্ষের অ্যাক্সেস |
| ফাঁস হলে | কেউ বাতিল না করা পর্যন্ত বৈধ: সাথে সাথে রোটেট করুন | শিগগির মেয়াদ শেষ হয়; রিফ্রেশ টোকেন বাতিল করুন |
./call -X POST $BASE/secure/ingest
./call -X POST -H 'X-API-Key: key-ingest-1' $BASE/secure/ingest{"detail":"invalid or missing API key"} -> HTTP 401
{"accepted":true,"service":"ingest-service"} -> HTTP 200দুটোই শুধু HTTPS দিয়ে যাবে, কখনো URL-এ নয় (URL লগ আর ব্রাউজার হিস্ট্রিতে চলে যায়), আর ব্যবহারকারী পড়তে পারে এমন ফ্রন্টএন্ড কোডে কখনো নয়। ডেটাবেসে API কি-র শুধু হ্যাশ রাখুন, পাসওয়ার্ডের মতো (পরের একটা পাতায়)।
OAuth 2.0 ও OpenID Connect
"Sign in with Google" ক্লিক করলে বা কোনো নোটবুক আপনার ফাইল পড়ার অনুমতি চাইলে অ্যাপের কাছে কোনো পাসওয়ার্ড যায় না। এটাই OAuth 2.0: একটা প্রোটোকল, যা ব্যবহারকারীর সম্মতিতে একটা অ্যাপকে অথরাইজেশন সার্ভার থেকে সীমিত একটা অ্যাক্সেস টোকেন পেতে দেয়, যাতে সে একটা API (রিসোর্স সার্ভার) কল করতে পারে। OpenID Connect (OIDC) তার ওপরের পাতলা একটা স্তর, যা লগইন যোগ করে: অ্যাপ openid স্কোপও চাইলে সে একটা ID টোকেন (সাইন করা JWT, যা বলে ব্যবহারকারী কে) এবং একটা /userinfo এন্ডপয়েন্ট পায়। OAuth জবাব দেয় "এই অ্যাপ কী করতে পারে?", OIDC জবাব দেয় "এই মানুষটি কে?"। চারটা ভূমিকা হলো ব্যবহারকারী (resource owner), ক্লায়েন্ট (অ্যাপ), অথরাইজেশন সার্ভার আর রিসোর্স সার্ভার।
কোন ফ্লো?
| পরিস্থিতি | ফ্লো (grant) |
|---|---|
| ব্যবহারকারী ওয়েব, মোবাইল বা ডেস্কটপ অ্যাপে (সিঙ্গেল-পেজ অ্যাপেও) সাইন ইন করছেন | Authorization code + PKCE |
| ব্যাকএন্ড জব বা সার্ভিস নিজের পরিচয়ে চলে (ব্যবহারকারী নেই): ট্রেনিং পাইপলাইন, cron, মাইক্রোসার্ভিস | Client credentials |
| ব্রাউজারহীন টিভি বা CLI | Device authorization grant |
| সেশন চালু রাখা | Refresh token (প্রতিবার ব্যবহারে নতুন করে দিন) |
| নিজের first-party লগইন ফর্ম, যা ইউজারনেম-পাসওয়ার্ড পোস্ট করে | OAuth নয়: সাধারণ লগইন (পরের একটা পাতায়)। OAuth password grant ব্যবহার করা চলবে না (RFC 9700); পুরনো implicit grantও নয় |
এই শিক্ষাগুলো এক জায়গায় আনা OAuth 2.1 ২০২৬ সালেও খসড়া। বর্তমান নিয়মের বই RFC 9700 ("Best Current Practice for OAuth 2.0 Security", জানুয়ারি ২০২৫): প্রতিটা authorization code ফ্লোতে PKCE ব্যবহার করুন, redirect URI হুবহু মেলান, অ্যাক্সেস টোকেনের মেয়াদ ছোট রাখুন আর রিফ্রেশ টোকেন রোটেট করুন।
Client credentials: সার্ভিস নিজের পরিচয়ে কাজ করে
ক্লায়েন্ট একটা ID আর সিক্রেট দিয়ে নিজের পরিচয় প্রমাণ করে (সিক্রেট সার্ভারে থাকে, ব্রাউজারে কখনো নয়) এবং যে স্কোপ চেয়েছে তা পাওয়ার অধিকার থাকলে সেই স্কোপসহ একটা অ্যাক্সেস টোকেন পায়:
Service Authorization server API
| POST /token | |
| grant_type=client_credentials | |
| client_id + client_secret + scope | |
|----------------------------------->| |
| access_token (expires in 10 min) |
|<-----------------------------------| |
| GET /secure/models Authorization: Bearer <token> |
|------------------------------------------------------------->|./call -X POST $BASE/oauth/token -d grant_type=client_credentials -d client_id=batch-job -d client_secret=batch-secret -d scope=models:read
./call -X POST $BASE/oauth/token -d grant_type=client_credentials -d client_id=batch-job -d client_secret=wrong
./call -X POST $BASE/oauth/token -d grant_type=client_credentials -d client_id=batch-job -d client_secret=batch-secret -d scope=admin
./call -X POST $BASE/oauth/token -d grant_type=client_credentials -d client_id=batch-job -d client_secret=batch-secret{"access_token":"tok-2","token_type":"Bearer","expires_in":600,"scope":"models:read"} -> HTTP 200
{"error":"invalid_client"} -> HTTP 401
{"error":"invalid_scope"} -> HTTP 400
{"access_token":"tok-3","token_type":"Bearer","expires_in":600,"scope":"models:read models:write"} -> HTTP 200প্রথম টোকেন (tok-2) শুধু চাওয়া স্কোপটাই বহন করে: least privilege। ভুল সিক্রেটে invalid_client, আর অজানা স্কোপে invalid_scope। চতুর্থ কলে বিশেষ কিছু চাওয়া হয়নি, তাই ক্লায়েন্টের ডিফল্ট স্কোপ পেয়েছে। শেষ টোকেন দিয়ে একটা মডেল retire করুন:
./call -X POST -H 'Authorization: Bearer tok-3' $BASE/secure/models/embed-small/retire
./call -H 'Authorization: Bearer tok-2' $BASE/secure/models{"name":"embed-small","dim":768,"status":"retired","by":"batch-job"} -> HTTP 200
{"user":"batch-job","models":["embed-small"]} -> HTTP 200PKCE সহ Authorization code: ব্যবহারকারী সাইন ইন করছেন
অ্যাপ ব্যবহারকারীর পাসওয়ার্ড কখনো দেখে না। সে ব্রাউজারকে অথরাইজেশন সার্ভারে পাঠায়; ব্যবহারকারী সেখানে লগইন করে সম্মতি দেয়; সার্ভার একবার-ব্যবহারযোগ্য একটা code দিয়ে অ্যাপে ফেরত পাঠায়; অ্যাপ সরাসরি সার্ভারকে কল করে সেই কোডের বদলে টোকেন নেয়। এই স্বল্পায়ু কোডটাই দুর্বল জায়গা: কেউ সেটা আটকে ফেলতে পারে (ফোনে একই রিডাইরেক্ট লিংকে নিবন্ধিত দুষ্ট অ্যাপ, ব্রাউজার এক্সটেনশন, লগ), আর আগে বদলে নিতে পারে। PKCE (Proof Key for Code Exchange, উচ্চারণ "পিক্সি") এই ফাঁক বন্ধ করে। অ্যাপ একটা র্যান্ডম code_verifier বানায়, শুরুতে শুধু তার হ্যাশ, code_challenge, পাঠায়, আর শেষে ভেরিফায়ারটা নিজে দেখায়। যে অ্যাপ ফ্লো শুরু করেছে একমাত্র সে-ই পারে।
App (browser/mobile) Authorization server API
| 1. make code_verifier (secret random string)
| code_challenge = BASE64URL(SHA256(code_verifier))
| 2. redirect user: /authorize?response_type=code&client_id&redirect_uri
| &scope&state&code_challenge&code_challenge_method=S256 ------>|
| 3. user logs in, approves scopes |
| 4. redirect to redirect_uri?code=...&state=... <-------------------|
| 5. POST /token: grant_type=authorization_code, code, code_verifier ->|
| 6. server hashes the verifier, compares |
| 7. access_token (+ refresh_token, + id_token if scope has openid) <-|
| 8. GET /api Authorization: Bearer <access_token> ----------------------------->|state একটা র্যান্ডম মান, যা অ্যাপও পাঠায় আর ব্যবহারকারী ফিরে এলে মিলিয়ে দেখে, জাল রিডাইরেক্ট (CSRF) ঠেকাতে। চ্যালেঞ্জ হলো ভেরিফায়ারের SHA-256-এর প্যাডিং ছাড়া Base64URL; RFC 7636 একটা পরীক্ষার মান দিয়েছে, যা আপনি নিজেই হিসাব করে দেখতে পারেন:
import base64
import hashlib
def code_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
RFC_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
print(code_challenge(RFC_VERIFIER))E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMআসল অ্যাপে ভেরিফায়ার বানাবেন secrets.token_urlsafe(64) দিয়ে (৪৩ থেকে ১২৮ অক্ষর)। এবার আমাদের খেলনা সার্ভারে পুরো নাচটা চালান, RFC-র ভেরিফায়ার দিয়ে, যাতে আউটপুট হুবহু মেলে:
export VERIFIER=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
export CHALLENGE=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
export AUTH="$BASE/oauth/authorize?response_type=code&client_id=web-app&redirect_uri=http://localhost:3000/callback&scope=models:read&state=xyz&code_challenge=$CHALLENGE&code_challenge_method=S256"
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "$AUTH"
./call -X POST $BASE/oauth/token -d grant_type=authorization_code -d client_id=web-app -d code=code-1 -d code_verifier=$VERIFIER302 http://localhost:3000/callback?code=code-1&state=xyz
{"access_token":"tok-4","token_type":"Bearer","expires_in":600,"scope":"models:read"} -> HTTP 200প্রথম রিকোয়েস্ট অ্যাপের নিবন্ধিত callback-এ 302 রিডাইরেক্ট করেছে, সাথে code-1 আর আমাদের state। তারপর অ্যাপ ভেরিফায়ারসহ কোডটা দিয়ে একটা টোকেন নিয়েছে, যাতে শুধু ব্যবহারকারীর অনুমোদন করা স্কোপটুকুই আছে। এবার আক্রমণকারীর দৃষ্টিকোণ: সে একটা কোড চুরি করেছে কিন্তু ভেরিফায়ার জানে না, আর কোড মাত্র একবারই কাজ করে।
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "$AUTH"
./call -X POST $BASE/oauth/token -d grant_type=authorization_code -d client_id=web-app -d code=code-2 -d code_verifier=not-the-verifier
./call -X POST $BASE/oauth/token -d grant_type=authorization_code -d client_id=web-app -d code=code-2 -d code_verifier=$VERIFIER
./call -H 'Authorization: Bearer tok-4' $BASE/secure/models302 http://localhost:3000/callback?code=code-2&state=xyz
{"error":"invalid_grant"} -> HTTP 400
{"error":"invalid_grant"} -> HTTP 400
{"user":"nadia","models":["embed-small"]} -> HTTP 200ভুল ভেরিফায়ারে বিনিময় ব্যর্থ হয় (invalid_grant), আর প্রথম ব্যবহারেই কোড খরচ হয়ে যায় বলে আসল অ্যাপও সেটা আর ব্যবহার করতে পারে না: তাকে নতুন করে শুরু করতে হবে। প্রথম টোকেন tok-4 (code-1 থেকে পাওয়া) API-তে ঠিকমতো কাজ করে। একটা আসল সার্ভার আরও করে: redirect URI নিবন্ধিতটার সাথে অক্ষরে অক্ষরে মেলায় (আমাদেরটা অজানা URI-তে রিডাইরেক্ট না করে 400 দেয়), এক মিনিটের মধ্যে কোডের মেয়াদ শেষ করে, রিফ্রেশ টোকেন দেয়, আর অ্যাক্সেস টোকেন JWT হিসেবে সাইন করে, যা API যাচাই করতে পারে (JWT অথ-এর পাতায়)।
Password grant বিদায় নিয়েছে
পুরনো "resource owner password" grant-এ অ্যাপ আপনার ইউজারনেম-পাসওয়ার্ড সংগ্রহ করে টোকেন এন্ডপয়েন্টে পোস্ট করত। এতে ব্যবহারকারীরা প্রতিটা অ্যাপে পাসওয়ার্ড লিখতে শেখে, মাল্টি-ফ্যাক্টর লগইন অকেজো হয়, আর "Sign in with Google"-এর সাথে এটা চলেই না। RFC 9700 বলছে এটা ব্যবহার করা চলবে না। নিয়ম মানা সার্ভার এটা প্রত্যাখ্যান করে:
./call -X POST $BASE/oauth/token -d grant_type=password -d client_id=web-app -d username=nadia -d password=s3cret-pass{"error":"unsupported_grant_type"} -> HTTP 400আপনার নিজের first-party অ্যাপ নিজের লগইন ফর্ম দেখাতে পারে আর নিজের লগইন এন্ডপয়েন্ট কল করতে পারে (FastAPI টিউটোরিয়ালে যেমন); সেটা OAuth grant নয়, আর পাসওয়ার্ড আপনার কোডে পৌঁছায় শুধু সেখানেই। "Google, GitHub বা Microsoft দিয়ে লগইন"-এর জন্য লাইব্রেরির মাধ্যমে OIDC সহ authorization code + PKCE ব্যবহার করুন, আর টোকেন যাচাইয়ের কোড নিজে লিখবেন না।
স্কোপ
স্কোপ হলো একটা নামওয়ালা পারমিশন, যা ক্লায়েন্ট চায় আর ব্যবহারকারী (বা অ্যাডমিন) দেয়: models:read, models:write, openid, email। টোকেন অনুমোদিত স্কোপগুলো বহন করে, আর প্রতিটা এন্ডপয়েন্ট যেটা দরকার সেটাই চায় (need() যেমন করে)। স্কোপ ডিজাইন করুন ক্লায়েন্ট কী করে তা ধরে, ডেটাবেস টেবিল ধরে নয়; সংখ্যায় কম রাখুন; ন্যূনতমটুকু চান; আর ব্যবহারকারীকে সহজ ভাষার সম্মতি স্ক্রিন দেখান। স্কোপ অ্যাপকে সীমিত করে; ব্যবহারকারী কোনো অবজেক্ট ছুঁতে পারে কি না যাচাই করার বিকল্প সে নয়।
বাস্তব কাজে ব্যবহার
- LLM কল: POST idempotent নয়, তাই প্রোভাইডাররা idempotency key সমর্থন করে; আপনার ক্লায়েন্ট শুধু 429 আর 5xx রিট্রাই করে, আর
Retry-Afterমানে। - মডেল ও প্রম্পট রেজিস্ট্রি: ETag আর
If-Matchদুই প্রকৌশলীকে একে অপরের প্রম্পট ভার্সন মুছতে দেয় না;If-None-Matchদিয়ে সার্ভিং প্রসেস সস্তায় নতুন মডেল ফাইল আছে কি না পোল করে। - ড্যাশবোর্ড ও ডেটাসেট: ধীর অ্যাগ্রিগেট এন্ডপয়েন্টে
Cache-Control: public, max-age; ব্যক্তিগত যেকোনো কিছুতেno-store। - ইন্টিগ্রেশন: ব্যবহারকারীর ক্যালেন্ডার পড়া MCP সার্ভার বা প্লাগইন authorization code + PKCE ব্যবহার করে; রাতের ইভ্যালুয়েশন জব সবচেয়ে ছোট স্কোপে client credentials ব্যবহার করে।
সাধারণ ভুল
- টাইমআউটের পর POST রিট্রাই করা। দুবার চার্জ হতে পারে বা দুটো ট্রেনিং রান শুরু হতে পারে। শুধু idempotent রিকোয়েস্ট রিট্রাই করুন, নয়তো idempotency key যোগ করুন।
- "লগইন করা নেই"-র জন্য 403 (বা "অনুমতি নেই"-র জন্য 401) ব্যবহার। ক্লায়েন্ট বুঝতে পারে না আবার লগইন করবে কি না। 401 মানে "অথেনটিকেট করুন", 403 মানে "আপনার অনুমতি নেই"।
- ভুলের জন্য 200 ফেরত দেওয়া। মনিটরিং, ক্যাশ আর রিট্রাই লজিক স্ট্যাটাস লাইন পড়ে, আপনার JSON নয়।
no-cache-কে "জমা রাখবে না" ভাবা। সিক্রেটের জন্যno-storeব্যবহার করুন;no-cacheশুধু যাচাই বাধ্যতামূলক করে। আরও এক ভুল: বডিAcceptবাAuthorization-এর ওপর নির্ভর করলেVaryভুলে যাওয়া, ফলে ক্যাশ একজনের উত্তর আরেকজনকে দিয়ে দেয়।- PKCE এড়িয়ে যাওয়া, ঢিলেঢালা redirect URI, বা password grant। প্রতিবার PKCE, হুবহু redirect URI নিবন্ধন, আর ক্লায়েন্ট সিক্রেট ব্রাউজার ও মোবাইল অ্যাপের বাইরে রাখুন।
নিজে চেষ্টা করুন
এই পাতার ল্যাব সার্ভার এখনো চলছে। একই টার্মিনালে কাজ করুন।
- সহজ:
text/plainAcceptহেডার দিয়ে/models/embed-smallচেয়ে দেখান যে তার ETag দিয়ে যাচাই করলে 304 আসে। (JSON আর টেক্সট রূপের ETag আলাদা।) - সহজ: একই বডি দিয়ে
PUT /models/embed-xlদুবার পাঠান, তারপরDELETEদুবার। শুধু চারটা স্ট্যাটাস কোড ছাপান। - মাঝারি: lost-update প্রতিরোধ দেখান:
embed-small-এর ETag পড়ুন, PATCH দিয়ে মডেল বদলান, তারপরIf-Matchসহ PUT করে স্ট্যাটাস ছাপান। - মাঝারি: শুধু
models:readস্কোপেbatch-job-এর জন্য একটা client-credentials টোকেন নিন, তা দিয়ে/secure/modelsকল করুন, তারপর একটা মডেল retire করার চেষ্টা করে দুটো স্ট্যাটাস কোডই ছাপান। - কঠিন:
requestsদিয়ে একটা Python স্ক্রিপ্ট লিখুন, যাweb-app-এর জন্য পুরো PKCE ফ্লো চালায়: র্যান্ডম ভেরিফায়ার বানান, চ্যালেঞ্জ হিসাব করুন, রিডাইরেক্ট অনুসরণ না করে/oauth/authorizeকল করুন,Locationহেডার থেকেcodeওstateপড়ুন, কোডটা বদলে টোকেন নিন, আর/secure/modelsকল করুন।stateমিলছে কি না যাচাই করুন।
উত্তর
১। টেক্সট রূপের নিজস্ব যাচাই-চিহ্ন আছে:
TEXT_ETAG=$(curl -s -i -H 'Accept: text/plain' $BASE/models/embed-small | tr -d '\r' | grep -i '^etag:' | cut -d' ' -f2)
curl -s -o /dev/null -w 'text revalidation: HTTP %{http_code}\n' -H 'Accept: text/plain' -H "If-None-Match: $TEXT_ETAG" $BASE/models/embed-small
curl -s -o /dev/null -w 'json with text etag: HTTP %{http_code}\n' -H "If-None-Match: $TEXT_ETAG" $BASE/models/embed-smalltext revalidation: HTTP 304
json with text etag: HTTP 200২। একবার বা দুবার কলের পর অবস্থা একই; শুধু পুনরাবৃত্তির স্ট্যাটাস আলাদা:
for method in PUT PUT DELETE DELETE; do
curl -s -o /dev/null -w "$method %{http_code}\n" -X $method $BASE/models/embed-xl -H 'Content-Type: application/json' -d '{"dim": 2048, "status": "active"}'
donePUT 201
PUT 200
DELETE 204
DELETE 404৩।
ETAG=$(curl -s -i $BASE/models/embed-small | tr -d '\r' | grep -i '^etag:' | cut -d' ' -f2)
curl -s -o /dev/null -X PATCH $BASE/models/embed-small -H 'Content-Type: application/json' -d '{"dim": 512}'
curl -s -o /dev/null -w 'stale PUT: HTTP %{http_code}\n' -X PUT $BASE/models/embed-small -H "If-Match: $ETAG" -H 'Content-Type: application/json' -d '{"dim": 384, "status": "active"}'stale PUT: HTTP 412৪। টোকেনটা একটা JSON ফিল্ড; python -c সেটা বের করে আনে:
TOKEN=$(curl -s -X POST $BASE/oauth/token -d grant_type=client_credentials -d client_id=batch-job -d client_secret=batch-secret -d scope=models:read | python -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
curl -s -o /dev/null -w 'list: HTTP %{http_code}\n' -H "Authorization: Bearer $TOKEN" $BASE/secure/models
curl -s -o /dev/null -w 'retire: HTTP %{http_code}\n' -X POST -H "Authorization: Bearer $TOKEN" $BASE/secure/models/embed-small/retirelist: HTTP 200
retire: HTTP 403৫। allow_redirects=False 302 রেখে দেয়, যাতে স্ক্রিপ্ট Location হেডার পড়তে পারে:
import base64
import hashlib
import secrets
from urllib.parse import parse_qs, urlparse
import requests
BASE = "http://localhost:8000"
verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
params = {
"response_type": "code", "client_id": "web-app", "redirect_uri": "http://localhost:3000/callback",
"scope": "models:read", "state": state, "code_challenge": challenge, "code_challenge_method": "S256",
}
redirect = requests.get(f"{BASE}/oauth/authorize", params=params, allow_redirects=False, timeout=5)
query = parse_qs(urlparse(redirect.headers["Location"]).query)
assert query["state"] == [state], "state mismatch: possible forged redirect"
tokens = requests.post(f"{BASE}/oauth/token", timeout=5, data={
"grant_type": "authorization_code", "client_id": "web-app",
"code": query["code"][0], "code_verifier": verifier,
}).json()
api = requests.get(f"{BASE}/secure/models", headers={"Authorization": f"Bearer {tokens['access_token']}"}, timeout=5)
print(redirect.status_code, tokens["scope"], api.status_code, api.json()["user"])302 models:read 200 nadiaকাজ শেষ হলে সার্ভার বন্ধ করুন:
kill $SERVER_PIDসারসংক্ষেপ
- GET, HEAD ও OPTIONS safe; GET, HEAD, PUT, DELETE ও OPTIONS idempotent; POST কোনোটাই নয়, তাই idempotency key ছাড়া রিট্রাই করবেন না। PUT প্রতিস্থাপন করে, PATCH অংশ বদলায়।
- স্ট্যাটাস কোড ক্লায়েন্টকে বলে এরপর কী করতে হবে: রিকোয়েস্ট ঠিক করা (4xx), অথেনটিকেট করা (401), ছেড়ে দেওয়া (403, 404), অপেক্ষা করা (429), রিট্রাই করা (502/503/504)।
Acceptফরম্যাট নেগোশিয়েট করে (না পারলে 406), আরVaryক্যাশকে সেটা জানায়।Cache-Controlfreshness দেয়;ETagওIf-None-Matchসস্তা 304 যাচাই দেয়;If-Matchও 412 lost update ঠেকায়।- 401 মানে "আপনি কে?", 403 মানে "অনুমতি নেই"; API কি দীর্ঘমেয়াদি অ্যাপ ক্রেডেনশিয়াল, বেয়ারার টোকেন স্বল্পমেয়াদি ও স্কোপ-সীমিত।
- OAuth 2.0: ব্যবহারকারীর জন্য authorization code + PKCE, সার্ভিসের জন্য client credentials, সাইন-ইন ধরে রাখতে রিফ্রেশ টোকেন; OIDC লগইনের জন্য ID টোকেন যোগ করে; password grant কখনোই নয় (RFC 9700)।
এরপর: মজবুত API ডিজাইন: পেজিনেশন, রেট লিমিট, রিট্রাই ও ভার্সনিং এই পাতার ওপর দাঁড়িয়ে: বড় রেজাল্ট সেট, অপব্যবহারকারী ক্লায়েন্ট, ব্যর্থ নেটওয়ার্ক আর বদলে যাওয়া API কীভাবে কাউকে না ভেঙে সামলানো হয়।