অধ্যায় 2 · API ডিজাইন ও API ক্লায়েন্ট
মজবুত API ডিজাইন: পেজিনেশন, রেট লিমিট, রিট্রাই ও ভার্সনিং
- পৃষ্ঠা 5 / 22
- 29 মিনিট পড়া
আগের পাতায় আপনি দেখেছেন HTTP কী কী প্রতিশ্রুতি দেয়: safe ও idempotent মেথড, ক্যাশিং, স্ট্যাটাস কোড। এই পাতায় দেখব বাস্তব ট্রাফিকের সামনে সেই প্রতিশ্রুতিগুলো কীভাবে টিকে থাকে। একটা লিস্ট এন্ডপয়েন্ট এক কোটি রো ফেরত দিচ্ছে। একজন ক্লায়েন্ট API-কে এত রিকোয়েস্ট পাঠাচ্ছে যে সে ব্লক হয়ে গেল। সার্ভার কার্ড থেকে টাকা কেটে নেওয়ার পর নেটওয়ার্ক রেসপন্সটা হারিয়ে ফেলল। একটা জব চার মিনিট লাগে, অথচ রিকোয়েস্টের উত্তর দিতে হবে ত্রিশ সেকেন্ডে। কোনো পার্টনারের API এক শুক্রবারে একটা ফিল্ডের নাম বদলে দিল। প্রতিটা AI প্রোডাক্টকেই এগুলোর সবকটার মুখোমুখি হতে হয়: এমবেডিং জব লম্বা হয়, LLM প্রোভাইডার কড়াভাবে রেট লিমিট করে, আর আপনার নিজের API-কেও কল করবে এমন মানুষ যারা রিট্রাই করে।
প্রতিটা ধারণার সার্ভার দিক আপনি FastAPI-তে বানাবেন, ক্লায়েন্ট দিক requests দিয়ে, আর BitByte Practice API-কে (http://localhost:8800, অন্য টার্মিনালে fastapi run practice_api.py --port 8800) ব্যবহার করবেন সেই অনির্ভরযোগ্য তৃতীয় পক্ষ হিসেবে, যেটা ঠিক এই কাজের জন্যই বানানো।
যা শিখবেন
- ডেটা বদলালে offset পেজিনেশন কেন ভেঙে যায়, আর cursor পেজিনেশন কীভাবে বানাতে হয়।
- রেট লিমিট ও কোটা কীভাবে কাজ করে,
Retry-After-সহ429মানে কী, আর এক্সপোনেনশিয়াল ব্যাকঅফ, জিটার ও রিট্রাই বাজেটসহ ক্লায়েন্ট কীভাবে লিখতে হয়। - ইডেমপোটেন্সি কি (idempotency key) কীভাবে
POST-কে রিট্রাইয়ের জন্য নিরাপদ করে, আর FastAPI-তে সেটা কীভাবে বানাতে হয়। 202 Accepted, স্ট্যাটাস URL ও পোলিং দিয়ে লম্বা জব চালানো, আর কাউকে না ভেঙে API-র ভার্সনিং।- OpenAPI কন্ট্রাক্ট পড়া, কোডে breaking আর non-breaking পরিবর্তন আলাদা করা, আর অবিশ্বস্ত বাইরের API নিরাপদে ব্যবহার করা।
প্রজেক্ট সেটআপ
একটা ছোট FastAPI প্রজেক্ট, দুই ধাপে বড় হবে। শুরুতে একটা ফোল্ডার, uv দিয়ে চালানো ভার্চুয়াল এনভায়রনমেন্ট, আর দরকারি দুটো লাইব্রেরি।
mkdir -p ~/projects/robust-api
cd ~/projects/robust-api
uv init --bare
uv add -q "fastapi[standard]" requests
# uv-এর বদলে pip দিয়ে:
# python -m venv .venv && source .venv/bin/activate
# pip install -q "fastapi[standard]" requestsInitialized project `robust-api`এই পাতার Python অংশগুলো নোটবুকের সেলের মতো একটার পর একটা চলবে। এই ফোল্ডার থেকে uv run python-এ (বা Jupyter-এ) চালান, অথবা একটা ফাইলে পেস্ট করে uv run python file.py দিয়ে চালান। আপনার নিজের সার্ভার শুনবে পোর্ট 8000-এ।
পেজিনেশন: offset নাকি cursor
"সব আইটেম" ফেরত দেওয়া এন্ডপয়েন্ট প্রথম দিন ঠিক চলে, নব্বই দিনের মাথায় ভেঙে পড়ে। প্রতিটা লিস্ট এন্ডপয়েন্টে শুরু থেকেই পেজিনেশন থাকা দরকার, কারণ পরে যোগ করলে রেসপন্সের গঠন বদলে যায়, আর সেটা একটা breaking change (নিচে আরও আছে)। প্রধান নকশা দুটো।
Offset (?offset=20&limit=10) | Cursor (?cursor=abc&limit=10) | |
|---|---|---|
| ধারণা | "২০টা রো বাদ দাও, পরের ১০টা দাও" | "আমি যেটা শেষ দেখেছি তার পরের ১০টা দাও" |
| সরাসরি ৫০ নম্বর পেজে যাওয়া | যায় | যায় না: শুধু সামনে যাওয়া যায় (পিছনে যেতে চাইলে আগের cursor আলাদা দিতে হয়) |
| দুই পেজের মাঝখানে ডেটা বদলালে | রো বাদ পড়ে বা দুবার আসে | স্থিতিশীল |
| বড় টেবিলে খরচ | ডেটাবেসকে তবু প্রতিটা বাদ-দেওয়া রো পেরিয়ে যেতে হয়: ১০,০০০ নম্বর পেজ ধীর | ইনডেক্সে সরাসরি লাফ: ১০,০০০ নম্বর পেজের খরচও প্রথম পেজের সমান |
| মোট সংখ্যা | সহজ (total) | সাধারণত থাকে না (গুনতে খরচ বেশি) |
| কোথায় ভালো | ছোট অ্যাডমিন টেবিল, "১২ পেজের মধ্যে ৩" ধরনের ইন্টারফেস | ফিড, লগ, চ্যাট হিস্ট্রি, বড় বা সচল সবকিছু, আর যে API ক্লায়েন্ট সবকিছু ক্রল করে |
দুটোই বানিয়ে পার্থক্যটা নিজে দেখুন। ডেটা ২৫টা নোট, নতুনটা আগে। cursor হবে অস্বচ্ছ (opaque): ক্লায়েন্ট এটাকে শুধু একটা স্ট্রিং ধরে ফেরত পাঠাবে, ফলে ভেতরে কী আছে তা আপনি পরে বদলাতে পারবেন (আজ ডেটাবেস id, কাল টাইমস্ট্যাম্প আর id) কাউকে না ভেঙে।
# file: items.py
import base64
import json
from typing import Annotated
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter()
ITEMS = [{"id": number, "title": f"Note {number}"} for number in range(1, 26)]
class Item(BaseModel):
id: int
title: str
class NewItem(BaseModel):
title: str
class OffsetPage(BaseModel):
items: list[Item]
total: int
offset: int
limit: int
class CursorPage(BaseModel):
items: list[Item]
next_cursor: str | None
def newest_first():
return sorted(ITEMS, key=lambda item: item["id"], reverse=True)
def encode_cursor(last_id: int) -> str:
return base64.urlsafe_b64encode(json.dumps({"last_id": last_id}).encode()).decode()
def decode_cursor(cursor: str) -> int:
try:
return int(json.loads(base64.urlsafe_b64decode(cursor.encode()))["last_id"])
except (ValueError, KeyError, TypeError):
raise HTTPException(400, "Invalid cursor")
@router.get("/items", response_model=OffsetPage)
def list_items(
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 10,
):
rows = newest_first()
return {"items": rows[offset:offset + limit], "total": len(rows), "offset": offset, "limit": limit}
@router.get("/feed", response_model=CursorPage)
def feed(cursor: str | None = None, limit: Annotated[int, Query(ge=1, le=100)] = 10):
last_id = decode_cursor(cursor) if cursor else None
rows = [row for row in newest_first() if last_id is None or row["id"] < last_id]
page = rows[:limit]
has_more = len(rows) > limit
return {"items": page, "next_cursor": encode_cursor(page[-1]["id"]) if has_more else None}
@router.post("/items", status_code=201, response_model=Item)
def create_item(body: NewItem):
item = {"id": max(row["id"] for row in ITEMS) + 1, "title": body.title}
ITEMS.append(item)
return item# file: main.py
from fastapi import FastAPI
import items
app = FastAPI(title="Robust API demo", version="1.0.0")
app.include_router(items.router)
@app.get("/health")
def health():
return {"status": "ok"}সার্ভারটা ব্যাকগ্রাউন্ডে চালিয়ে সাড়া দেওয়া পর্যন্ত অপেক্ষা করুন। pid একটা ফাইলে রাখা হলো, যাতে পরে সার্ভার বন্ধ করা যায়।
uv run fastapi run main.py --port 8000 > server.log 2>&1 &
echo $! > server.pid
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health{"status":"ok"}দুই ধরনের প্রথম পেজ পড়ুন, তারপর offset লিস্টের দ্বিতীয় পেজ চান। বাস্তবে গোলমালটা এই দ্বিতীয় রিকোয়েস্টেই হয়:
import requests
APP = "http://localhost:8000"
PRACTICE = "http://localhost:8800"
first = requests.get(f"{APP}/items", params={"offset": 0, "limit": 5}).json()
print("offset page 1:", [item["id"] for item in first["items"]], "total", first["total"])
feed_first = requests.get(f"{APP}/feed", params={"limit": 5}).json()
print("cursor page 1:", [item["id"] for item in feed_first["items"]])
print("next_cursor:", feed_first["next_cursor"])offset page 1: [25, 24, 23, 22, 21] total 25
cursor page 1: [25, 24, 23, 22, 21]
next_cursor: eyJsYXN0X2lkIjogMjF9এবার একটা ব্যস্ত প্রোডাক্টের কথা ভাবুন: পাঠক পেজ ২ চাওয়ার আগেই কেউ লিস্টের মাথায় একটা নতুন নোট যোগ করল। offset ক্লায়েন্ট এমন একটা লিস্টের ৫ থেকে ৯ নম্বর রো চাইছে যেটা এক ঘর সরে গেছে, ফলে সে আগে-দেখা একটা রো আবার পায়। cursor ক্লায়েন্ট চাইছে "id ২১-এর চেয়ে পুরোনো সব", তাই সে অক্ষত থাকে:
requests.post(f"{APP}/items", json={"title": "Note 26, added while you were reading"})
second_offset = requests.get(f"{APP}/items", params={"offset": 5, "limit": 5}).json()
print("offset page 2:", [item["id"] for item in second_offset["items"]])
second_cursor = requests.get(f"{APP}/feed", params={"limit": 5, "cursor": feed_first["next_cursor"]}).json()
print("cursor page 2:", [item["id"] for item in second_cursor["items"]])offset page 2: [21, 20, 19, 18, 17]
cursor page 2: [20, 19, 18, 17, 16]offset পাঠক ২১ নম্বর নোটটা দুবার দেখল। ডিলিট হলে উল্টোটা হয়, একটা রো নিঃশব্দে বাদ পড়ে। ব্যবহারকারী লিস্ট স্ক্রল করলে এটা একটা ছোট গ্লিচ; কিন্তু ট্রেনিং সেট বা ইনডেক্স বানাতে সব রেকর্ড নামানো একটা AI পাইপলাইনের জন্য এটা নিঃশব্দে ডেটা হারানো বা ডুপ্লিকেট হওয়া। SQL-এ একই কোয়েরি দেখলে বোঝা যায় cursor খরচেও কেন জেতে:
-- offset: ডেটাবেস আগে ১০০০০০টা রো পড়ে ফেলে দেয়
SELECT id, title FROM items ORDER BY id DESC LIMIT 10 OFFSET 100000;
-- cursor ("keyset"): ইনডেক্সের নির্দিষ্ট জায়গায় সরাসরি লাফ
SELECT id, title FROM items WHERE id < :last_id ORDER BY id DESC LIMIT 11; -- ১১ = limit + 1এন্ডপয়েন্টে ব্যবহার করা কৌশলটা হলো limit + 1 রো আনা: বাড়তি রোটা থাকলে পরের পেজ আছে, আলাদা count কোয়েরি লাগে না। ভালো cursor-এর নিয়ম:
- অনন্য ও স্থিতিশীল কি দিয়ে সাজান।
created_atদিয়ে সাজালে (যেটা পুনরাবৃত্ত হতে পারে) id-কে টাইব্রেকার হিসেবে নিন এবং দুটোই cursor-এ রাখুন। - শেষ পেজে সবসময়
next_cursor: nullফেরত দিন, যাতে ক্লায়েন্ট জানে কখন থামতে হবে। limit-এর ঊর্ধ্বসীমা দিন (এখানে ১০০), যাতে একটা রিকোয়েস্টে পুরো টেবিল চাওয়া না যায়।- base64 cursor গোপন নয়, ক্লায়েন্ট সেটা বদলাতে পারে। তাই যাচাই করুন (এন্ডপয়েন্ট
400দেয়) এবং এমন কিছু ভেতরে রাখবেন না যা দিয়ে অ্যাক্সেস মেলে; বদলানো ঠেকাতে চাইলে HMAC দিয়ে সাইন করুন।
সবকিছু ক্রল করা ক্লায়েন্ট একটা ছোট লুপ, আর cursor-এর ভেতরে কী আছে তা তাকে জানতেই হয় না:
def iter_feed(base_url, limit=10):
cursor = None
while True:
params = {"limit": limit}
if cursor:
params["cursor"] = cursor
page = requests.get(f"{base_url}/feed", params=params, timeout=5).json()
yield from page["items"]
cursor = page["next_cursor"]
if cursor is None:
return
ids = [item["id"] for item in iter_feed(APP, limit=10)]
print(len(ids), ids[:3], ids[-1], len(set(ids)) == len(ids))26 [26, 25, 24] 1 TruePractice API-র নিজের /v1/courses offset ধরনের (page, per_page, next_page), কারণ সেই লিস্ট ছোট ও স্থির। বড় এবং বদলাতে থাকা সংগ্রহ (ইভেন্ট, মেসেজ, এমবেডিং, লগ লাইন) cursor-ভিত্তিক হওয়া উচিত।
রেট লিমিট ও কোটা
প্রতিটা গুরুতর API তার কলারদের সীমা বেঁধে দেয়: ওভারলোড থেকে নিজেকে বাঁচাতে, ক্ষমতা ন্যায্যভাবে ভাগ করতে, আর খরচ নিয়ন্ত্রণে রাখতে। দুটো ধারণা সহজেই গুলিয়ে যায়:
| রেট লিমিট | কোটা | |
|---|---|---|
| কোন প্রশ্নের উত্তর | "কত দ্রুত?" | "মোট কতটা?" |
| সময়ের জানালা | সেকেন্ড বা মিনিট | দিন বা মাস |
| সীমা পেরোলে সাধারণ রেসপন্স | 429 Too Many Requests আর Retry-After: একটু পরে আবার চেষ্টা করুন | 429 বা 403, সঙ্গে quota_exceeded ধরনের এরর কোড: সময়কাল শেষ না হলে বা প্ল্যান না বদলালে রিট্রাই করে লাভ নেই |
| AI-র উদাহরণ | মিনিটে ৬০টা রিকোয়েস্ট, মিনিটে ১,০০,০০০ টোকেন | মাসে ৫০ লাখ টোকেন, খরচের ক্যাপ |
LLM প্রোভাইডাররা সাধারণত অ্যাকাউন্ট ও মডেল ধরে মিনিটে রিকোয়েস্ট এবং টোকেন দুটোই সীমিত করে, তাই একটা বিশাল প্রম্পট এমন লিমিট ছুঁয়ে ফেলতে পারে যা হাজারটা ছোট প্রম্পট ছোঁয় না। Practice API-তে একটা ছোট, দ্রুত লিমিট আছে যেটা আপনি ইচ্ছে করে ছুঁতে পারেন: X-Client-Id প্রতি ১০ সেকেন্ডে ৩টা রিকোয়েস্ট। চতুর্থ রিকোয়েস্ট কেমন দেখায় লক্ষ করুন:
for number in range(1, 5):
response = requests.get(f"{PRACTICE}/v1/limited", headers={"X-Client-Id": "robust-demo"})
print(number, response.status_code, response.json(), "Retry-After:", response.headers.get("Retry-After"))1 200 {'ok': True, 'remaining': 2} Retry-After: None
2 200 {'ok': True, 'remaining': 1} Retry-After: None
3 200 {'ok': True, 'remaining': 0} Retry-After: None
4 429 {'detail': 'Too many requests'} Retry-After: 10এই উত্তরে তিনটা জিনিস পড়ার আছে। স্ট্যাটাস 429, মানে "আপনি, ধীরে চলুন", 503 নয়, যার মানে "আমি ভাঙা"। বডি লিখে ব্যাখ্যা করছে। আর Retry-After: 10 হলো সার্ভার ঠিক কতক্ষণ অপেক্ষা করতে বলছে: স্ট্যান্ডার্ড অনুযায়ী এটা সেকেন্ডের সংখ্যা বা HTTP তারিখ হতে পারে, আর ভালো ক্লায়েন্ট নিজের অনুমানের আগে এটা মানে। অনেক API প্রতিটা রেসপন্সে অনানুষ্ঠানিক হেডারও পাঠায়, যেমন X-RateLimit-Remaining আর X-RateLimit-Reset (একটা IETF ড্রাফট স্ট্যান্ডার্ড RateLimit হেডার প্রস্তাব করেছে, কিন্তু বাস্তবে এখনও X- নামগুলোই বেশি দেখবেন); "remaining" দেখে চলা ক্লায়েন্ট ব্লক হওয়ার আগেই গতি কমাতে পারে। নিজের সার্ভারে লিমিটার (fixed window, sliding window, token bucket) বানানো Redis দিয়ে ক্যাশিং, রেট লিমিটিং ও নির্ভরযোগ্যতা পাতার কাজ।
রিট্রাই: এক্সপোনেনশিয়াল ব্যাকঅফ, জিটার ও বাজেট
নেটওয়ার্ক ছোট ছোট ঝাপটায় ফেল করে। রিট্রাই সেগুলোর বেশিরভাগ ঠিক করে দেয়, কিন্তু অসাবধান রিট্রাই প্রোভাইডারের ৩০ সেকেন্ডের গোলমালকে এক ঘণ্টার বিভ্রাটে বদলে দেয়, কারণ হাজার হাজার ক্লায়েন্ট একই মুহূর্তে রিট্রাই করে। ভালো রিট্রাই নীতির পাঁচটা অংশ:
- শুধু সাময়িক ব্যর্থতায় রিট্রাই: কানেকশন এরর, টাইমআউট,
429,500,502,503,504।400,401,403,404বা422-এ কখনও নয়: উত্তর বদলাবে না। - শুধু নিরাপদ রিকোয়েস্টে রিট্রাই:
GET,PUT,DELETEidempotent।POSTরিট্রাই হবে কেবল ইডেমপোটেন্সি কি থাকলে (পরের সেকশন)। - এক্সপোনেনশিয়ালি পিছিয়ে যান: ০.৫, ১, ২, ৪ সেকেন্ড… একটা ঊর্ধ্বসীমা পর্যন্ত, যাতে হাঁপিয়ে ওঠা সার্ভার সেরে ওঠার সময় পায়।
- জিটার যোগ করুন: ০ থেকে ব্যাকঅফ মান পর্যন্ত একটা এলোমেলো অপেক্ষা বেছে নিন ("full jitter"), যাতে ক্লায়েন্টরা ঢেউ করে না এসে ছড়িয়ে পড়ে।
- মোট সীমা বাঁধুন: সর্বোচ্চ চেষ্টার সংখ্যা, আর সব কল মিলিয়ে রিট্রাই সীমিত করা একটা রিট্রাই বাজেট, যেমন "রিট্রাই থেকে বাড়তি লোড বড়জোর ১০%"।
আগে সময়সূচিটাই দেখুন। জিটার ছাড়া অপেক্ষাগুলো নির্দিষ্ট; জিটার থাকলে প্রতিটা হলো সেই সীমা পর্যন্ত একটা এলোমেলো সংখ্যা (seed দেওয়া জেনারেটর ব্যবহার হয়েছে বলে সংখ্যাগুলো প্রতিবার একই আসে):
import random
import time
def backoff_ceiling(attempt, base=0.5, cap=8.0):
return min(cap, base * 2 ** attempt)
def backoff_delay(attempt, base=0.5, cap=8.0, rng=random):
return rng.uniform(0, backoff_ceiling(attempt, base, cap))
print("ceilings:", [backoff_ceiling(a) for a in range(7)])
seeded = random.Random(7)
print("full jitter:", [round(backoff_delay(a, rng=seeded), 2) for a in range(7)])ceilings: [0.5, 1.0, 2.0, 4.0, 8.0, 8.0, 8.0]
full jitter: [0.16, 0.15, 1.3, 0.29, 4.29, 2.93, 0.46]এবার রিট্রাই বাজেট আর ক্লায়েন্ট। বাজেটটা একটা ছোট টোকেন বাকেট: প্রতিটা প্রথম চেষ্টা একটা টোকেনের একটা ভগ্নাংশ জমা দেয়, প্রতিটা রিট্রাই একটা পুরো টোকেন খরচ করে। সার্ভিস সুস্থ থাকলে রিট্রাই কম হয়, বাজেট ভরা থাকে; সবকিছু ফেল করতে থাকলে কয়েকটা রিট্রাইয়ের পরই বাজেট ফুরিয়ে যায় আর ক্লায়েন্ট লোড বাড়ানো বন্ধ করে। sleep আর rng আর্গুমেন্ট বদলানো যায়, তাই ফাংশনটা টেস্ট করা সহজ, আর এই পাতাও সত্যিকারের অপেক্ষা ছাড়াই চলে।
RETRY_STATUS = {429, 500, 502, 503, 504}
IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}
class RetryBudget:
def __init__(self, ratio=0.1, reserve=2):
self.ratio, self.cap, self.tokens = ratio, float(reserve), float(reserve)
def deposit(self):
self.tokens = min(self.cap, self.tokens + self.ratio)
def withdraw(self):
if self.tokens >= 1:
self.tokens -= 1
return True
return False
def call_with_retries(session, method, url, *, max_attempts=4, base=0.5, cap=8.0,
budget=None, sleep=time.sleep, rng=random, waits=None, **kwargs):
kwargs.setdefault("timeout", (3, 10))
safe = method.upper() in IDEMPOTENT_METHODS or "Idempotency-Key" in kwargs.get("headers", {})
if not safe:
max_attempts = 1
if budget is not None:
budget.deposit()
for attempt in range(max_attempts):
try:
response, error = session.request(method, url, **kwargs), None
if response.status_code not in RETRY_STATUS:
return response
except (requests.ConnectionError, requests.Timeout) as exc:
response, error = None, exc
if attempt == max_attempts - 1 or (budget is not None and not budget.withdraw()):
if error is not None:
raise error
return response
delay = backoff_delay(attempt, base, cap, rng)
if response is not None and "Retry-After" in response.headers:
delay = max(delay, float(response.headers["Retry-After"]))
if waits is not None:
waits.append(round(delay, 2))
sleep(delay)Practice API-র flaky এন্ডপয়েন্টে কল করুন; একটা নির্দিষ্ট key-এর জন্য সেটা দুবার 503 দেয়, তারপর সফল হয়। ঘুমানোর বদলে অপেক্ষাগুলো শুধু লিখে রাখা হচ্ছে:
session = requests.Session()
waits = []
response = call_with_retries(
session, "GET", f"{PRACTICE}/v1/flaky", params={"key": "robust-1", "fail_times": 2},
sleep=lambda seconds: None, rng=random.Random(7), waits=waits,
)
print(response.status_code, response.json())
print("would have waited:", waits)200 {'ok': True, 'attempt': 3}
would have waited: [0.16, 0.15]তিনবার চেষ্টা লেগেছে (বডিতে "attempt": 3), আর দুটো অপেক্ষাই তাদের সীমা, ০.৫ আর ১ সেকেন্ডের নিচে ছিল। এবার রেট-লিমিটেড এন্ডপয়েন্ট। ক্লায়েন্ট id robust-demo আগের সেকশনেই লিমিট পার করে ফেলেছে, তাই প্রতিটা উত্তর Retry-After-সহ 429, আর এলোমেলো জিটার যা-ই বলুক, ক্লায়েন্ট অন্তত ততক্ষণ অপেক্ষা করে:
waits = []
response = call_with_retries(
session, "GET", f"{PRACTICE}/v1/limited", headers={"X-Client-Id": "robust-demo"},
max_attempts=3, sleep=lambda seconds: None, rng=random.Random(7), waits=waits,
)
print(response.status_code, "after waits", waits)429 after waits [10.0, 10.0]শেষে বাজেট। /v1/status/503 সবসময় ফেল করে। প্রতিটায় চারবার করে চেষ্টা ধরলে বিশটা কলে ৬০টা রিট্রাই হতে পারত; বাজেট অনেক কম হতে দেয়:
budget = RetryBudget(ratio=0.1, reserve=2)
retries = []
for _ in range(20):
call_with_retries(session, "GET", f"{PRACTICE}/v1/status/503", max_attempts=4,
budget=budget, sleep=lambda seconds: None, waits=retries)
print("calls: 20, retries actually made:", len(retries), "(without a budget: 60)")calls: 20, retries actually made: 3 (without a budget: 60)লাইব্রেরিগুলো এটা আপনার হয়ে করে: requests অ্যাডাপ্টারে urllib3.util.Retry, tenacity, আর OpenAI ও Anthropic SDK-র রিট্রাই অপশন (max_retries)। নীতিটা জানা জরুরি, কারণ ডিফল্ট সবসময় আপনার জন্য ঠিক নয়: যেমন প্রোভাইডার SDK ভেতরে ২ বার রিট্রাই করছে আর আপনার নিজের লুপ ৩ বার, মোট চেষ্টা ৯। পরের-পরের পাতা httpx আর asyncio-র জন্য একই নীতি বানায়: httpx ও asyncio দিয়ে পেশাদার API ক্লায়েন্ট।
ইডেমপোটেন্সি কি (Idempotency-Key)
আপনি POST /payments পাঠালেন। সার্ভার কার্ড চার্জ করল। ফেরার পথে রেসপন্স হারিয়ে গেল, আপনার ক্লায়েন্ট টাইমআউট পেল। আবার পাঠালে গ্রাহকের কাছ থেকে দুবার টাকা কাটা যেতে পারে; না পাঠালে হয়তো একবারও কাটা হয়নি। ক্লায়েন্ট বুঝতে পারে না কোনটা সত্যি। ইডেমপোটেন্সি কি এর সমাধান: ক্লায়েন্ট অপারেশনটার জন্য একটা অনন্য কি বানায় (একটা UUID) এবং প্রতিটা চেষ্টায় Idempotency-Key হেডারে পাঠায়। সার্ভার ওই কি-র অধীনে প্রথম ফলাফল মনে রাখে এবং পুনরাবৃত্তিতে সেটাই ফিরিয়ে দেয়। Stripe এই প্যাটার্নটা জনপ্রিয় করেছে, আর একটা IETF ড্রাফটে হেডারটার বর্ণনা আছে। AI ব্যাকএন্ডেও একই ধারণা কাজে লাগে: "এই ব্যয়বহুল ফাইন-টিউনিং জবটা শুরু করো", "এই ইমেইলটা পাঠাও", "এই জেনারেশনের জন্য ৩ ক্রেডিট কাটো"।
সার্ভার যে নিয়মগুলো মানে:
- যে অনিরাপদ রিকোয়েস্টে কি আবশ্যক, সেখানে হেডারটা না থাকলে:
400। - কি-টা প্রথমবার দেখলে: কাজ করো, ফলাফল আর রিকোয়েস্ট বডির একটা ফিঙ্গারপ্রিন্ট জমা রাখো।
- একই কি, একই বডি: কাজটা আবার করো না, জমানো ফলাফল ফেরত দাও (একটা হেডার দিয়ে চিহ্নিত করে)।
- একই কি, ভিন্ন বডি: ক্লায়েন্টে বাগ আছে; আন্দাজ না করে
422দাও। - কি-র মেয়াদ থাকে (২৪ ঘণ্টা প্রচলিত), আর একই কি-সহ একই মুহূর্তে আসা দুটো রিকোয়েস্ট যেন দুটোই না চলে।
# file: payments.py
import hashlib
import json
import threading
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, Response
from pydantic import BaseModel, Field
router = APIRouter()
PAYMENTS: list[dict] = []
SEEN: dict[str, dict] = {}
LOCK = threading.Lock()
class PaymentIn(BaseModel):
amount: int = Field(gt=0, description="Amount in poisha")
currency: str = "BDT"
note: str = ""
def fingerprint(payment: PaymentIn) -> str:
return hashlib.sha256(json.dumps(payment.model_dump(), sort_keys=True).encode()).hexdigest()
@router.post("/payments", status_code=201)
def create_payment(
payment: PaymentIn,
response: Response,
idempotency_key: Annotated[str | None, Header()] = None,
):
if not idempotency_key:
raise HTTPException(400, "The Idempotency-Key header is required")
digest = fingerprint(payment)
with LOCK:
saved = SEEN.get(idempotency_key)
if saved is not None:
if saved["fingerprint"] != digest:
raise HTTPException(422, "This Idempotency-Key was already used with a different request")
response.headers["Idempotent-Replayed"] = "true"
return saved["body"]
body = {"id": f"pay_{len(PAYMENTS) + 1:04d}", **payment.model_dump()}
PAYMENTS.append(body)
SEEN[idempotency_key] = {"fingerprint": digest, "body": body}
return body
@router.get("/payments")
def list_payments():
return {"count": len(PAYMENTS), "payments": PAYMENTS}লকটা "কি খুঁজে দেখা, তারপর কাজ করা"-কে একটা ধাপ বানায়, তাই একই কি-সহ দুটো একসঙ্গে আসা রিকোয়েস্ট একটার পর একটা চলে আর দ্বিতীয়টা প্রথমের ফলাফল দেখতে পায়। এটা একটা প্রসেসের ভেতরে কাজ করে। একাধিক ওয়ার্কার বা সার্ভার থাকলে স্টোর শেয়ার করা এবং দাবি করাটা অ্যাটমিক হতে হয়, যেমন কি-র ওপর unique constraint-সহ একটা ডেটাবেস টেবিল (INSERT … ON CONFLICT DO NOTHING) বা Redis-এর SET key value NX EX 86400। এরপর আসছে দুটো রাউটার (জব ও ভার্সন) এবং সবগুলোকে জুড়ে দেওয়া নতুন একটা main.py; সেগুলো সেভ করে সার্ভার রিস্টার্ট করবেন।
লম্বা জব: 202, স্ট্যাটাস URL ও পোলিং
একটা HTTP রিকোয়েস্টের উত্তর আসা উচিত কয়েক সেকেন্ডে। হাজারটা ডকুমেন্টের এমবেডিং, একটা রিপোর্ট বানানো বা ব্যাচ প্রেডিকশনে লাগে কয়েক মিনিট। কানেকশন খোলা রাখলে ক্লায়েন্ট আর আপনার সার্ভারের মাঝের প্রতিটা প্রক্সিতে টাইমআউটের ঝুঁকি থাকে, আর রিট্রাই করলে জব দুবার শুরু হয়। প্যাটার্নটা এরকম:
POST /jobs client asks for the work
<- 202 Accepted "I took it, it is not done"
Location: /jobs/job_001 where to look
Retry-After: 1 how soon to look
GET /jobs/job_001 client polls
<- 200 {"status": "running", "progress": 0.5}
GET /jobs/job_001
<- 200 {"status": "succeeded", "result": {...}}এই সার্ভার প্রতি ডকুমেন্টে একটু ঘুমিয়ে কাজের ভান করে। প্রোডাকশনে কাজটা চলে কিউ ওয়ার্কারে (FastAPI-র ব্যাকগ্রাউন্ড টাস্ক কেন যথেষ্ট নয় তা ডিপেন্ডেন্সি, লাইফস্প্যান, মিডলওয়্যার ও সেটিংস পাতায় আছে); HTTP চুক্তিটা একই থাকে।
# file: jobs.py
import asyncio
import itertools
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel, Field
router = APIRouter()
JOBS: dict[str, dict] = {}
TASKS: set[asyncio.Task] = set()
COUNTER = itertools.count(1)
class JobIn(BaseModel):
documents: list[str] = Field(min_length=1, max_length=100)
async def run_job(job_id: str, documents: list[str]):
job = JOBS[job_id]
job["status"] = "running"
for number in range(1, len(documents) + 1):
await asyncio.sleep(0.05)
job["progress"] = round(number / len(documents), 2)
job["status"] = "succeeded"
job["result"] = {"embedded": len(documents), "dimensions": 8}
@router.post("/jobs", status_code=202)
async def create_job(body: JobIn, response: Response):
job_id = f"job_{next(COUNTER):03d}"
JOBS[job_id] = {"id": job_id, "status": "queued", "progress": 0.0, "result": None}
task = asyncio.create_task(run_job(job_id, body.documents))
TASKS.add(task)
task.add_done_callback(TASKS.discard)
response.headers["Location"] = f"/jobs/{job_id}"
response.headers["Retry-After"] = "1"
return dict(JOBS[job_id])
@router.get("/jobs/{job_id}")
def get_job(job_id: str):
job = JOBS.get(job_id)
if job is None:
raise HTTPException(404, f"Job {job_id} not found")
return jobটাস্কের রেফারেন্স ধরে রাখা (TASKS সেট) জরুরি: Python চলমান টাস্কের শুধু দুর্বল (weak) রেফারেন্স রাখে, তাই কেউ ধরে না রাখলে সেটা মাঝপথে garbage-collect হয়ে যেতে পারে।
ভার্সনিং
আপনি API বদলাবেন; ক্লায়েন্টরা একই মুহূর্তে বদলাবে না। ভার্সনিং হলো দুই পক্ষের টিকে থাকার উপায়। প্রথম নিয়ম, ভার্সনিং যতটা পারা যায় এড়ানো: কিছু যোগ করা (নতুন ঐচ্ছিক ফিল্ড, নতুন এন্ডপয়েন্ট) নিরাপদ, তাই যতদিন পারেন শুধু যোগ করে এগোন। পুরোনো চুক্তি সত্যিই ভাঙতে হলে দুটো ভার্সন পাশাপাশি রাখুন:
| কৌশল | উদাহরণ | মন্তব্য |
|---|---|---|
| URL পাথ | /v1/profile, /v2/profile | সবচেয়ে প্রচলিত, চোখে দেখা যায়, রাউট, ক্যাশ ও লগ করা সহজ। সেরা ডিফল্ট। |
| হেডার | Accept: application/vnd.example.v2+json | URL পরিষ্কার থাকে; ব্রাউজারে চেষ্টা করা এবং ঠিকমতো ক্যাশ করা কঠিন (Vary: Accept)। |
| কোয়েরি প্যারামিটার | /profile?version=2 | সহজ, কিন্তু ভুলে যাওয়া সহজ এবং ডকুমেন্ট করা অস্বস্তিকর। |
| তারিখ-ভিত্তিক | Stripe-Version: 2026-03-25 | প্রতিটা অ্যাকাউন্ট সাইন-আপের তারিখের ভার্সনে আটকে থাকে; আপগ্রেড হয় স্পষ্টভাবে। চমৎকার, কিন্তু সার্ভারে অনেক যন্ত্রপাতি লাগে। |
পুরোনো ভার্সন ভদ্রভাবে বিদায় দিন: আগে থেকে জানান, তার প্রতিটা উত্তরে Deprecation ও Sunset হেডার পাঠান, Link হেডারে উত্তরসূরি দেখিয়ে দিন, আর লগ দেখে বুঝুন কারা এখনও সেটা কল করছে। FastAPI-তে দুটো রাউটারেই হয়:
# file: versions.py
from fastapi import APIRouter, Depends, Response
DEPRECATION = "@1767225600"
SUNSET = "Thu, 31 Dec 2026 23:59:59 GMT"
def mark_deprecated(response: Response):
response.headers["Deprecation"] = DEPRECATION
response.headers["Sunset"] = SUNSET
response.headers["Link"] = '</v2/profile>; rel="successor-version"'
v1 = APIRouter(prefix="/v1", dependencies=[Depends(mark_deprecated)])
v2 = APIRouter(prefix="/v2")
@v1.get("/profile")
def profile_v1():
return {"id": 1, "name": "Nadia Rahman"}
@v2.get("/profile")
def profile_v2():
return {"id": 1, "first_name": "Nadia", "last_name": "Rahman"}এবার চূড়ান্ত main.py। এটা প্রথমটাকে ওভাররাইট করে সব রাউটার জুড়ে দেয়:
# file: main.py
from fastapi import FastAPI
import items
import jobs
import payments
import versions
app = FastAPI(title="Robust API demo", version="1.1.0")
app.include_router(items.router)
app.include_router(payments.router)
app.include_router(jobs.router)
app.include_router(versions.v1)
app.include_router(versions.v2)
@app.get("/health")
def health():
return {"status": "ok"}পুরোনো সার্ভার বন্ধ করুন, পোর্ট খালি হওয়া পর্যন্ত অপেক্ষা করুন, তারপর নতুনটা চালু করুন:
kill $(cat server.pid)
while curl -s -o /dev/null http://localhost:8000/health; do sleep 0.1; done
uv run fastapi run main.py --port 8000 > server.log 2>&1 &
echo $! > server.pid
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health{"status":"ok"}রিস্টার্টে ইন-মেমরি ডেটা মুছে গেছে, তাই আগের উদাহরণগুলো প্রথম সার্ভারে চলেছিল। প্রথমে ভার্সন: পুরোনো রাউট এখনও চলে এবং জানায় যে সেটা বিদায় নিচ্ছে; নতুনটা পরিষ্কার:
curl -s -i http://localhost:8000/v1/profile | tr -d "\r" | grep -i -E "^(HTTP|deprecation|sunset|link|\{)"
echo
curl -s http://localhost:8000/v2/profileHTTP/1.1 200 OK
deprecation: @1767225600
sunset: Thu, 31 Dec 2026 23:59:59 GMT
link: </v2/profile>; rel="successor-version"
{"id":1,"name":"Nadia Rahman"}
{"id":1,"first_name":"Nadia","last_name":"Rahman"}এবার ইডেমপোটেন্সি। ক্লায়েন্ট প্রতিটা অপারেশনের জন্য একটা কি বেছে নেয়, আর তার রিট্রাই একই কি পাঠায়। সার্ভার দুটো নয়, একটাই পেমেন্ট বানায়, এবং দ্বিতীয় উত্তরটা যে পুনরাবৃত্তি তা জানিয়ে দেয়। ভিন্ন অঙ্কের জন্য একই কি ব্যবহার করলে সেটা প্রত্যাখ্যাত হয়:
import uuid
key = "order-1001" # আসল কোডে: str(uuid.uuid4()), প্রতি অপারেশনে একবার বানানো
body = {"amount": 25000, "note": "Course fee"}
first = requests.post(f"{APP}/payments", json=body, headers={"Idempotency-Key": key})
retry = requests.post(f"{APP}/payments", json=body, headers={"Idempotency-Key": key})
print(first.status_code, first.json()["id"], first.headers.get("Idempotent-Replayed"))
print(retry.status_code, retry.json()["id"], retry.headers.get("Idempotent-Replayed"))
changed = requests.post(f"{APP}/payments", json={**body, "amount": 99999}, headers={"Idempotency-Key": key})
print(changed.status_code, changed.json()["detail"])
missing = requests.post(f"{APP}/payments", json=body)
print(missing.status_code, missing.json()["detail"])
print("payments stored:", requests.get(f"{APP}/payments").json()["count"])201 pay_0001 None
201 pay_0001 true
422 This Idempotency-Key was already used with a different request
400 The Idempotency-Key header is required
payments stored: 1এ কারণেই ওপরের call_with_retries ফাংশন হেডার ছাড়া POST রিট্রাই করে না। কি থাকলে রিট্রাই নিরাপদ, আর ক্লায়েন্ট পুরো ব্যাকঅফ নীতি ব্যবহার করতে পারে:
response = call_with_retries(
requests, "POST", f"{APP}/payments", json=body, headers={"Idempotency-Key": key},
sleep=lambda seconds: None,
)
print(response.status_code, response.json()["id"], response.headers.get("Idempotent-Replayed"))201 pay_0001 trueএবার জব। POST সঙ্গে সঙ্গে 202, একটা Location আর queued স্ট্যাটাস নিয়ে ফেরে। তারপর ক্লায়েন্ট স্ট্যাটাস URL পোল করতে থাকে জব শেষ না হওয়া পর্যন্ত, প্রতিবারের মাঝে বিরতি আর মোট টাইমআউট নিয়ে, কারণ ডেডলাইনহীন পোল লুপ হলো সেই দিনের জন্য অপেক্ষারত বাগ, যেদিন জবটা আর শেষই হবে না:
created = requests.post(f"{APP}/jobs", json={"documents": ["a", "b", "c", "d"]})
print(created.status_code, created.headers["Location"], created.headers["Retry-After"])
print(created.json())
def wait_for_job(base_url, location, interval=0.1, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
job = requests.get(base_url + location, timeout=5).json()
if job["status"] in ("succeeded", "failed"):
return job
time.sleep(interval)
raise TimeoutError(f"{location} did not finish in {timeout} seconds")
print(wait_for_job(APP, created.headers["Location"]))
print(requests.get(f"{APP}/jobs/job_999").status_code)202 /jobs/job_001 1
{'id': 'job_001', 'status': 'queued', 'progress': 0.0, 'result': None}
{'id': 'job_001', 'status': 'succeeded', 'progress': 1.0, 'result': {'embedded': 4, 'dimensions': 8}}
404বাস্তব সার্ভিস এই প্যাটার্নে কয়েকটা জিনিস যোগ করে: ক্লায়েন্ট একটা Idempotency-Key-ও পাঠাতে পারে, যাতে রিট্রাই হওয়া POST /jobs দ্বিতীয় জব শুরু না করে; DELETE /jobs/{id} জব বাতিল করতে পারে; সফল হলে স্ট্যাটাস রিসোর্স ফলাফলের লিংক দিতে পারে বা সেদিকে ইঙ্গিত করা 303 See Other দিতে পারে; আর পোলিংয়ের বদলে সার্ভার ওয়েবহুক দিয়ে ক্লায়েন্টকে ফোন করেও জানাতে পারে, যেটা পরের পাতার বিষয়।
OpenAPI কন্ট্রাক্ট ও সামঞ্জস্য
FastAPI আপনার অ্যাপের জন্য একটা OpenAPI ডকুমেন্ট লেখে, মেশিনে পড়ার মতো একটা কন্ট্রাক্ট, যেখানে প্রতিটা পাথ, মেথড, প্যারামিটার আর রেসপন্সের গঠন আছে। এটা /openapi.json-এ পাওয়া যায় এবং /docs চালায়। ডেটা হিসেবে পড়ুন:
spec = requests.get(f"{APP}/openapi.json").json()
print(spec["openapi"], spec["info"]["title"], spec["info"]["version"])
for path, methods in spec["paths"].items():
print(f"{path:<18}", sorted(method.upper() for method in methods))
print(sorted(spec["components"]["schemas"]["Item"]["properties"]))3.1.0 Robust API demo 1.1.0
/items ['GET', 'POST']
/feed ['GET']
/payments ['GET', 'POST']
/jobs ['POST']
/jobs/{job_id} ['GET']
/v1/profile ['GET']
/v2/profile ['GET']
/health ['GET']
['id', 'title']কন্ট্রাক্ট হলো আপনার ক্লায়েন্টদের কাছে দেওয়া প্রতিশ্রুতি, তাই রিভিউ করতে হয় শুধু কোড নয়, কন্ট্রাক্টের পরিবর্তনও। কিছু পরিবর্তন নিরাপদ; কিছু কাউকে না কাউকে ভাঙে:
| Non-breaking (শুধু যোগ) | Breaking |
|---|---|
| নতুন একটা এন্ডপয়েন্ট | এন্ডপয়েন্ট সরানো বা নাম বদলানো |
| নতুন ঐচ্ছিক রিকোয়েস্ট প্যারামিটার বা ফিল্ড | নতুন আবশ্যক প্যারামিটার বা ফিল্ড |
| রেসপন্সে নতুন ফিল্ড (ক্লায়েন্টকে অজানা ফিল্ড উপেক্ষা করতেই হবে) | রেসপন্সের ফিল্ড সরানো বা নাম বদলানো, অথবা তার টাইপ বা অর্থ বদলানো |
| রেসপন্সে নতুন enum মান, যদি ক্লায়েন্টদের আগেই বলা থাকে আরও আসতে পারে | যা গ্রহণ করেন তা সংকুচিত করা (ছোট সর্বোচ্চ দৈর্ঘ্য, কড়া প্যাটার্ন) |
| নতুন এরর কোড, কেবল যেখানে ক্লায়েন্ট অজানা কোডকে সাধারণভাবে সামলায় | ক্লায়েন্ট যার ওপর নির্ভর করে সেই স্ট্যাটাস কোড বা এরর গঠন বদলানো; পেজিনেশনের ক্রম বদলানো |
এই টেবিল প্রোগ্রাম দিয়েও যাচাই করা যায়। এই ছোট চেকার দুটো কন্ট্রাক্ট তুলনা করে যে breaking পরিবর্তন তার চোখে পড়ে তা জানায়। বাস্তব টুল আরও বেশি করে (কমান্ড-লাইন টুল oasdiff ভালো একটা), কিন্তু ধারণাটা ঠিক এটাই, আর প্রতিটা পুল রিকোয়েস্টে CI-তে চালানো যায়:
import copy
def operations(spec):
for path, methods in spec["paths"].items():
for method, operation in methods.items():
yield (method.upper(), path), operation
def required_params(operation):
return {p["name"] for p in operation.get("parameters", []) if p.get("required")}
def resolve(spec, schema):
if "$ref" in schema:
return spec["components"]["schemas"][schema["$ref"].split("/")[-1]]
return schema
def field_names(spec, schema, prefix=""):
schema = resolve(spec, schema)
if schema.get("type") == "array":
return field_names(spec, schema["items"], prefix)
names = set()
for name, sub in schema.get("properties", {}).items():
names.add(prefix + name)
names |= field_names(spec, sub, prefix + name + ".")
return names
def response_fields(spec, operation):
for code in ("200", "201", "202"):
if code in operation["responses"]:
content = operation["responses"][code].get("content", {}).get("application/json", {})
return field_names(spec, content.get("schema", {}))
return set()
def breaking_changes(old, new):
problems = []
new_operations = dict(operations(new))
for (method, path), old_operation in operations(old):
label = f"{method} {path}"
new_operation = new_operations.get((method, path))
if new_operation is None:
problems.append(f"{label}: endpoint removed")
continue
for name in sorted(required_params(new_operation) - required_params(old_operation)):
problems.append(f"{label}: new required parameter {name!r}")
for name in sorted(response_fields(old, old_operation) - response_fields(new, new_operation)):
problems.append(f"{label}: response field {name!r} removed")
return problems
print("no change:", breaking_changes(spec, spec))no change: []এবার কন্ট্রাক্টের একটা কপিতে তিনটা breaking আর একটা নিরীহ পরিবর্তন করুন, যেন কোনো ডেভেলপার কোড এডিট করেছেন, তারপর দেখুন চেকার কী বলে:
changed = copy.deepcopy(spec)
del changed["paths"]["/feed"] # এন্ডপয়েন্ট সরানো হলো
changed["paths"]["/items"]["get"]["parameters"].append( # নতুন আবশ্যক প্যারামিটার
{"name": "tenant", "in": "query", "required": True, "schema": {"type": "string"}})
properties = changed["components"]["schemas"]["Item"]["properties"]
properties["name"] = properties.pop("title") # ফিল্ডের নাম বদলানো হলো
changed["paths"]["/stats"] = {"get": {"responses": {"200": {"description": "ok"}}}} # নতুন এন্ডপয়েন্ট
for problem in breaking_changes(spec, changed):
print(problem)GET /items: new required parameter 'tenant'
GET /items: response field 'items.title' removed
POST /items: response field 'title' removed
GET /feed: endpoint removedনতুন /stats এন্ডপয়েন্ট রিপোর্ট হয়নি, কারণ সেটা যোগ করলে কেউ ভাঙে না। কন্ট্রাক্ট থেকে টাইপ করা ক্লায়েন্ট কোডও জেনারেট করা যায়, আর ব্যবহারকারীরা কন্ট্রাক্ট টেস্ট লিখতে পারে; পেশাদার API টেস্ট করা পাতা ঠিক এজন্যই /openapi.json-এর সেভ করা স্ন্যাপশট ব্যবহার করে।
অবিশ্বস্ত বাইরের API ব্যবহার করা
এতক্ষণ যা বললাম তা ছিল আপনার বানানো API নিয়ে। বেশিরভাগ AI ব্যাকএন্ড আবার এমন API-র ক্লায়েন্টও যেগুলো তাদের নিয়ন্ত্রণে নেই: LLM প্রোভাইডার, সার্চ, স্ক্র্যাপার, পার্টনারের ডেটা, ব্যবহারকারীর দেওয়া ওয়েবহুক ও URL। সিস্টেমের বাইরে থেকে আসা প্রতিটা বাইটকে অবিশ্বস্ত ইনপুট ধরুন, বিশ্বস্ত কোম্পানির কাছ থেকে এলেও, কারণ বাগ, বিভ্রাট আর আক্রমণকারী সবাই অদ্ভুত উত্তর তৈরি করে।
| ঝুঁকি | প্রতিরোধ |
|---|---|
| কখনও উত্তরই দেয় না | প্রতিটা কলে connect ও read টাইমআউট, সঙ্গে মোট ডেডলাইন (সেকেন্ডে এক বাইট করে ঝরানো সার্ভার কোনো per-read টাইমআউট ছোঁয় না) |
| গিগাবাইট-ভরা উত্তর দেয় | বডি স্ট্রিম করে পড়ুন এবং সাইজ লিমিটে থামুন; Content-Length আগে দেখুন, কিন্তু তার ওপর ভরসা করবেন না |
| ভুল গঠনের ডেটা পাঠায় | Pydantic দিয়ে যাচাই: টাইপ, পরিসীমা, দৈর্ঘ্য, অনুমোদিত মান; অজানা ফিল্ড উপেক্ষা করুন; অনুপস্থিত ফিল্ডে স্পষ্টভাবে ফেল করুন |
| আপনাকে ভেতরের কোথাও রিডাইরেক্ট করে | অন্ধভাবে রিডাইরেক্ট অনুসরণ করবেন না; ব্যবহারকারীর দেওয়া URL-এর জন্য প্রাইভেট ও মেটাডেটা অ্যাড্রেস ব্লক করুন (SSRF, দেখুন API নিরাপত্তা ও OWASP API Top 10) |
| এর টেক্সটে নির্দেশ লুকানো থাকে | ওয়েব পেজ বা টুল রেজাল্টের টেক্সট ডেটা, কমান্ড নয়: সেটা চালাবেন না, escape ছাড়া SQL বা HTML-এ বসাবেন না, আর আপনার প্রম্পটকে ওভাররাইড করতে দেবেন না |
| না জানিয়ে বদলে যায় | যেখানে পারেন ভার্সন পিন করুন, যাচাই-ব্যর্থতা লগ করুন, ব্যর্থতার হার বাড়লে অ্যালার্ট দিন |
প্রথম তিনটা প্রতিরোধ প্রয়োগ করা একটা র্যাপার এই। এটা বডি টুকরো টুকরো করে পড়ে এবং সীমা পেরোলে এগোতে অস্বীকার করে:
import json
class ResponseTooLarge(Exception):
pass
def fetch_json(method, url, *, max_bytes=100_000, timeout=(3, 5), deadline=10.0, **kwargs):
started = time.monotonic()
with requests.request(method, url, stream=True, timeout=timeout, allow_redirects=False, **kwargs) as response:
response.raise_for_status()
if "application/json" not in response.headers.get("Content-Type", ""):
raise ValueError(f"expected JSON, got {response.headers.get('Content-Type')!r}")
chunks, size = [], 0
for chunk in response.iter_content(chunk_size=1024):
size += len(chunk)
if size > max_bytes:
raise ResponseTooLarge(f"more than {max_bytes} bytes")
if time.monotonic() - started > deadline:
raise TimeoutError(f"took longer than {deadline} seconds")
chunks.append(chunk)
return json.loads(b"".join(chunks))
print(fetch_json("GET", f"{PRACTICE}/v1/courses/8"))
try:
fetch_json("POST", f"{PRACTICE}/v1/echo", json={"blob": "x" * 5000}, max_bytes=1000)
except ResponseTooLarge as exc:
print("ResponseTooLarge:", exc)
try:
fetch_json("GET", f"{PRACTICE}/v1/slow", params={"seconds": 3}, timeout=(1, 0.5))
except requests.exceptions.Timeout as exc:
print("Timeout:", type(exc).__name__){'id': 8, 'title': 'RAG Systems', 'level': 'advanced', 'hours': 10, 'rating': 4.9}
ResponseTooLarge: more than 1000 bytes
Timeout: ReadTimeoutতারপর গঠন যাচাই করুন। কড়া একটা মডেল আপনি যা আশা করেন তা-ই নেয়, আর কিছু নয়; এখানে তাকে weather রেসপন্সকে কোর্স হিসেবে পড়তে দেওয়া হয়েছে, আর আপস্ট্রিম API নিঃশব্দে বদলে গেলে ঠিক এমনটাই দেখায়:
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class Course(BaseModel):
model_config = ConfigDict(extra="ignore")
id: int
title: str = Field(max_length=200)
level: Literal["beginner", "intermediate", "advanced"]
hours: int = Field(ge=0, le=1000)
rating: float = Field(ge=0, le=5)
page = fetch_json("GET", f"{PRACTICE}/v1/courses", params={"per_page": 20})
courses = [Course(**row) for row in page["items"]]
print(len(courses), "valid courses, first:", courses[0].title)
try:
Course(**fetch_json("GET", f"{PRACTICE}/v1/weather", params={"city": "Dhaka"}))
except ValidationError as exc:
print("rejected, bad fields:", [error["loc"][0] for error in exc.errors()])10 valid courses, first: How AI Works
rejected, bad fields: ['id', 'title', 'level', 'hours', 'rating']সীমানায় যাচাই করলে প্রোগ্রামের বাকি অংশ শুধু পরিচ্ছন্ন, টাইপ-করা অবজেক্টই দেখে। আপস্ট্রিমের চমক তিন স্তর গভীরে একটা দুর্বোধ্য KeyError হয়ে ফেটে পড়াও এতে ঠেকে।
বাস্তব ব্যবহার
- ডেটা পাইপলাইন যেগুলো পার্টনার API থেকে ডেটা টানে, তারা cursor পেজিনেশন, জিটারসহ ব্যাকঅফ আর যাচাইয়ের স্তর ব্যবহার করে, যাতে রাতের সিঙ্ক খারাপ মিনিটগুলো পার করেও রো ডুপ্লিকেট বা হারানো ছাড়া টিকে থাকে।
- LLM প্রোভাইডার মিনিটে রিকোয়েস্ট ও টোকেন ধরে রেট লিমিট করে এবং
Retry-After-সহ429ফেরত দেয়। SDK-র রিট্রাই, রিট্রাই বাজেট আর প্রোভাইডারের সামনে কিউ থাকলে ব্যবহারকারীদের ঢল বিভ্রাটে পরিণত হয় না। - পেমেন্ট, ক্রেডিট ও কোটা সিস্টেম ইডেমপোটেন্সি কি ব্যবহার করে, যাতে রিট্রাই হওয়া "৩ ক্রেডিট কাটো" কখনও দুবার কাটে না। "ফাইন-টিউনিং জব বানাও" আর "ইমেইল পাঠাও"-এর জন্যও একই কথা।
- ব্যাচ ইনফারেন্স, এমবেডিং ও রিপোর্ট জেনারেশন জবের URL-সহ
202দেয়; ফ্রন্টএন্ড পোল করে প্রোগ্রেস বার দেখায়, বা ওয়েবহুক পায়। - পাবলিক API ভার্সন আর ডিপ্রিকেশনের তারিখ প্রকাশ করে এবং CI-তে OpenAPI diff চালায়, যাতে কেউ অসাবধানে কন্ট্রাক্ট না ভাঙে।
সাধারণ ভুল
- বড় ও সচল টেবিলে offset পেজিনেশন। পেজের মাঝে রো পুনরাবৃত্ত হয় বা হারায়, আর গভীর পেজ ক্রমশ ধীর হয়। অনন্য, ইনডেক্স-করা সাজানোর কি-র ওপর cursor ব্যবহার করুন,
limit-এ ঊর্ধ্বসীমা দিন, আর শেষেnullফেরত দিন। - ব্যাকঅফ, জিটার বা সীমা ছাড়া রিট্রাই। দশ হাজার ক্লায়েন্টের প্রতি এক সেকেন্ডে স্থির রিট্রাই নিজের ওপর নিজে চালানো আক্রমণ। এক্সপোনেনশিয়ালি পিছিয়ে যান, জিটার দিন,
Retry-Afterমানুন, আর চেষ্টা ও মোট রিট্রাইয়ের সীমা বাঁধুন। - ইডেমপোটেন্সি কি ছাড়া
POSTরিট্রাই। প্রথম চেষ্টা হয়তো কাজ করেছিল। কি যোগ করুন, বডির ফিঙ্গারপ্রিন্ট রাখুন, আর কি ভিন্ন ডেটায় ব্যবহার হলে422দিন। - লম্বা জবের জন্য কানেকশন খোলা রাখা। প্রক্সি টাইমআউট করে, আর রিট্রাই কাজটা দুবার শুরু করে।
Location-সহ202দিন, ডেডলাইন দিয়ে পোল করুন, আর কাজটা রিকোয়েস্টের বাইরে চালান। - অন্য পক্ষকে বিশ্বাস করা। টাইমআউট, সাইজ লিমিট আর যাচাই না থাকলে একটা খারাপ রেসপন্স একটা ওয়ার্কার ঝুলিয়ে দিতে, মেমরি ভরাতে বা আসল কারণ থেকে অনেক দূরে ক্র্যাশ করাতে পারে। প্রতিটা বাইরের কল একই নিরাপদ fetch-and-validate ফাংশনে মুড়ে নিন।
নিজে চেষ্টা করুন
- সহজ:
limit=7দিয়ে/feedব্যবহার করুন এবংnext_cursornullনা হওয়া পর্যন্ত তা অনুসরণ করুন। কতগুলো পেজ লাগল আর মোট কতগুলো আইটেম পেলেন তা প্রিন্ট করুন। - মাঝারি: একই
Idempotency-Keyকিন্তু ভিন্ন অঙ্ক দিয়ে দুটোPOST /paymentsরিকোয়েস্ট পাঠান, এবং দুটোর স্ট্যাটাস কোড ও এরর বার্তা প্রিন্ট করুন। - মাঝারি: ২টা ডকুমেন্টের একটা জব শুরু করুন এবং নিজের পোলিং লুপ লিখুন: প্রথমে
0.02সেকেন্ড অপেক্ষা, তারপর প্রতিটা পোলের পর বিরতি দ্বিগুণ করে0.2সেকেন্ড পর্যন্ত (পোলিংয়ের ব্যাকঅফ), জব শেষ হলে থামা, আর চূড়ান্ত স্ট্যাটাস ও ফলাফল প্রিন্ট করা। - কঠিন:
additive_changes(old, new)লিখুন, যেটা দুই কন্ট্রাক্টের মধ্যে নতুন এন্ডপয়েন্ট আর নতুন রেসপন্স ফিল্ডের তালিকা দেয় (breaking_changes-এর উল্টোটা), এবং এই পাতারspecআর সম্পাদিতchangedকন্ট্রাক্টের ওপর চালান।
উত্তর
# 1. Easy
pages, total, cursor = 0, 0, None
while True:
params = {"limit": 7, **({"cursor": cursor} if cursor else {})}
page = requests.get(f"{APP}/feed", params=params).json()
pages += 1
total += len(page["items"])
cursor = page["next_cursor"]
if cursor is None:
break
print(pages, total)
# 2. Medium
one = requests.post(f"{APP}/payments", json={"amount": 100}, headers={"Idempotency-Key": "ex-2"})
two = requests.post(f"{APP}/payments", json={"amount": 200}, headers={"Idempotency-Key": "ex-2"})
print(one.status_code, one.json()["id"])
print(two.status_code, two.json()["detail"])
# 3. Medium
job = requests.post(f"{APP}/jobs", json={"documents": ["x", "y"]}).json()
interval, polls = 0.02, 0
while True:
job = requests.get(f"{APP}/jobs/{job['id']}").json()
polls += 1
if job["status"] in ("succeeded", "failed"):
break
time.sleep(interval)
interval = min(interval * 2, 0.2)
print(job["status"], job["result"], polls >= 1)
# 4. Hard
def additive_changes(old, new):
found = []
old_operations = dict(operations(old))
for key, operation in operations(new):
if key not in old_operations:
found.append(f"{key[0]} {key[1]}: new endpoint")
else:
for name in sorted(response_fields(new, operation) - response_fields(old, old_operations[key])):
found.append(f"{key[0]} {key[1]}: new response field {name!r}")
return found
for line in additive_changes(spec, changed):
print(line)4 25
201 pay_0002
422 This Idempotency-Key was already used with a different request
succeeded {'embedded': 2, 'dimensions': 8} True
GET /items: new response field 'items.name'
POST /items: new response field 'name'
GET /stats: new endpointকাজ শেষ হলে সার্ভার বন্ধ করে দিন, যাতে পরের পাতার জন্য পোর্ট 8000 ফাঁকা থাকে:
kill $(cat server.pid)সারসংক্ষেপ
- Offset পেজিনেশন সহজ, কিন্তু ডেটা বদলালে রো পুনরাবৃত্ত বা বাদ পড়ে এবং গভীরে গেলে ধীর হয়; অনন্য, ইনডেক্স-করা কি-র ওপর cursor (keyset) পেজিনেশন স্থিতিশীল ও দ্রুত। cursor অস্বচ্ছ রাখুন, যাচাই করুন,
limit-এ সীমা দিন, আর শেষেnext_cursor: nullদিন। - রেট লিমিট বলে "কত দ্রুত", কোটা বলে "মোট কতটা"।
Retry-After-সহ429ক্লায়েন্টকে জানায় কতক্ষণ অপেক্ষা করতে হবে। - নিরাপদ রিকোয়েস্টের শুধু সাময়িক ব্যর্থতায় রিট্রাই করুন: এক্সপোনেনশিয়াল ব্যাকঅফ, full jitter,
Retry-After, সর্বোচ্চ চেষ্টার সংখ্যা আর একটা রিট্রাই বাজেটসহ। Idempotency-KeyPOST-কে রিট্রাইয়ের জন্য নিরাপদ করে: প্রথম ফলাফল বডির ফিঙ্গারপ্রিন্টসহ জমা রাখুন, পুনরাবৃত্তিতে সেটা ফেরত দিন, বদলানো বডি422দিয়ে ফিরিয়ে দিন। লম্বা কাজেLocationস্ট্যাটাস URL-সহ202দিন এবং ডেডলাইনসহ পোল করুন।- যোগ করে এগোনোই ভালো; ভাঙতে হলে ভার্সন করুন (URL পাথ ডিফল্ট) এবং
DeprecationওSunsetহেডার পাঠান। OpenAPI ডকুমেন্ট আপনার কন্ট্রাক্ট, কোডে তার diff করা যায়। বাইরের API অবিশ্বস্ত: টাইমআউট, সাইজ লিমিট, ডেডলাইন আর সীমানায় Pydantic যাচাই।
এরপর: ওয়েবহুক ও সার্ভার-সেন্ট ইভেন্ট দিয়ে স্ট্রিমিং পাতায় দিক উল্টে যায়: জব পোল করার বদলে সার্ভার সাইন করা ওয়েবহুক দিয়ে আপনাকে ফোন করে জানাবে, আর পুরো উত্তরের জন্য অপেক্ষা না করে উত্তরটা টুকরো টুকরো করে স্ট্রিম করবে।