অধ্যায় 3 · HTTP ও ওয়েব API
নির্ভরযোগ্য API কল: টাইমআউট, রিট্রাই, পেজিনেশন ও LLM API
- পৃষ্ঠা 15 / 22
- 20 মিনিট পড়া
নিজের ল্যাপটপে শান্ত এক বিকেলে কাজ করা একটা কল এখনো নির্ভরযোগ্য কল নয়। প্রোডাকশনে নেটওয়ার্ক প্যাকেট হারায়, সার্ভার রিস্টার্ট হয়, প্রোভাইডার আপনাকে রেট লিমিট করে, আর কয়েক সপ্তাহ পরপর একটা LLM প্রোভাইডারের পাঁচ মিনিট খারাপ যায়। প্রথম 503-তেই ক্র্যাশ করা স্ক্রিপ্ট একটা রাতের কাজ নষ্ট করে ফেলে; আর অন্ধভাবে রিট্রাই করা স্ক্রিপ্ট কাহিল সার্ভারকে আরও চাপে ফেলতে পারে, কিংবা আপনার কাছ থেকে দুবার টাকা কেটে নিতে পারে। নির্ভরযোগ্যতা মানে অল্প কিছু সতর্ক কোড: যেটা রিট্রাই করার যোগ্য সেটাই রিট্রাই করা, সঠিক সময় অপেক্ষা করা, কানেকশন পুনর্ব্যবহার করা, আনা ডেটা সেভ করা, আর সবকিছু এক জায়গায় রাখা।
এই পাতায় Practice API দিয়ে ধাপে ধাপে সেটা বানানো হবে: 429 ও 503-এর জন্য ব্যাকঅফসহ রিট্রাই, Retry-After মেনে চলা, requests.Session, JSON ও CSV-তে ফলাফল সেভ করা, একটা পুনর্ব্যবহারযোগ্য ক্লায়েন্ট ক্লাস, আর শেষে টোকেন ও খরচ হিসাবসহ LLM-ধাঁচের একটা চ্যাট API-তে কল। এটা সরাসরি Python-এর requests দিয়ে API কল পাতার ধারাবাহিকতা।
যা শিখবেন
- কোন ব্যর্থতা রিট্রাই করার যোগ্য আর কোনটা নয়, এবং এক্সপোনেনশিয়াল ব্যাকঅফ, জিটার ও
Retry-Afterকীভাবে কাজ করে। - একটা রিট্রাই ফাংশন লেখা, আর বিল্ট-ইন বিকল্প (
Session-সহurllib3-এরRetry) দেখা। - শেয়ার্ড হেডার ও কানেকশন পুনর্ব্যবহারের জন্য
requests.Sessionব্যবহার। - API-র ফলাফল নিরাপদে JSON ও CSV ফাইলে সেভ করা।
- সবকিছু একটা পুনর্ব্যবহারযোগ্য ক্লায়েন্ট ক্লাসে গুছিয়ে একটা চ্যাট API (স্ট্রিমিং ছাড়া) ডাকা এবং টোকেন ও খরচ ট্র্যাক করা।
সেট আপ
Practice API-কে তার দ্বিতীয় টার্মিনালে চালু রাখুন। প্রথম টার্মিনালে একটা প্রজেক্ট বানান এবং কোড যে দুটো সেটিং পড়বে সেগুলো আগের পাতার মতো এনভায়রনমেন্ট ভেরিয়েবলে রাখুন:
mkdir -p ~/projects/reliable-client
cd ~/projects/reliable-client
python -m venv .venv
source .venv/bin/activate
pip install -q requests
export PRACTICE_BASE_URL="http://localhost:8800"
export PRACTICE_TOKEN="token-nadia-001"(uv দিয়ে: uv init ও uv add requests। টোকেনটা সেটাই যা ওয়েব API ও REST: ডকুমেন্টেশন পড়া আর curl দিয়ে পরীক্ষা পাতায় POST /v1/token আপনাকে দিয়েছিল; আসল প্রোভাইডার ড্যাশবোর্ড থেকে একটা কি দেয়। PowerShell: $env:PRACTICE_TOKEN = "token-nadia-001"।)
সাময়িক ব্যর্থতা এবং কী রিট্রাই করবেন
কিছু ব্যর্থতা সাময়িক (transient): একটু পরে আবার চাইলে সম্ভবত কাজ হবে। কিছু স্থায়ী: আবার চাইলে আবার ব্যর্থ হবে, শুধু সময় বা টাকা নষ্ট হবে। Practice API-র /v1/flaky এন্ডপয়েন্ট একই key (কি) দেওয়া প্রথম fail_times কলে 503 দেয়, তারপর সফল হয়। একটা সরল লুপে ধারণাটা দেখা যাক:
import json
import os
import random
import time
import requests
BASE = os.environ["PRACTICE_BASE_URL"]
TIMEOUT = (3.05, 10)
for attempt in range(1, 5):
r = requests.get(f"{BASE}/v1/flaky", params={"key": "demo-1", "fail_times": 2}, timeout=TIMEOUT)
print(attempt, r.status_code, r.json())
if r.ok:
break1 503 {'detail': 'Service temporarily unavailable'}
2 503 {'detail': 'Service temporarily unavailable'}
3 200 {'ok': True, 'attempt': 3}কোন ব্যর্থতা রিট্রাই করবেন:
| ফলাফল | রিট্রাই? | কেন |
|---|---|---|
| কানেকশন এরর, টাইমআউট | হ্যাঁ (সতর্কভাবে) | নেটওয়ার্কের ক্ষণিক সমস্যা, সার্ভার রিস্টার্ট হচ্ছে |
429 Too Many Requests | হ্যাঁ, অপেক্ষার পর | আপনি সীমা ছাড়িয়েছেন; সীমা রিসেট হয় |
500, 502, 503, 504 | হ্যাঁ | সার্ভার বা গেটওয়ের সমস্যা, প্রায়ই স্বল্পস্থায়ী |
400, 404, 422 | না | আপনার রিকোয়েস্টই ভুল; আবারও ভুল থাকবে |
401, 403 | না | ভুল বা অনুপস্থিত ক্রেডেনশিয়াল, ঠিক করুন |
আরেকটা সতর্কতা: যে রিকোয়েস্ট কিছু বদলায় (যেমন অর্ডার তৈরির POST), সেটা রিট্রাই করলে কাজটা দুবার হয়ে যেতে পারে, যদি প্রথম চেষ্টা আসলে সফল হয়ে থাকে কিন্তু উত্তরটা হারিয়ে যায়। GET, PUT ও DELETE নির্দ্বিধায় রিট্রাই করা যায়; POST রিট্রাই করুন শুধু যখন ডুপ্লিকেট কল ক্ষতিকর নয় (চ্যাট কমপ্লিশনে শুধু টাকা খরচ হয়) অথবা API আইডেমপোটেন্সি কি সমর্থন করে (অ্যাডভান্সড টিউটোরিয়ালে পাবেন)।
এক্সপোনেনশিয়াল ব্যাকঅফ
টাইট লুপে সঙ্গে সঙ্গে রিট্রাই করলে চাপে থাকা সার্ভারের অবস্থা আরও খারাপ হয়। এক্সপোনেনশিয়াল ব্যাকঅফ প্রতিবার ব্যর্থ হওয়ার পর আরও বেশি সময় অপেক্ষা করে: 0.1 সেকেন্ড, 0.2, 0.4, 0.8 … দ্বিগুণ হতে হতে একটা সীমা পর্যন্ত। আসল সার্ভিসে বড় সংখ্যা (১ সেকেন্ড, ২, ৪ …) ব্যবহার হয়; আমরা ছোট সংখ্যা রাখছি যাতে পাতাটা দ্রুত চলে।
def backoff_delay(attempt, base=0.1, cap=5.0):
"""Seconds to wait after the given failed attempt (1, 2, 3 ...)."""
return min(cap, base * 2 ** (attempt - 1))
print([backoff_delay(n) for n in range(1, 8)])[0.1, 0.2, 0.4, 0.8, 1.6, 3.2, 5.0]এবার যোগ করুন জিটার (jitter): সামান্য র্যান্ডমনেস। হাজারটা ক্লায়েন্ট একই মুহূর্তে ব্যর্থ হলে সাধারণ ব্যাকঅফে সবাই আবার একই মুহূর্তে রিট্রাই করবে। ০ থেকে হিসাব করা বিলম্বের মধ্যে একটা র্যান্ডম সময় অপেক্ষা করলে ("ফুল জিটার") তারা ছড়িয়ে পড়ে। আর সার্ভার যদি ঠিক কতক্ষণ অপেক্ষা করতে হবে বলে দেয়, তাহলে শুনুন: 429 সাধারণত Retry-After হেডারে কয়েক সেকেন্ডের সংখ্যা বহন করে (এটা তারিখও হতে পারে; আমরা সংখ্যার রূপটা সামলাচ্ছি)। পুরো ধারণাটা একটা ফাংশনে:
RETRY_STATUSES = {429, 500, 502, 503, 504}
def request_with_retries(method, url, *, session=None, max_attempts=4, base=0.1, cap=5.0,
jitter=True, on_retry=None, **kwargs):
"""Send a request, retrying transient failures. Returns the last response."""
caller = session or requests
kwargs.setdefault("timeout", TIMEOUT)
for attempt in range(1, max_attempts + 1):
last = attempt == max_attempts
retry_after = None
try:
response = caller.request(method, url, **kwargs)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as error:
if last:
raise
reason = type(error).__name__
else:
if response.status_code not in RETRY_STATUSES or last:
return response
reason = str(response.status_code)
header = response.headers.get("Retry-After", "")
retry_after = int(header) if header.isdigit() else None
delay = backoff_delay(attempt, base, cap)
if jitter:
delay = random.uniform(0, delay)
if retry_after is not None:
delay = max(delay, min(retry_after, 30)) # সার্ভারের কথা মানুন, তবে অনন্তকাল নয়
if on_retry:
on_retry(attempt, reason, delay)
time.sleep(delay)ধীরে ধীরে পড়ুন; প্রতিটা লাইন একটা সিদ্ধান্ত। max_attempts-এর পর লুপ হাল ছেড়ে দেয় (কখনো চিরকাল রিট্রাই করবেন না)। নেটওয়ার্ক এক্সেপশন রিট্রাই হয়, আর শেষ চেষ্টায় আবার ছোড়া হয়। যে রেসপন্সের স্ট্যাটাস RETRY_STATUSES-এ নেই তা সঙ্গে সঙ্গে ফেরত যায়, তাই 404 ও 422 সোজা কলারের কাছে আসে। শেষ চেষ্টায় রেসপন্স যেমন আছে তেমনই ফেরত দেওয়া হয়, কী করবেন তা কলার ঠিক করে। on_retry হলো লগিংয়ের একটা হুক। /v1/flaky-তে চালিয়ে দেখুন; আমরা jitter=False দিচ্ছি যাতে ছাপা বিলম্বগুলো অনুমানযোগ্য হয়:
log = []
r = request_with_retries(
"GET", f"{BASE}/v1/flaky", params={"key": "demo-2", "fail_times": 2},
jitter=False, on_retry=lambda n, why, delay: log.append((n, why, delay)),
)
print(r.status_code, r.json())
print(log)200 {'ok': True, 'attempt': 3}
[(1, '503', 0.1), (2, '503', 0.2)]এটা দুবার রিট্রাই করেছে (0.1 সেকেন্ড, তারপর 0.2 সেকেন্ড পরে) আর তৃতীয় চেষ্টায় কাজ হয়েছে। এবার দুটো ক্ষেত্র যেখানে এটাকে অবশ্যই হাল ছাড়তে হবে: যে সার্ভার ব্যর্থ হতেই থাকে, আর যে পোর্টে কেউ শুনছে না:
r = request_with_retries("GET", f"{BASE}/v1/flaky", params={"key": "demo-3", "fail_times": 10},
max_attempts=3, jitter=False)
print("last status:", r.status_code)
try:
r.raise_for_status()
except requests.exceptions.HTTPError as error:
print("gave up:", error.response.status_code)
try:
request_with_retries("GET", "http://localhost:8001/health", max_attempts=2, base=0.05)
except requests.exceptions.ConnectionError:
print("gave up: ConnectionError")last status: 503
gave up: 503
gave up: ConnectionErrorরেট লিমিট ও Retry-After
প্রতিটা গুরুত্বপূর্ণ API আপনি কত দ্রুত কল করতে পারবেন তা সীমিত করে, আর LLM API সীমা দেয় মিনিটে রিকোয়েস্ট সংখ্যা এবং মিনিটে টোকেন সংখ্যা দিয়ে। Practice API-র /v1/limited প্রতিটা X-Client-Id-র জন্য ১০ সেকেন্ডে ৩টা রিকোয়েস্ট দেয়, তারপর Retry-After হেডারসহ 429 দেয়:
headers = {"X-Client-Id": "nadia-demo"}
for i in range(1, 5):
r = requests.get(f"{BASE}/v1/limited", headers=headers, timeout=TIMEOUT)
print(i, r.status_code, r.json())
wait = int(r.headers["Retry-After"])
print("Retry-After is a whole number of seconds up to 10:", 1 <= wait <= 10)1 200 {'ok': True, 'remaining': 2}
2 200 {'ok': True, 'remaining': 1}
3 200 {'ok': True, 'remaining': 0}
4 429 {'detail': 'Too many requests'}
Retry-After is a whole number of seconds up to 10: Trueএবার request_with_retries-কে সামলাতে দিন। চতুর্থ কলটা 429 পাবে, ফাংশন Retry-After পড়বে, ততক্ষণ ঘুমাবে (এখানে আসলেই প্রায় দশ সেকেন্ড, তাই ধৈর্য ধরুন), আর রিট্রাই সফল হবে:
headers = {"X-Client-Id": "nadia-retry"}
for _ in range(3):
requests.get(f"{BASE}/v1/limited", headers=headers, timeout=TIMEOUT)
waits = []
r = request_with_retries("GET", f"{BASE}/v1/limited", headers=headers,
on_retry=lambda n, why, delay: waits.append((why, delay >= 1)))
print(r.status_code, r.json())
print(waits)200 {'ok': True, 'remaining': 2}
[('429', True)]এটা অপেক্ষা করেছে কারণ সার্ভার বলেছে, আমাদের নিজের আন্দাজে নয়। আরও ভালো হলো সীমায় না পৌঁছানোই: কলগুলো ছড়িয়ে দিন, যা আগেই পেয়েছেন তা ক্যাশ করুন, আর API ব্যাচিং দিলে কম কিন্তু বড় রিকোয়েস্ট পাঠান।
সেশন
প্রতিটা requests.get একটা নতুন কানেকশন খোলে, অর্থাৎ নতুন TCP হ্যান্ডশেক এবং HTTPS-এ নতুন TLS হ্যান্ডশেক। requests.Session কানেকশন খোলা রাখে ও পুনর্ব্যবহার করে, আর শেয়ার্ড সেটিং বহন করে: হেডার, অথেনটিকেশন, কুকি। একই হোস্টে অনেক কলের (পেজ ধরে ধরে ফলাফল আনা, চ্যাট লুপ) জন্য এটা দ্রুততর ও পরিচ্ছন্ন:
session = requests.Session()
session.headers.update({"X-Client-Id": "nadia-session", "Accept": "application/json"})
session.headers["Authorization"] = f"Bearer {os.environ['PRACTICE_TOKEN']}"
echoed = session.get(f"{BASE}/v1/echo", timeout=TIMEOUT).json()["headers"]
print(echoed["accept"], "authorization" in echoed)
session.close()application/json Trueএই সেশনের প্রতিটা রিকোয়েস্টে এখন এই হেডারগুলো যায়, আর কি একবারই লেখা। কাজ শেষ হলে সেশন বন্ধ করুন (session.close() অথবা with requests.Session() as session:)। একটা জিনিস সেশন করে না: টাইমআউট মনে রাখে না, প্রতিটা কলে timeout= দিতেই হবে; সেশনটাকে নিজের ক্লায়েন্টে মুড়ে নেওয়ার (নিচে) এটাও একটা কারণ।
নিজের রিট্রাই লুপ লিখতেই হবে এমন নয়। requests তৈরি urllib3-এর ওপর, যার Retry ক্লাস সেশনে মাউন্ট করা যায়। আপনার দেওয়া স্ট্যাটাস কোডে এটা রিট্রাই করে, ব্যাকঅফ করে, আর Retry-After মানে:
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(total=3, backoff_factor=0.05, status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"], respect_retry_after_header=True)
auto = requests.Session()
auto.mount("http://", HTTPAdapter(max_retries=retry))
auto.mount("https://", HTTPAdapter(max_retries=retry))
r = auto.get(f"{BASE}/v1/flaky", params={"key": "demo-4", "fail_times": 2}, timeout=TIMEOUT)
print(r.status_code, r.json())
try:
auto.get(f"{BASE}/v1/flaky", params={"key": "demo-5", "fail_times": 10}, timeout=TIMEOUT)
except requests.exceptions.RetryError:
print("RetryError: out of retries")200 {'ok': True, 'attempt': 3}
RetryError: out of retriesকোনটা ব্যবহার করবেন? সাধারণ GET-এর জন্য বিল্ট-ইন Retry ভালো এবং ভালোভাবে পরীক্ষিত। হাতে লেখা ফাংশনে আপনার নিয়ন্ত্রণ থাকে: নিজস্ব লগিং, রিট্রাই বাজেট, এন্ডপয়েন্টভেদে আলাদা নিয়ম, আর টিউটোরিয়ালে পড়া ও ইউনিট-টেস্ট করা সহজ। যেটাই বেছে নিন, নিয়মগুলো একই: শুধু সাময়িক ব্যর্থতা, চেষ্টার একটা সীমা, বাড়তে থাকা বিলম্ব, আর প্রতিটা চেষ্টায় একটা টাইমআউট।
ফলাফল JSON ও CSV-তে সেভ করা
একটা API কলে সময়, কোটা এবং কখনো টাকা খরচ হয়। আবার ব্যবহার করবেন এমন ডেটা (ট্রেনিংয়ের ডেটাসেট, সার্চ ইনডেক্সের ডকুমেন্ট) আনলে সেভ করে রাখুন, যাতে পরে ক্র্যাশ করলে গোড়া থেকে শুরু করতে না হয়। দুটো ফরম্যাটে বেশিরভাগ চাহিদা মেটে: JSON নেস্টেড কাঠামো ধরে রাখে, আর CSV সমতল টেবিলের জন্য, যা Excel ও pandas-এ খোলে। আমরা next_page অনুসরণ করে ও রিট্রাইসহ সব কোর্স আনছি:
import csv
courses = []
page = 1
while page:
r = request_with_retries("GET", f"{BASE}/v1/courses", params={"page": page, "per_page": 4})
r.raise_for_status()
data = r.json()
courses.extend(data["items"])
page = data["next_page"]
print(len(courses), "courses fetched")
with open("courses.json", "w", encoding="utf-8") as f:
json.dump(courses, f, indent=2, ensure_ascii=False)
with open("courses.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["id", "title", "level", "hours", "rating"], lineterminator="\n")
writer.writeheader()
writer.writerows(courses)10 courses fetchedhead -3 courses.csv
wc -l courses.csvid,title,level,hours,rating
1,How AI Works,beginner,3,4.7
2,Python for AI,beginner,12,4.8
11 courses.csvগুরুত্বপূর্ণ খুঁটিনাটি: সবসময় encoding="utf-8" দিন (নইলে Windows-এর ডিফল্ট এনকোডিংয়ে বাংলা লেখা ও অনেক চিহ্ন ভেঙে যায়), ensure_ascii=False JSON ফাইলে ইংরেজি-বহির্ভূত অক্ষর পাঠযোগ্য রাখে, আর newline="" হলো csv মডিউল ফাইল খোলার সময় যা চায়। ফেরত পড়াও একই রকম সহজ, আর পরের রানে নেটওয়ার্ক এড়ানো যায়:
with open("courses.json", encoding="utf-8") as f:
again = json.load(f)
print(again == courses)
with open("courses.csv", encoding="utf-8", newline="") as f:
rows = list(csv.DictReader(f))
print(rows[0])
print(type(rows[0]["hours"]).__name__)True
{'id': '1', 'title': 'How AI Works', 'level': 'beginner', 'hours': '3', 'rating': '4.7'}
strলক্ষ করুন CSV সবকিছু স্ট্রিং হিসেবে ফেরত দেয়; "3" আর 3 এক নয়। JSON সংখ্যাকে সংখ্যাই রাখে। pandas দিয়ে pd.DataFrame(courses).to_csv("courses.csv", index=False) এক লাইনে CSV অংশটা করে দেয়। বড় বা দীর্ঘ ডাউনলোডে একটা অস্থায়ী ফাইলে লিখে শেষে সেটার নাম বদলে দিন (os.replace), যাতে ক্র্যাশ হলে অর্ধেক লেখা ফাইল সম্পূর্ণ ফাইলের মতো পড়ে না থাকে।
একটা পুনর্ব্যবহারযোগ্য ক্লায়েন্ট
এখন আপনার কাছে টাইমআউট, রিট্রাই, সেশন, টোকেন আর পেজিনেশন আছে, কিন্তু ছড়ানো আলাদা সেলে। এগুলো একটা ক্লাসে রাখুন, যাতে প্রজেক্টের প্রতিটা স্ক্রিপ্ট একই নিরাপদ উপায়ে কল করে। এটা practice_client.py নামে সেভ করুন:
# file: practice_client.py
"""A small, reusable client for the BitByte Practice API."""
import os
import random
import time
from typing import NamedTuple
import requests
RETRY_STATUSES = {429, 500, 502, 503, 504}
class APIError(Exception):
"""The API answered with an error we will not (or can no longer) retry."""
def __init__(self, message, status=None):
super().__init__(message)
self.status = status
class ChatReply(NamedTuple):
text: str
usage: dict
class PracticeClient:
def __init__(self, base_url=None, token=None, timeout=(3.05, 30), max_attempts=4,
backoff=0.1, jitter=0.25, sleep=time.sleep):
self.base_url = (base_url or os.environ["PRACTICE_BASE_URL"]).rstrip("/")
self.timeout = timeout
self.max_attempts = max_attempts
self.backoff = backoff
self.jitter = jitter
self._sleep = sleep
self.session = requests.Session()
self.session.headers["Accept"] = "application/json"
token = token or os.environ.get("PRACTICE_TOKEN")
if token:
self.session.headers["Authorization"] = f"Bearer {token}"
self.calls = 0
self.usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
def __enter__(self):
return self
def __exit__(self, *exc_info):
self.session.close()
def _delay(self, attempt, response=None):
delay = min(5.0, self.backoff * 2 ** (attempt - 1))
delay *= 1 + random.uniform(0, self.jitter)
header = response.headers.get("Retry-After", "") if response is not None else ""
if header.isdigit():
delay = max(delay, min(int(header), 30))
return delay
@staticmethod
def _detail(response):
try:
return response.json()["detail"]
except (ValueError, KeyError, TypeError):
return response.text[:100]
def request(self, method, path, **kwargs):
"""Send a request with retries; return the parsed JSON or raise APIError."""
url = f"{self.base_url}{path}"
kwargs.setdefault("timeout", self.timeout)
for attempt in range(1, self.max_attempts + 1):
last = attempt == self.max_attempts
try:
response = self.session.request(method, url, **kwargs)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as error:
if last:
raise APIError(f"{method} {path} failed: {type(error).__name__}") from error
self._sleep(self._delay(attempt))
continue
if response.status_code in RETRY_STATUSES and not last:
self._sleep(self._delay(attempt, response))
continue
if not response.ok:
message = f"{method} {path} -> {response.status_code}: {self._detail(response)}"
raise APIError(message, response.status_code)
return response.json()
def get(self, path, **params):
return self.request("GET", path, params=params)
def courses(self, **filters):
"""Yield every course, following next_page."""
page = 1
while page:
data = self.get("/v1/courses", page=page, per_page=4, **filters)
yield from data["items"]
page = data["next_page"]
def chat(self, messages, model="practice-llm-1"):
"""Send a conversation (non-streaming) and return a ChatReply."""
data = self.request("POST", "/v1/chat", json={"model": model, "messages": messages})
usage = data["usage"]
self.calls += 1
for name in self.usage:
self.usage[name] += usage[name]
return ChatReply(data["message"]["content"], usage)আসল কথা এর কাঠামো। বেস URL আর টোকেন আসে এনভায়রনমেন্ট থেকে (বা কনস্ট্রাক্টর আর্গুমেন্ট থেকে, যা টেস্ট সহজ করে), একটা Session শেয়ার্ড, একটা request মেথড টাইমআউট, রিট্রাই ও এরর অনুবাদের মালিক, আর বাকি সব (get, courses, chat) এর ওপর পাতলা স্তর। কলার একটাই এক্সেপশন টাইপ APIError দেখে, স্ট্যাটাস কোডসহ। sleep আর্গুমেন্ট টেস্টে এমন নকল পাঠাতে দেয় যা আসলে অপেক্ষা করে না। পরের ব্লকে ব্যবহার করুন:
from practice_client import APIError, PracticeClient
waits = []
client = PracticeClient(jitter=0, sleep=waits.append)
print(client.get("/health"))
print(client.get("/v1/flaky", key="demo-6", fail_times=2))
print("waited between retries:", waits)
try:
client.get("/v1/courses/99")
except APIError as error:
print(error, "| status", error.status, "| waits unchanged:", waits)
print([c["title"] for c in client.courses(level="intermediate")]){'status': 'ok'}
{'ok': True, 'attempt': 3}
waited between retries: [0.1, 0.2]
GET /v1/courses/99 -> 404: Course 99 not found | status 404 | waits unchanged: [0.1, 0.2]
['Prompt Engineering', 'LLM APIs']503 রিট্রাই হয়েছে, 404 হয়নি। সব কলের জায়গা এখন একই নীতি মানে: পরে রিট্রাইয়ের নিয়ম বদলাতে চাইলে একটা মেথড বদলালেই হয়।
LLM-ধাঁচের চ্যাট API ডাকা
আপনার কোডের দৃষ্টিতে একটা হোস্টেড LLM একটা HTTP API: JSON বডিসহ POST, একটা Authorization: Bearer … হেডার, আর JSON উত্তর। Practice API-র /v1/chat একই আকারের একটা নকল LLM (এর উত্তর অনুমানযোগ্য)। আগে কাঁচা রিকোয়েস্ট, যাতে ঠিক কী যায় তা দেখতে পান:
token = os.environ["PRACTICE_TOKEN"]
body = {"model": "practice-llm-1", "messages": [{"role": "user", "content": "What is an API?"}]}
r = requests.post(f"{BASE}/v1/chat", json=body, headers={"Authorization": f"Bearer {token}"},
timeout=(3.05, 60))
print(r.status_code)
print(json.dumps(r.json(), indent=2))200
{
"model": "practice-llm-1",
"message": {
"role": "assistant",
"content": "This is a practice answer about: What is an API. A real model would say more."
},
"usage": {
"prompt_tokens": 4,
"completion_tokens": 16,
"total_tokens": 20
}
}তিনটা জিনিস লক্ষ করুন। messages হলো {"role", "content"} টার্নের তালিকা; রোল system (আচরণের নির্দেশ), user ও assistant। উত্তর থাকে message.content-এ। আর usage বলে কত টোকেন ঢুকেছে আর বেরিয়েছে। এখানে রিড টাইমআউট লম্বা (৬০ সেকেন্ড), কারণ আসল মডেল উত্তর বানাতে কয়েক সেকেন্ড থেকে কয়েক মিনিট নেয়; কানেক্ট টাইমআউট ছোটই থাকে। আসল প্রোভাইডারদের ফিল্ডের নাম আলাদা, ধারণা এক:
| API | উত্তরের টেক্সট | টোকেন ব্যবহার |
|---|---|---|
Practice API /v1/chat | message.content | usage.prompt_tokens, usage.completion_tokens |
| OpenAI Chat Completions | choices[0].message.content | usage.prompt_tokens, usage.completion_tokens |
| OpenAI Responses (নতুন প্রজেক্টে সুপারিশকৃত) | SDK-তে output_text, আসলে output-এর আইটেম | usage.input_tokens, usage.output_tokens |
| Anthropic Messages | content[0].text (ব্লকের তালিকা) | usage.input_tokens, usage.output_tokens |
ফিল্ডের নাম আলাদা বলে প্রোভাইডারকে নিজের ফাংশনের (chat(messages) -> ChatReply) পেছনে রাখুন, ঠিক ওপরের PracticeClient.chat-এর মতো, যাতে প্রোভাইডার বদলালে একটা ফাইলই বদলাতে হয়। বাকি জায়গার মতো এখানেও একই এরর: টোকেন না থাকলে 401, ভুল টোকেনেও 401; কোনোটাই রিট্রাই হয় না:
for bad in (None, "token-wrong"):
anonymous = PracticeClient(token=bad, sleep=waits.append)
anonymous.session.headers.pop("Authorization", None)
if bad:
anonymous.session.headers["Authorization"] = f"Bearer {bad}"
try:
anonymous.chat([{"role": "user", "content": "hi"}])
except APIError as error:
print(error.status, error)401 POST /v1/chat -> 401: Missing bearer token
401 POST /v1/chat -> 401: Invalid or expired tokenকথোপকথন স্টেটলেস
চ্যাট API এক কল থেকে আরেক কলে কিছু মনে রাখে না। কথোপকথন চালাতে প্রতিবার পুরো ইতিহাস পাঠাতে হয়: আপনার সিস্টেম মেসেজ, আগের প্রতিটা ইউজার মেসেজ আর আগের প্রতিটা উত্তর। এজন্যই চ্যাট অ্যাপ্লিকেশন একটা তালিকা রাখে আর তাতে যোগ করে:
history = [{"role": "system", "content": "You are a concise tutor."}]
for question in ["What is an API?", "Why use timeouts?", "What is a retry?"]:
history.append({"role": "user", "content": question})
reply = client.chat(history)
history.append({"role": "assistant", "content": reply.text})
print(reply.usage["prompt_tokens"], reply.usage["completion_tokens"], "|", reply.text)
print(client.calls, "calls, totals:", client.usage)9 16 | This is a practice answer about: What is an API. A real model would say more.
28 15 | This is a practice answer about: Why use timeouts. A real model would say more.
47 16 | This is a practice answer about: What is a retry. A real model would say more.
3 calls, totals: {'prompt_tokens': 84, 'completion_tokens': 47, 'total_tokens': 131}প্রতিটা লাইনের প্রথম সংখ্যাটা দেখুন: প্রতি টার্নে prompt_tokens বাড়ছে, কারণ আগের টার্নগুলো আবার পাঠানো হচ্ছে আর আবার বিল হচ্ছে। (Practice API শব্দ গোনে; আসল প্রোভাইডাররা টোকেনাইজার দিয়ে টোকেন, অর্থাৎ শব্দের টুকরো, গোনে। ইংরেজিতে গড়ে প্রায় প্রতি টোকেনে তিন-চতুর্থাংশ শব্দ পড়ে, আর বাংলার মতো ভাষায় সাধারণত প্রতি শব্দে বেশি টোকেন লাগে, তাই একই লেখার খরচ বেশি হতে পারে।) লম্বা কথোপকথন ব্যয়বহুল হয় আর শেষে মডেলের কনটেক্সট উইন্ডো ছাড়িয়ে যায়, তাই আসল অ্যাপ্লিকেশন পুরনো টার্ন ছেঁটে ফেলে, সারাংশ করে, বা প্রম্পটের স্থির শুরুর অংশ ক্যাশ করে।
টোকেন ও খরচ সচেতনতা
LLM প্রোভাইডাররা টোকেন ধরে বিল করে, ইনপুট ও আউটপুটের দাম আলাদা, আর আউটপুট সাধারণত কয়েক গুণ বেশি দামি। খরচের হিসাব usage-এর সংখ্যার ওপর সাধারণ অঙ্ক। নিচের দামগুলো উদাহরণ; সবসময় প্রোভাইডারের বর্তমান প্রাইসিং পেজ দেখুন।
PRICE_PER_MILLION = {"input": 2.00, "output": 10.00} # উদাহরণ হিসেবে USD-তে দাম
def estimate_cost(usage, price=PRICE_PER_MILLION):
return (usage["prompt_tokens"] * price["input"]
+ usage["completion_tokens"] * price["output"]) / 1_000_000
per_call = estimate_cost(client.usage) / client.calls
print(f"cost of the conversation: ${estimate_cost(client.usage):.6f}")
print(f"if 1000 users make 30 such calls a month: ${per_call * 1000 * 30:.2f}")cost of the conversation: $0.000638
if 1000 users make 30 such calls a month: $6.38বিল অনুমানযোগ্য রাখার কিছু অভ্যাস:
- প্রতিটা কলে
usageলগ করুন (ইউজার আইডি ও ফিচারের নামসহ), যাতে "টাকা কোথায় গেল?" প্রশ্নের উত্তর দিতে পারেন। max_tokensঠিক করুন (বা প্রোভাইডারের সমতুল্য), উত্তরের দৈর্ঘ্যের সীমা দিতে, আর ছোট উত্তরই যথেষ্ট হলে ছোট উত্তর চান।- যে ইতিহাস আবার পাঠান তা ছাঁটুন, আর বড় অপরিবর্তিত লেখা (নির্দেশনা, ডকুমেন্ট) শুরুতে রাখুন, যাতে প্রোভাইডারের প্রম্পট ক্যাশিং ছাড় দিতে পারে।
- কাজ হয়ে যায় এমন সবচেয়ে ছোট মডেল সহজ কাজে (যেমন ক্লাসিফিকেশন) ব্যবহার করুন; বড়টা কঠিন প্রশ্নের জন্য রাখুন।
- রিট্রাইয়েও টাকা লাগে। চলে যাওয়া একটা রিট্রাই করা LLM কল আবার বিল হয়। চেষ্টা সীমিত রাখুন, 4xx এরর রিট্রাই করবেন না।
- প্রোভাইডারের ড্যাশবোর্ডে বাজেট ও অ্যালার্ট দিন, আর নিজের ব্যাকএন্ডে ইউজারপ্রতি সীমা রাখুন।
আসল প্রোভাইডার SDK (এখানে চালানো হয়নি)
আসল প্রোভাইডারের জন্য সাধারণত কাঁচা requests-এর বদলে এর অফিসিয়াল SDK ব্যবহার করা হয়। SDK রিকোয়েস্ট বানায়, রেসপন্সকে অবজেক্টে পার্স করে, আর সাময়িক এরর নিজেই রিট্রাই করে (ডিফল্ট দুবার, max_retries দিয়ে বদলানো যায়), সাথে একটা timeout সেটিং থাকে যা আপনাকে তবুও বেছে দিতে হবে। কি পড়া হয় এনভায়রনমেন্ট ভেরিয়েবল থেকে, তাই কোডে তা কখনো আসে না। এই ব্লকগুলোর জন্য অ্যাকাউন্ট, পেইড কি ও ইন্টারনেট লাগে, তাই এগুলো দেখানো হয়েছে কিন্তু চালানো হয়নি। Anthropic-এর Messages API:
# pip install anthropic (এনভায়রনমেন্টে ANTHROPIC_API_KEY লাগবে)
import anthropic
client = anthropic.Anthropic(timeout=60.0, max_retries=2) # ANTHROPIC_API_KEY নিজেই পড়ে
message = client.messages.create(
model="claude-sonnet-5-5", # মডেলের নাম বদলায়: প্রোভাইডারের মডেল তালিকা দেখুন
max_tokens=1024, # উত্তরের দৈর্ঘ্যের সীমা সবসময় দিন
system="You are a concise tutor.",
messages=[{"role": "user", "content": "What is an API?"}],
)
print(message.content[0].text)
print(message.usage.input_tokens, message.usage.output_tokens)OpenAI-র Responses API-র মোট আকার একই, নাম আলাদা:
# pip install openai (এনভায়রনমেন্টে OPENAI_API_KEY লাগবে)
from openai import OpenAI
client = OpenAI(timeout=60.0, max_retries=2) # OPENAI_API_KEY নিজেই পড়ে
response = client.responses.create(
model=os.environ["OPENAI_MODEL"], # প্রোভাইডারের ডকুমেন্টেশন থেকে একটা বর্তমান মডেল বেছে নিন
instructions="You are a concise tutor.",
input="What is an API?",
max_output_tokens=1024,
)
print(response.output_text)
print(response.usage.input_tokens, response.usage.output_tokens)দুটোকেই PracticeClient.chat-এর সাথে মেলান: এনভায়রনমেন্ট থেকে কি, একটা টাইমআউট, আউটপুটের সীমা, ভেতরে মেসেজের তালিকা, বেরিয়ে টেক্সট ও টোকেন গণনা। এজন্যই এদের যেকোনোটার চারপাশে নিজের একটা পাতলা chat() ফাংশন লেখা লাভজনক। আসল কি কখনো স্ক্রিপ্ট, নোটবুক বা চ্যাটে পেস্ট করবেন না; ফাঁস হওয়া কি-র বিল আপনার ঘাড়ে আসে।
বাস্তব ব্যবহার
- ব্যাচ কাজ: দশ হাজার ডকুমেন্ট LLM দিয়ে ক্লাসিফাই বা সারাংশ করা। রিট্রাই ও সংরক্ষিত অগ্রগতি না থাকলে ৮,০০০ নম্বর ডকুমেন্টে একটা 429 পুরো রান নষ্ট করে; থাকলে রান আবার চালু হয়।
- ব্যাকএন্ড: আপনার FastAPI সার্ভিস প্রতিটা ইউজার রিকোয়েস্টে একটা মডেল প্রোভাইডারকে ডাকে; এর ক্লায়েন্ট ক্লাস, টাইমআউট ও usage লগিং ঠিক এখানে যা লিখলেন তাই।
- খরচ নিয়ন্ত্রণ: ফিচার ও ইউজারপ্রতি টোকেন ব্যবহার প্রতিটা AI প্রোডাক্ট টিমের বানানো প্রথম ড্যাশবোর্ড।
সাধারণ ভুল
- সবকিছু রিট্রাই করা।
401বা422নিজে ঠিক হবে না; রিট্রাইয়ে সময় নষ্ট হয়, আর পেইড API-তে টাকাও। শুধু সাময়িক ব্যর্থতা রিট্রাই করুন। - চিরকাল বা তৎক্ষণাৎ রিট্রাই। সীমা না থাকলে অন্তহীন লুপ, বিলম্ব না থাকলে কাহিল সার্ভারের ওপর হাতুড়ি। চেষ্টা সীমিত করুন, ব্যাকঅফ করুন, জিটার দিন।
Retry-Afterউপেক্ষা করা। সার্ভার ঠিক বলে দিয়েছে কখন ফিরবেন। মানুন (একটা যুক্তিসংগত ঊর্ধ্বসীমাসহ)।- অনিরাপদ POST রিট্রাই। বারবার "create order" দুটো অর্ডার বানাতে পারে। রিট্রাই করুন শুধু যখন ডুপ্লিকেট কল নিরীহ বা আইডেমপোটেন্সি কি আপনাকে রক্ষা করছে।
- ইতিহাস আবার পাঠানো হয় ভুলে যাওয়া। প্রতি টার্নে খরচ বাড়ে। ছাঁটুন, সারাংশ করুন, আর
max_tokensসীমিত রাখুন। - usage লগ না করা। প্রতি কলে টোকেন রেকর্ড না করলে বেপরোয়া লুপ প্রথম ধরা পড়ে ইনভয়েসে।
- কোডে কি। কি পড়ুন এনভায়রনমেন্ট থেকে; কখনো কমিট বা প্রিন্ট করবেন না।
নিজে চেষ্টা করুন
- সহজ:
base=0.5ওcap=4দিয়ে 1 থেকে ৫ নম্বর চেষ্টার ব্যাকঅফ বিলম্বগুলো ছাপুন। - সহজ:
PracticeClientব্যবহার করে ৮ ঘণ্টার বেশি এমন সবbeginnerকোর্সের শিরোনাম ঘণ্টা অনুযায়ী সাজিয়ে ছাপুন। - মাঝারি:
request_with_retriesদিয়েfail_times=3সহ/v1/flakyদুবার ডাকুন: একবারmax_attempts=3(কিex-1), একবারmax_attempts=5(কিex-2)। প্রতিবার চূড়ান্ত স্ট্যাটাস ছাপুন। - মাঝারি: চ্যাট API-কে তিনটা প্রশ্ন করুন (প্রতিটা এক টার্নের), প্রশ্ন, উত্তর ও টোকেন সংখ্যা
answers.json-এ সেভ করুন, আর ফাইলে কয়টা রেকর্ড আছে ও মোট টোকেন কত ছাপুন। - কঠিন:
BudgetClientলিখুন, যাPracticeClient-এর সাবক্লাস,max_total_tokensনেয়, এবং মোট ব্যবহার বাজেটে পৌঁছে গেলে চ্যাট কলের আগেইAPIError("token budget exceeded")ছোড়ে। 40 বাজেট দিয়ে প্রশ্নের একটা লুপ থামতে দেখান।
উত্তর
# 1. বিলম্ব
print([backoff_delay(n, base=0.5, cap=4) for n in range(1, 6)])
# 2. লম্বা বিগিনার কোর্স
long_ones = [c for c in client.courses(level="beginner") if c["hours"] > 8]
for c in sorted(long_ones, key=lambda c: c["hours"]):
print(c["hours"], c["title"])[0.5, 1.0, 2.0, 4, 4]
9 Git, APIs and Backend
10 SQL and Data Handling
12 Python for AI# 3. হাল ছাড়া বনাম চালিয়ে যাওয়া
a = request_with_retries("GET", f"{BASE}/v1/flaky", params={"key": "ex-1", "fail_times": 3},
max_attempts=3, jitter=False, base=0.05)
b = request_with_retries("GET", f"{BASE}/v1/flaky", params={"key": "ex-2", "fail_times": 3},
max_attempts=5, jitter=False, base=0.05)
print(a.status_code, b.status_code, b.json())
# 4. উত্তর সেভ করা
records = []
for question in ["What is a token?", "What is a timeout?", "What is JSON?"]:
reply = client.chat([{"role": "user", "content": question}])
records.append({"question": question, "answer": reply.text, **reply.usage})
with open("answers.json", "w", encoding="utf-8") as f:
json.dump(records, f, indent=2, ensure_ascii=False)
with open("answers.json", encoding="utf-8") as f:
saved = json.load(f)
print(len(saved), sum(r["total_tokens"] for r in saved))503 200 {'ok': True, 'attempt': 4}
3 58# 5. টোকেন বাজেট
class BudgetClient(PracticeClient):
def __init__(self, max_total_tokens, **kwargs):
super().__init__(**kwargs)
self.max_total_tokens = max_total_tokens
def chat(self, messages, model="practice-llm-1"):
if self.usage["total_tokens"] >= self.max_total_tokens:
raise APIError("token budget exceeded")
return super().chat(messages, model)
budgeted = BudgetClient(40)
for number in range(1, 10):
try:
reply = budgeted.chat([{"role": "user", "content": f"Question number {number}"}])
except APIError as error:
print(f"stopped before call {number}:", error, budgeted.usage["total_tokens"])
breakstopped before call 4: token budget exceeded 54
সারসংক্ষেপ
- শুধু সাময়িক ব্যর্থতা রিট্রাই করুন: কানেকশন এরর, টাইমআউট, 429, 500, 502, 503, 504। নিজের ভুলের 4xx কখনো নয়।
- সীমাসহ এক্সপোনেনশিয়াল ব্যাকঅফ ও জিটার, সর্বোচ্চ চেষ্টার সংখ্যা, এবং
Retry-Afterমানা। POST রিট্রাইয়ে সতর্ক থাকুন। requests.Sessionকানেকশন পুনর্ব্যবহার ও হেডার শেয়ার করে; urllib3-এরRetryহাতে লেখা লুপের বিল্ট-ইন বিকল্প।- আনা ডেটা
encoding="utf-8"দিয়ে JSON (কাঠামো থাকে) বা CSV (সমতল টেবিল) ফাইলে সেভ করুন। - টাইমআউট, রিট্রাই, অথ ও এরর অনুবাদ একটাই এক্সেপশন টাইপসহ একটা ক্লায়েন্ট ক্লাসে মুড়ে দিন।
- চ্যাট API স্টেটলেস: ইতিহাস আবার পাঠাতে হয়, আর সাথে টোকেন ও খরচ বাড়ে।
usageলগ করুন,max_tokensসীমিত করুন, প্রোভাইডারের প্রাইসিং পড়ুন।
এরপর: What Is a Backend? পাতায় দিক ঘুরে যাবে: অন্যের সার্ভার ডাকার বদলে আপনি নিজের সার্ভার বানাতে শুরু করবেন।