অধ্যায় 2 · API ডিজাইন ও API ক্লায়েন্ট
httpx ও asyncio দিয়ে পেশাদার API ক্লায়েন্ট
- পৃষ্ঠা 7 / 22
- 26 মিনিট পড়া
প্রায় প্রতিটা AI প্রোডাক্ট আর যা-ই হোক, আগে একটা ক্লায়েন্ট। একটা RAG সার্ভিস এমবেডিং API আর ভেক্টর ডেটাবেসকে কল করে, একটা এজেন্ট পরপর দশটা টুল কল করে, একটা ডেটা পাইপলাইন পার্টনারের API থেকে হাজার হাজার রেকর্ড টেনে আনে, আর প্রতিটা LLM রিকোয়েস্টই আসলে প্রোভাইডারের কাছে একটা HTTP কল। বিগিনার টিউটোরিয়ালে আপনি requests দিয়ে একবারে একটা করে কল করেছেন। এই পাতা সেই ক্লায়েন্ট কোড নিয়ে, যা প্রোডাকশনে চলে: একটাই শেয়ার করা কানেকশন পুল, প্রতিটা কলে টাইমআউট, সার্ভারের কথা মেনে রিট্রাই, asyncio দিয়ে একসাথে অনেক রিকোয়েস্ট, স্ট্রিম করা উত্তর, টাইপ করা ফলাফল, নেটওয়ার্ক না ছুঁয়েই টেস্ট, আর এমন লগ যাতে কখনো কোনো কি (key) ফাঁস হয় না।
টুলটা হলো httpx: দেখতে requests-এর মতো, কিন্তু async, HTTP/2 আর স্ট্রিমিংও পারে। সবকিছু চলবে http://localhost:8800-এ BitByte Practice API-র বিরুদ্ধে, তাই আগে থেকে চালু না থাকলে দ্বিতীয় একটা টার্মিনালে সেটা চালু করুন (fastapi run practice_api.py --port 8800)।
যা শিখবেন
- httpx কোথায়
requestsথেকে আলাদা, আর httpx2 কী এবং OpenAI ও Anthropic-এর SDK কেন সেদিকে সরে গেছে। - কানেকশন পুলিংয়ের জন্য একটাই
httpx.Clientবারবার ব্যবহার করা: বেস URL, হেডার, লিমিট আর স্পষ্ট টাইমআউটসহ। - এক্সপোনেনশিয়াল ব্যাকঅফ, জিটার আর
Retry-Afterমেনে রিট্রাই, আর পেজিনেশনের একটা হেল্পার। asyncio-র মূল ধারণা,AsyncClient, সেমাফোরসহgather, ডেডলাইন, আর SSE স্ট্রিম পড়া।- Pydantic দিয়ে টাইপ করা একটা ক্লায়েন্ট, respx আর
MockTransportদিয়ে তার টেস্ট, নিরাপদ লগিং, আর সেটাকে লাইব্রেরি হিসেবে প্যাকেজ করা।
প্রজেক্ট সেটআপ
mkdir -p ~/projects/api-clients
cd ~/projects/api-clients
uv init --bare
uv add -q httpx pydantic
uv add -q --dev pytest pytest-asyncio respx
# uv-এর বদলে pip দিয়ে:
# python -m venv .venv && source .venv/bin/activate
# pip install -q httpx pydantic pytest pytest-asyncio respx
lsInitialized project `api-clients`
pyproject.toml
uv.lockএই পাতার Python অংশগুলো নোটবুকের সেলের মতো একটার পর একটা চলবে। এই ফোল্ডার থেকে uv run python-এ (বা Jupyter-এ) চালান, অথবা একটা ফাইলে পেস্ট করে uv run python file.py দিয়ে চালান।
requests, httpx আর httpx2
প্রথম কলটা দেখতে হুবহু requests-এর মতো:
import httpx
BASE_URL = "http://localhost:8800"
response = httpx.get(f"{BASE_URL}/v1/courses", params={"level": "advanced"})
print(response.status_code, response.http_version)
print(response.url)
print([course["title"] for course in response.json()["items"]])200 HTTP/1.1
http://localhost:8800/v1/courses?level=advanced
['RAG Systems', 'AI Agents', 'LLMOps']কোড যখন কারও নজর ছাড়া একা চলে, তখন পার্থক্যগুলো গুরুত্বপূর্ণ হয়ে ওঠে:
requests 2.34 | httpx 0.28 / httpx2 2.x | |
|---|---|---|
| ডিফল্ট টাইমআউট | নেই: সার্ভার আটকে গেলে আপনার প্রোগ্রামও চিরকাল আটকে থাকে | connect, read, write আর pool, প্রতিটার জন্য ৫ সেকেন্ড |
| রিডাইরেক্ট | নিজে থেকেই ফলো করে | follow_redirects=True না দিলে ফলো করে না |
| Async | নেই | একই মেথডসহ httpx.AsyncClient |
| HTTP/2 | নেই | ঐচ্ছিক: pip install "httpx[http2]", Client(http2=True) |
| স্ট্রিমিং | stream=True + iter_lines() | with client.stream(...) + iter_lines() / aiter_lines() |
| টেস্টের ব্যবস্থা | থার্ড-পার্টি (responses) | বিল্ট-ইন MockTransport, সাথে respx |
httpx2 জিনিসটা কী
httpx-এর শেষ রিলিজ 0.28.1 বেরিয়েছিল ২০২৪ সালের ডিসেম্বরে, আর বহুদিনের প্রতিশ্রুত 1.0 এখনো আসেনি। ২০২৬ সালের মে মাসে Pydantic শুরু করে httpx2: httpx 0.28.1-এর একটা রক্ষণাবেক্ষণ করা ফর্ক (fork), একই API, শুধু ইমপোর্টের নাম নতুন। OpenAI SDK (3.x) আর Anthropic SDK (1.x) এখন httpx2-এর ওপর নির্ভর করে, আর Starlette-এর TestClient-ও আগে এটাকেই খোঁজে। এই পাতার কোডে import httpx লেখা হয়েছে, কারণ বেশিরভাগ ডকুমেন্টেশন আর পুরোনো কোডে সেটাই দেখবেন; বদলাতে লাগে এক লাইন:
import httpx2
print(httpx2.Client is httpx.Client) # দুটো আলাদা লাইব্রেরি
with httpx2.Client(base_url=BASE_URL) as other:
print(other.get("/health").json())
print(other.headers["user-agent"].split("/")[0])False
{'status': 'ok'}
python-httpx2- একটা ফাইল সরিয়ে নিতে লিখুন
import httpx2 as httpx; ক্লাস, মেথড আর এক্সেপশন সব একই। তবে দুই লাইব্রেরির অবজেক্ট মেশানো যায় না (একটাhttpx.Responseকিন্তুhttpx2.Responseনয়)। - httpx2 TLS যাচাই করে
certifiবান্ডেলের বদলে অপারেটিং সিস্টেমের সার্টিফিকেট স্টোর দিয়ে (truststore-এর মাধ্যমে)। স্লিম Docker ইমেজে তাইca-certificatesপ্যাকেজ লাগে (Debian-এরpython:*-slimইমেজে এটা আছে)। - httpx-এর জন্য বানানো টুল, যেমন নিচের respx, শুধু
httpx-কেই প্যাচ করে। নতুন অ্যাপ্লিকেশনের জন্য দীর্ঘমেয়াদে httpx2 নিরাপদ পছন্দ; লাইব্রেরি লিখলে ক্লায়েন্টটা কলারের হাত থেকে নিন, তাহলে দুটোই চলবে।
এক ক্লায়েন্ট, অনেক রিকোয়েস্ট: কানেকশন পুলিং
httpx.get() ফাংশনটা একটা নতুন কানেকশন খোলে, একটা রিকোয়েস্ট পাঠায়, তারপর কানেকশন বন্ধ করে দেয়। কানেকশন খুলতে লাগে একটা TCP হ্যান্ডশেক, আর HTTPS হলে একটা TLS হ্যান্ডশেকও: অন্য মহাদেশের কোনো প্রোভাইডারের বেলায় সহজেই ৫০ থেকে ৩০০ মিলিসেকেন্ড, আপনার রিকোয়েস্টের প্রথম বাইট যাওয়ার আগেই। একটা Client কানেকশনগুলো খোলা রাখে আর আবার ব্যবহার করে (HTTP keep-alive)। httpx-এর trace এক্সটেনশন দিয়ে তারের ওপর আসলে কী ঘটছে তা জানা যায়, তাই নতুন কানেকশনগুলো গুনে দেখুন:
def count_connections(client, n):
opened = []
def trace(event, info):
if event == "connection.connect_tcp.complete":
opened.append(event)
for _ in range(n):
client.get(f"{BASE_URL}/health", extensions={"trace": trace})
return len(opened)
with httpx.Client() as shared:
print("one shared client: ", count_connections(shared, 5), "connection(s) for 5 requests")
total = 0
for _ in range(5):
with httpx.Client() as throwaway:
total += count_connections(throwaway, 1)
print("a client per request:", total, "connection(s) for 5 requests")one shared client: 1 connection(s) for 5 requests
a client per request: 5 connection(s) for 5 requestsতাই প্রোগ্রাম চালু হওয়ার সময় প্রতিটা আপস্ট্রিম সার্ভিসের জন্য একটা করে ক্লায়েন্ট বানান, সব জায়গায় সেটাই ব্যবহার করুন, আর প্রোগ্রাম শেষ হলে বন্ধ করুন (FastAPI-তে এটা lifespan-এর কাজ, পরের পাতাগুলোতে দেখবেন)। কনফিগারেশন একবারই করুন:
client = httpx.Client(
base_url=BASE_URL,
headers={"User-Agent": "bitbyte-notebook/1.0", "Accept": "application/json"},
timeout=httpx.Timeout(10.0, connect=3.0),
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
)
echo = client.get("/v1/echo", params={"q": "vector databases", "k": 3}).json()
print(echo["query"])
print(echo["headers"])
print(client.timeout){'q': 'vector databases', 'k': '3'}
{'user-agent': 'bitbyte-notebook/1.0', 'accept': 'application/json'}
Timeout(connect=3.0, read=10.0, write=10.0, pool=10.0)base_urlথাকায় প্রতিটা কলে শুধু পাথ লিখলেই হয়, ফলে Practice API থেকে স্টেজিং সার্ভারে যেতে একটা সেটিং বদলালেই চলে।paramsআপনার হয়ে URL-এনকোড হয়: স্পেস আর সংখ্যা দুটোই ঠিকঠাক পৌঁছেছে।limitsঠিক করে পুল সর্বোচ্চ কয়টা কানেকশন খুলবে। ২০টাই ব্যস্ত থাকলে পরের রিকোয়েস্ট একটা খালি হওয়ার অপেক্ষা করে, সর্বোচ্চ pool টাইমআউট পর্যন্ত।
টাইমআউট আর এরর
httpx-এ চারটা টাইমআউট আছে, প্রতিটা আলাদা ধরনের বিপদ ঠেকায়:
| টাইমআউট | যে সময়টা সীমিত করে | সাধারণ মান |
|---|---|---|
connect | TCP/TLS কানেকশন খোলা | ২–৫ সেকেন্ড: যে সার্ভার কানেকশনই নিতে পারছে না, সেটা ডাউন |
read | রেসপন্সের পরের টুকরোর জন্য অপেক্ষা | ১০–৩০ সেকেন্ড; স্ট্রিমিং ছাড়া LLM কলে ৬০–১২০ সেকেন্ডও লাগতে পারে |
write | রিকোয়েস্ট বডির একটা টুকরো পাঠানো | read-এর মতোই; আপলোডে গুরুত্বপূর্ণ |
pool | পুল থেকে একটা খালি কানেকশনের অপেক্ষা | কয়েক সেকেন্ড; এটা ঘটলে হয় পুল ছোট, নয়তো কোথাও কানেকশন লিক হচ্ছে |
read টাইমআউট পুরো রেসপন্সের জন্য নয়, প্রতিটা টুকরোর জন্য: যে স্ট্রিম প্রতি সেকেন্ডে একটা টোকেন পাঠায়, সেটা পাঁচ মিনিট চললেও ১০ সেকেন্ডের read টাইমআউটে কখনো আটকাবে না। পুরো কাজের একটা সর্বোচ্চ সময় চাইলে asyncio.timeout() ব্যবহার করুন (এই পাতাতেই পরে আসছে)। এক্সেপশনগুলো একটা গাছের মতো সাজানো, তাই যত বড় বা ছোট পরিসরে দরকার, ধরতে পারেন:
httpx.HTTPError
├── httpx.HTTPStatusError 4xx/5xx রেসপন্সে raise_for_status()
└── httpx.RequestError কোনো কাজের রেসপন্সই আসেনি
└── httpx.TransportError
├── httpx.TimeoutException
│ ├── ConnectTimeout ReadTimeout WriteTimeout PoolTimeout
└── httpx.NetworkError
├── ConnectError ReadError WriteError CloseErrorইচ্ছে করে তিনটা ঘটান: ছোট read টাইমআউটসহ একটা ধীর এন্ডপয়েন্ট, এমন একটা পোর্ট যেখানে কেউ শুনছে না, আর না-থাকা একটা কোর্স:
def try_call(label, func):
try:
response = func()
response.raise_for_status()
print(f"{label}: {response.status_code}")
except httpx.HTTPStatusError as exc:
print(f"{label}: HTTPStatusError {exc.response.status_code} {exc.response.json()['detail']!r}")
except httpx.TimeoutException as exc:
print(f"{label}: {type(exc).__name__}")
except httpx.RequestError as exc:
print(f"{label}: {type(exc).__name__} {exc}")
try_call("slow", lambda: client.get("/v1/slow", params={"seconds": 2}, timeout=httpx.Timeout(5.0, read=0.5)))
try_call("down", lambda: client.get("http://localhost:8001/health"))
try_call("missing", lambda: client.get("/v1/courses/99"))
try_call("ok", lambda: client.get("/v1/courses/8"))slow: ReadTimeout
down: ConnectError [Errno 111] Connection refused
missing: HTTPStatusError 404 'Course 99 not found'
ok: 200খেয়াল করুন, রিকোয়েস্টে দেওয়া timeout= ক্লায়েন্টেরটাকে ছাপিয়ে যায়, আর পুরো (absolute) URL দিলে base_url উপেক্ষিত হয়। HTTPStatusError আর RequestError আলাদা করে ধরুন: প্রথমটার মানে সার্ভার উত্তর দিয়েছে (তার এরর বডি পড়তে পারবেন), দ্বিতীয়টার মানে কোনো উত্তরই পাননি (রিট্রাই কাজে লাগতে পারে)।
রিট্রাই, ব্যাকঅফ আর রেট লিমিট
নেটওয়ার্কে প্যাকেট হারায়, লোড ব্যালান্সার রিস্টার্ট হয়, চাপের মুখে প্রোভাইডার 503 দেয় আর কোটা পেরোলে দেয় 429। প্রোডাকশনের ক্লায়েন্ট কিছু ব্যর্থতায় রিট্রাই করে, প্রতিবার আরও বেশি অপেক্ষা করে, আর সময়মতো হাল ছেড়ে দেয়। নিয়মগুলো:
- রিট্রাই করুন কানেকশন এরর, টাইমআউট আর
429,502,503,504স্ট্যাটাসে।400,401,403,404বা422-এ কখনো নয়: একই রিকোয়েস্ট একইভাবে ব্যর্থ হবে। - চোখ বুজে রিট্রাই শুধু ইডেমপোটেন্ট রিকোয়েস্টে (GET, PUT, DELETE)। টাইমআউট হওয়া একটা
POSTহয়তো এর মধ্যেই কার্ড থেকে টাকা কেটে ফেলেছে বা একটা LLM জব চালু করে দিয়েছে; সেটা রিট্রাই করুন শুধু আইডেমপোটেন্সি কি দিয়ে (মজবুত API ডিজাইন: পেজিনেশন, রেট লিমিট, রিট্রাই ও ভার্সনিং)। - জিটারসহ এক্সপোনেনশিয়াল ব্যাকঅফ: মোটামুটি
base × 2^attemptসময় অপেক্ষা করুন, তবে এলোমেলো করে, যাতে একসাথে ব্যর্থ হওয়া হাজারটা ক্লায়েন্ট একসাথে আবার চেষ্টা না করে। Retry-Afterমেনে চলুন, আর সেটা যদি আপনার কলারের সহ্যের চেয়ে বেশি অপেক্ষা করতে বলে, তাহলে হাল ছেড়ে দিন।
httpx.HTTPTransport(retries=3) আছে বটে, কিন্তু সেটা শুধু ব্যর্থ কানেকশনে (ConnectError, ConnectTimeout) রিট্রাই করে, স্ট্যাটাস কোডে কখনো নয়। স্ট্যাটাস দেখে রিট্রাই করা আপনার কোডের দায়িত্ব:
# file: retrying.py
"""স্ট্যাটাস দেখে রিট্রাই: এক্সপোনেনশিয়াল ব্যাকঅফ, ফুল জিটার আর Retry-After।"""
import random
import time
import httpx
RETRY_STATUSES = {429, 502, 503, 504}
RETRY_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout)
def retry_after_seconds(response):
"""সংখ্যায় লেখা Retry-After হেডার থেকে সেকেন্ড, নয়তো None।"""
value = response.headers.get("Retry-After", "")
return float(value) if value.isdigit() else None
def request_with_retries(client, method, url, *, attempts=4, base_delay=0.2, max_wait=5.0,
log=print, **kwargs):
for attempt in range(1, attempts + 1):
response = None
try:
response = client.request(method, url, **kwargs)
if response.status_code not in RETRY_STATUSES:
return response
reason = f"HTTP {response.status_code}"
except RETRY_ERRORS as exc:
reason = type(exc).__name__
if attempt == attempts:
raise
if attempt == attempts:
return response
wait = retry_after_seconds(response) if response is not None else None
if wait is None:
wait = random.uniform(0, base_delay * 2 ** attempt) # "ফুল জিটার"
if wait > max_wait:
log(f" attempt {attempt}: {reason}, server asks for {wait:.0f} s, giving up")
return response
log(f" attempt {attempt}: {reason}, retrying")
time.sleep(wait)from retrying import request_with_retries
print("flaky endpoint:")
r = request_with_retries(client, "GET", "/v1/flaky", params={"key": "p07-demo", "fail_times": 2})
print(" ", r.status_code, r.json())
print("rate-limited endpoint:")
for call in range(4):
r = request_with_retries(client, "GET", "/v1/limited", headers={"X-Client-Id": "p07-notebook"})
print(" ", r.status_code, r.json(), r.headers.get("Retry-After"))flaky endpoint:
attempt 1: HTTP 503, retrying
attempt 2: HTTP 503, retrying
200 {'ok': True, 'attempt': 3}
rate-limited endpoint:
200 {'ok': True, 'remaining': 2} None
200 {'ok': True, 'remaining': 1} None
200 {'ok': True, 'remaining': 0} None
attempt 1: HTTP 429, server asks for 10 s, giving up
429 {'detail': 'Too many requests'} 10flaky এন্ডপয়েন্টটা দুবার ব্যর্থ হয়ে তৃতীয়বারে সফল হয়েছে। রেট-লিমিটেড এন্ডপয়েন্টটা দশ সেকেন্ডে তিনটা কল নেয়; চতুর্থটা পেয়েছে 429 আর Retry-After: 10, যা এই কলারের max_wait-এর চেয়ে বেশি, তাই ফাংশনটা ঘুমিয়ে না থেকে তখনই 429 ফেরত দিয়েছে। এটাই রিট্রাই বাজেট: চ্যাটবটের উত্তরের জন্য অপেক্ষারত একজন ব্যবহারকারী দশ সেকেন্ড আর তার ওপর তিনটা রিট্রাইয়ের ধকল সইতে পারবেন না; পরিষ্কার একটা মেসেজসহ দ্রুত ব্যর্থ হওয়াই ভালো। রাতের কোনো ব্যাচ জব অবশ্য বড় max_wait রাখতে পারে।
Retry-Afterএকটা HTTP তারিখও হতে পারে (Wed, 21 Oct 2026 07:28:00 GMT);email.utils.parsedate_to_datetimeসেটা পার্স করে। tenacity বা stamina-র মতো লাইব্রেরি এই যুক্তিটাই ডেকোরেটর হিসেবে দেয়; একবার নিজে লিখলে বুঝবেন সেগুলো কীভাবে কনফিগার করা উচিত।
পেজিনেশন হেল্পার
লিস্টিং এন্ডপয়েন্ট একবারে একটা পেজ ফেরত দেয়। পেজের লুপটা একটা জেনারেটরের ভেতরে লুকিয়ে ফেলুন, যাতে কলার শুধু ইটারেট করেন:
def iter_courses(client, level=None, per_page=4):
"""next_page None না হওয়া পর্যন্ত পরের পেজে গিয়ে প্রতিটা কোর্স yield করে।"""
page = 1
while page is not None:
params = {"page": page, "per_page": per_page}
if level:
params["level"] = level
response = client.get("/v1/courses", params=params)
response.raise_for_status()
body = response.json()
print(f" fetched page {body['page']} ({len(body['items'])} items)")
yield from body["items"]
page = body["next_page"]
titles = [course["title"] for course in iter_courses(client)]
print(len(titles), "courses; last:", titles[-1])
first_two = []
for course in iter_courses(client, per_page=2):
first_two.append(course["id"])
if len(first_two) == 2:
break
print(first_two) fetched page 1 (4 items)
fetched page 2 (4 items)
fetched page 3 (2 items)
10 courses; last: LLMOps
fetched page 1 (2 items)
[1, 2]এটা জেনারেটর বলে দ্বিতীয় লুপটা মাত্র একটা পেজ এনেছে: কলার ইটারেট করা থামানোর সাথে সাথে সেটাও আর চায়নি। কার্সর-ভিত্তিক API-তে (next_page-এর বদলে next_cursor) কাঠামোটা একই।
Pydantic দিয়ে টাইপ করা ক্লায়েন্ট
কোডবেস জুড়ে response.json()["items"][0]["title"] ছড়িয়ে রাখার মানে হলো, আপস্ট্রিম API কোনো ফিল্ড বদলালে আপনি সেটা জানবেন আপনার বিজনেস লজিকের গভীরে একটা KeyError থেকে। একটা ক্লায়েন্ট ক্লাস একটা API সম্পর্কে সব জ্ঞান এক জায়গায় রাখে, সীমানাতেই প্রতিটা রেসপন্স Pydantic দিয়ে যাচাই করে, আর HTTP-র ব্যর্থতাগুলোকে আপনার নিজের এক্সেপশনে বদলে দেয়। এটাকে একটা মডিউল হিসেবে সেভ করুন:
# file: bitbyte_client.py
"""BitByte Practice API-র জন্য ছোট একটা টাইপ করা ক্লায়েন্ট।"""
from collections.abc import Iterator
import httpx
from pydantic import BaseModel, ValidationError
class Course(BaseModel):
id: int
title: str
level: str
hours: int
rating: float
class CoursePage(BaseModel):
page: int
total: int
next_page: int | None
items: list[Course]
class Weather(BaseModel):
city: str
temp_c: float
humidity: int
condition: str
class BitByteError(Exception):
"""এই ক্লায়েন্ট যত এরর তোলে, সবার বেস ক্লাস।"""
class NotFound(BitByteError):
pass
class UpstreamError(BitByteError):
"""API ব্যর্থ হয়েছে, বা এমন ডেটা দিয়েছে যা কাজে লাগানো যায় না।"""
class BitByteClient:
def __init__(self, base_url: str, *, timeout: float = 10.0,
transport: httpx.BaseTransport | None = None) -> None:
self._http = httpx.Client(
base_url=base_url,
timeout=httpx.Timeout(timeout, connect=3.0),
headers={"User-Agent": "bitbyte-client/0.1"},
transport=transport,
)
def __enter__(self) -> "BitByteClient":
return self
def __exit__(self, *exc_info) -> None:
self.close()
def close(self) -> None:
self._http.close()
def _get(self, path: str, **params) -> dict:
try:
response = self._http.get(path, params=params)
except httpx.RequestError as exc:
raise UpstreamError(f"GET {path} failed: {type(exc).__name__}") from exc
if response.status_code == 404:
raise NotFound(response.json().get("detail", path))
if response.is_error:
raise UpstreamError(f"GET {path} returned {response.status_code}")
return response.json()
def get_course(self, course_id: int) -> Course:
return self._parse(Course, self._get(f"/v1/courses/{course_id}"))
def list_courses(self, *, level: str | None = None, page: int = 1, per_page: int = 5) -> CoursePage:
params = {"page": page, "per_page": per_page}
if level:
params["level"] = level
return self._parse(CoursePage, self._get("/v1/courses", **params))
def iter_courses(self, *, level: str | None = None) -> Iterator[Course]:
page: int | None = 1
while page is not None:
result = self.list_courses(level=level, page=page, per_page=20)
yield from result.items
page = result.next_page
def weather(self, city: str) -> Weather:
return self._parse(Weather, self._get("/v1/weather", city=city))
@staticmethod
def _parse(model, data):
try:
return model.model_validate(data)
except ValidationError as exc:
raise UpstreamError(f"unexpected {model.__name__} payload: {exc.error_count()} error(s)") from excযেকোনো লাইব্রেরির মতোই ব্যবহার করুন। খেয়াল করুন, কলাররা এখন টাইপসহ অ্যাট্রিবিউট পান (course.rating একটা float, এডিটর অটোকমপ্লিট করতে পারে), আর এক্সেপশন পান শুধু ক্লায়েন্টের নিজের:
from bitbyte_client import BitByteClient, NotFound
with BitByteClient(BASE_URL) as bitbyte:
course = bitbyte.get_course(8)
print(course)
print(sum(c.hours for c in bitbyte.iter_courses(level="beginner")), "hours of beginner courses")
print(bitbyte.weather("Sylhet").model_dump())
try:
bitbyte.get_course(99)
except NotFound as exc:
print("NotFound:", exc)id=8 title='RAG Systems' level='advanced' hours=10 rating=4.9
42 hours of beginner courses
{'city': 'Sylhet', 'temp_c': 27.4, 'humidity': 85, 'condition': 'Rain'}
NotFound: Course 99 not foundtransport আর্গুমেন্টটা আপাতত অকেজো মনে হচ্ছে। এটা আসলে টেস্টিংয়ের দরজা, নিচে কাজে লাগবে।
এক সেকশনে asyncio
একটা API ক্লায়েন্টের জীবনের বেশিরভাগটাই কাটে অপেক্ষায়: নেটওয়ার্কের জন্য, সার্ভারের জন্য, মডেলের টোকেন বানানোর জন্য। সিঙ্ক্রোনাস প্রোগ্রাম একটা রেসপন্স না আসা পর্যন্ত পরের রিকোয়েস্ট পাঠায় না। asyncio দিয়ে একটা থ্রেডই অনেক রিকোয়েস্ট শুরু করে দিতে পারে, আর যেটার উত্তর আগে আসে সেটা নিয়ে কাজ করতে পারে। শব্দগুলো:
| শব্দ | মানে |
|---|---|
| কোরুটিন ফাংশন | async def f(): ...। f() কল করলে এখনো কিছুই চলে না; এটা একটা কোরুটিন অবজেক্ট ফেরত দেয়। |
await | "এটা চালাও, শেষ না হওয়া পর্যন্ত আমাকে থামিয়ে রাখো; এর মধ্যে ইভেন্ট লুপ অন্য টাস্ক চালাতে পারে"। শুধু async def-এর ভেতরে লেখা যায়। |
| ইভেন্ট লুপ | সেই শিডিউলার, যে কোনো টাস্ক I/O-র জন্য await করলেই অন্য টাস্কে চলে যায়। asyncio.run(main()) একটা লুপ চালু করে, main() চালায়, তারপর লুপ বন্ধ করে। |
| টাস্ক | একসাথে চলার জন্য শিডিউল করা একটা কোরুটিন: asyncio.create_task(), asyncio.gather() বা একটা TaskGroup। |
httpx.AsyncClient-এ Client-এর সব মেথডই আছে, তবে সেগুলো কোরুটিন। চারটা ধীর রেসপন্স (প্রতিটা আধা সেকেন্ডের) আনুন, প্রথমে একটার পর একটা, তারপর সব একসাথে:
import asyncio
import time
async def fetch_slow(client, seconds):
response = await client.get("/v1/slow", params={"seconds": seconds})
return response.json()["waited_seconds"]
async def compare():
async with httpx.AsyncClient(base_url=BASE_URL, timeout=10.0) as aclient:
start = time.perf_counter()
for _ in range(4):
await fetch_slow(aclient, 0.5)
sequential = time.perf_counter() - start
start = time.perf_counter()
results = await asyncio.gather(*(fetch_slow(aclient, 0.5) for _ in range(4)))
concurrent = time.perf_counter() - start
print("results:", results)
print("sequential took at least 2 s:", sequential >= 2.0)
print("concurrent took under 1 s: ", concurrent < 1.0)
asyncio.run(compare())results: [0.5, 0.5, 0.5, 0.5]
sequential took at least 2 s: True
concurrent took under 1 s: Truegather চারটা কোরুটিনই শুরু করে দিয়েছে, আর প্রতিটা যখন রেসপন্সের জন্য await করছিল, লুপ তখন বাকিগুলো চালিয়েছে; তাই ০.৫ সেকেন্ডের চারটা অপেক্ষা একে অপরের ওপর পড়ে মোট প্রায় ০.৫ সেকেন্ডে শেষ। পুরো কৌশলটা এটুকুই, আর এর সীমাবদ্ধতাগুলোও এখান থেকেই আসে:
- asyncio কাজে লাগে অপেক্ষার (I/O) বেলায়। CPU-র কাজ (টোকেনাইজ করা, NumPy, মডেল ইনফারেন্স) এতে দ্রুত হয় না; তার জন্য থ্রেড বা প্রসেস লাগে।
- কোরুটিনের ভেতরে একটাও ব্লকিং কল (
time.sleep,requests.get, সিঙ্ক ডেটাবেস ড্রাইভার) থাকলে সব টাস্ক জমে যায়।async def-এর ভেতরে শুধু await করা যায় এমন I/O ব্যবহার করুন:await asyncio.sleep(),AsyncClient, async ড্রাইভার। - Jupyter-এ একটা লুপ আগে থেকেই চলছে, তাই সেলে
asyncio.run(compare())-এর বদলে লিখুনawait compare()।
নিরাপদে অনেক রিকোয়েস্ট: সেমাফোর আর ডেডলাইন
"সব একসাথে ছুড়ে দাও" হলো রেট-লিমিটেড বা ব্যান হওয়ার সহজ রাস্তা। ৫০০টা এমবেডিং দরকার আর প্রোভাইডার একসাথে ১০টা রিকোয়েস্ট নেয়? তাহলে কনকারেন্সির একটা সীমা টানুন। asyncio.Semaphore(n) একটা ব্লকের ভেতরে একসাথে সর্বোচ্চ nটা টাস্ক ঢুকতে দেয়। নিচের কোড একসাথে সর্বোচ্চ ৩টা করে মোট ১৩টা রিসোর্স আনে, সর্বোচ্চ কয়টা একসাথে চলেছে তা লিখে রাখে, আর প্রথম ব্যর্থতাতেই ক্র্যাশ না করে ব্যর্থতাগুলো জমা করে:
async def fetch_all(paths, limit=3):
semaphore = asyncio.Semaphore(limit)
in_flight = peak = 0
async def fetch(aclient, path):
nonlocal in_flight, peak
async with semaphore: # ৩টা চলতে থাকলে এখানে অপেক্ষা করে
in_flight += 1
peak = max(peak, in_flight)
try:
response = await aclient.get(path)
response.raise_for_status()
return response.json()
finally:
in_flight -= 1
async with httpx.AsyncClient(base_url=BASE_URL, timeout=10.0) as aclient:
results = await asyncio.gather(*(fetch(aclient, p) for p in paths), return_exceptions=True)
return results, peak
cities = ["Dhaka", "Chattogram", "Sylhet", "Khulna", "Rajshahi"]
paths = [f"/v1/weather?city={c}" for c in cities] + [f"/v1/courses/{i}" for i in (1, 2, 3, 6, 7, 9, 10, 99)]
results, peak = asyncio.run(fetch_all(paths))
ok = [r for r in results if not isinstance(r, Exception)]
failed = [(p, type(r).__name__) for p, r in zip(paths, results) if isinstance(r, Exception)]
print(len(ok), "ok, peak concurrency:", peak)
print("failed:", failed)
print([r.get("city") or r.get("title") for r in ok][:6])12 ok, peak concurrency: 3
failed: [('/v1/courses/99', 'HTTPStatusError')]
['Dhaka', 'Chattogram', 'Sylhet', 'Khulna', 'Rajshahi', 'How AI Works']gatherফলাফল ফেরত দেয় ইনপুটের ক্রমে, শেষ হওয়ার ক্রমে নয়, তাইzip(paths, results)নিরাপদ।return_exceptions=Trueথাকলে একটা 404 পুরো ব্যাচ বাতিল না করে তালিকার একটা মান হয়ে যায়। এটা না থাকলে প্রথম এক্সেপশনটাই ওপরে উঠে আসে, আর বাকি ফলাফল হারান।- Python 3.11+-এ
asyncio.TaskGroup-ও আছে, যা একটা টাস্ক ব্যর্থ হলে বাকিগুলো বাতিল করে দেয়: যখন হয় সব ফলাফল চাই, নয়তো একটাও না, তখন সেটাই ঠিক পছন্দ। - কনকারেন্সির সীমা নয়, বরং রেট লিমিট (প্রতি মিনিটে কয়টা রিকোয়েস্ট) মানতে হলে একটা টোকেন বাকেট যোগ করুন; Redis দিয়ে ক্যাশিং, রেট লিমিটিং ও নির্ভরযোগ্যতা পাতায় সেটা বানানো হবে।
টাইমআউট রক্ষা করে প্রতিটা রিকোয়েস্টকে; ডেডলাইন রক্ষা করে পুরো কাজটাকে। ব্যবহারকারীর সামনের একটা এন্ডপয়েন্ট হয়তো তার সব আপস্ট্রিম কল মিলিয়ে ২ সেকেন্ড সময় দিতে পারে। asyncio.timeout() (Python 3.11+) সময় ফুরোলে যা যা তখনো চলছে সব বাতিল করে দেয়:
async def with_deadline():
async with httpx.AsyncClient(base_url=BASE_URL, timeout=10.0) as aclient:
try:
async with asyncio.timeout(1.0):
await asyncio.gather(fetch_slow(aclient, 0.2), fetch_slow(aclient, 3))
except TimeoutError:
print("deadline of 1 s exceeded: both requests were cancelled")
asyncio.run(with_deadline())deadline of 1 s exceeded: both requests were cancelledস্ট্রিমিং রেসপন্স আর SSE
LLM উত্তর বানায় টোকেন ধরে ধরে, আর ব্যবহারকারীরাও আশা করেন উত্তরটা সেভাবেই ফুটে উঠবে। প্রোভাইডাররা স্ট্রিম করে Server-Sent Events দিয়ে: Content-Type: text/event-stream ওয়ালা একটা রেসপন্স, যার বডিতে data: {...}-এর মতো লাইন, মাঝে মাঝে ফাঁকা লাইন (ফরম্যাটটা আছে ওয়েবহুক ও সার্ভার-সেন্ট ইভেন্ট দিয়ে স্ট্রিমিং পাতায়)। httpx-এ client.stream() বডি আসার আগেই রেসপন্সটা আপনার হাতে দেয়, আর aiter_lines() লাইনগুলো আসামাত্র একটা একটা করে দেয়:
import json
async def stream_chat(question):
async with httpx.AsyncClient(base_url=BASE_URL, timeout=httpx.Timeout(10.0, read=30.0)) as aclient:
login = await aclient.post("/v1/token", json={"username": "nadia", "password": "s3cret-pass"})
token = login.json()["access_token"]
payload = {"messages": [{"role": "user", "content": question}], "stream": True}
headers = {"Authorization": f"Bearer {token}"}
async with aclient.stream("POST", "/v1/chat", json=payload, headers=headers) as response:
response.raise_for_status()
print(response.headers["content-type"])
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue # ফাঁকা বিভাজক লাইন, কমেন্ট, keep-alive
event = json.loads(line.removeprefix("data: "))
if event.get("done"):
print("\nusage:", event["usage"])
else:
print(event["delta"], end="", flush=True)
asyncio.run(stream_chat("What is an embedding?"))text/event-stream; charset=utf-8
This is a practice answer about: What is an embedding. A real model would say more.
usage: {'prompt_tokens': 4, 'completion_tokens': 16, 'total_tokens': 20}async with ... stream(...)ব্লকটা জরুরি: আপনি মাঝপথে পড়া থামালেও (ব্যবহারকারী "Stop" চেপেছেন) কানেকশনটা যেন পুলে ফিরে যায়, সেটা এটাই নিশ্চিত করে।- স্ট্রিমের জন্য
readটাইমআউট উদারভাবে দিন: এটা দুই টুকরোর মাঝের ফাঁকের জন্য, মানে কার্যত "মডেল সর্বোচ্চ কতক্ষণ চুপ থাকতে পারে"। - আসল প্রোভাইডাররা আরও সমৃদ্ধ ইভেন্ট পাঠায় (
content_block_deltaবাresponse.output_text.delta-এর মতো টাইপসহevent:লাইন), কিন্তু লুপ একই: লাইন পড়ুন,data:পার্স করুন, টাইপ দেখে সিদ্ধান্ত নিন। সিঙ্ক সংস্করণেwith client.stream(...)আরiter_lines()।
নেটওয়ার্ক ছাড়াই টেস্ট: respx আর MockTransport
যে টেস্ট আসল API কল করে, সেটা ধীর, মাঝে মাঝে কারণ ছাড়াই ফেল করে, টাকা খরচ করায় (LLM-এর বেলায় সত্যিই) আর যে ব্যর্থতাগুলো সবচেয়ে বেশি টেস্ট করা দরকার সেগুলো সহজে ঘটাতে পারে না: একটা 503, একটা টাইমআউট, ভাঙা পেলোড। তার বদলে HTTP স্তরটাকে মক (mock) করুন। respx httpx-এর রিকোয়েস্ট মাঝপথে ধরে আপনার বানানো রুট থেকে উত্তর দেয়; সাথে একটা pytest ফিক্সচারও দেয়, respx_mock:
# file: test_bitbyte_client.py
import httpx
import pytest
from bitbyte_client import BitByteClient, NotFound, UpstreamError
BASE = "https://api.example.test"
COURSE = {"id": 8, "title": "RAG Systems", "level": "advanced", "hours": 10, "rating": 4.9}
@pytest.fixture
def bitbyte():
with BitByteClient(BASE) as client:
yield client
def test_get_course_returns_a_model(bitbyte, respx_mock):
route = respx_mock.get(f"{BASE}/v1/courses/8").respond(200, json=COURSE)
course = bitbyte.get_course(8)
assert course.title == "RAG Systems"
assert route.call_count == 1
def test_404_becomes_not_found(bitbyte, respx_mock):
respx_mock.get(f"{BASE}/v1/courses/99").respond(404, json={"detail": "Course 99 not found"})
with pytest.raises(NotFound, match="Course 99"):
bitbyte.get_course(99)
def test_timeout_becomes_upstream_error(bitbyte, respx_mock):
respx_mock.get(f"{BASE}/v1/weather").mock(side_effect=httpx.ReadTimeout("slow"))
with pytest.raises(UpstreamError, match="ReadTimeout"):
bitbyte.weather("Dhaka")
def test_changed_payload_is_caught(bitbyte, respx_mock):
broken = {**COURSE, "rating": "excellent"} # আপস্ট্রিম API একটা টাইপ বদলে ফেলেছে
respx_mock.get(f"{BASE}/v1/courses/8").respond(200, json=broken)
with pytest.raises(UpstreamError, match="unexpected Course payload"):
bitbyte.get_course(8)
def test_query_parameters_are_sent(bitbyte, respx_mock):
route = respx_mock.get(f"{BASE}/v1/courses", params={"level": "advanced"}).respond(
200, json={"page": 1, "total": 1, "next_page": None, "items": [COURSE]}
)
assert [c.id for c in bitbyte.iter_courses(level="advanced")] == [8]
assert route.calls.last.request.url.params["per_page"] == "20"
def test_mock_transport_without_respx():
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["user-agent"] == "bitbyte-client/0.1"
return httpx.Response(200, json={"city": "Dhaka", "temp_c": 30, "humidity": 70, "condition": "Sunny"})
with BitByteClient(BASE, transport=httpx.MockTransport(handler)) as client:
assert client.weather("Dhaka").temp_c == 30.0uv run pytest -q...... [100%]
6 passed in 0.24sখেয়াল করুন, টেস্টগুলো একটা কাল্পনিক বেস URL ব্যবহার করছে যার কোনো অস্তিত্ব নেই: কোনো রুট বাদ পড়লে respx রিকোয়েস্টটাকে ইন্টারনেটে যেতে না দিয়ে এরর তোলে। শেষ টেস্টটা ব্যবহার করে httpx.MockTransport, যা httpx-এ (আর httpx2-তে) বিল্ট-ইন: একটা সাধারণ ফাংশন প্রতিটা রিকোয়েস্ট নেয় আর একটা রেসপন্স ফেরত দেয়। যে লাইব্রেরিই আপনাকে ট্রান্সপোর্ট দিতে দেয়, তার সাথেই এটা চলে, আর সেজন্যই ক্লায়েন্টটা একটা ট্রান্সপোর্ট নেয়।
async কোড টেস্ট করা
AsyncClient-এর বেলাতেও respx একইভাবে কাজ করে। pytest-asyncio দিয়ে টেস্টটা মার্ক করুন আর সেটাকে async def বানান। side_effect-এ একটা তালিকা দিলে প্রতি কলে একটা করে রেসপন্স ফেরত আসে, আর রিট্রাই লুপ এভাবেই টেস্ট করা হয়:
# file: test_async_retry.py
import httpx
import pytest
async def get_with_one_retry(client: httpx.AsyncClient, url: str) -> httpx.Response:
response = await client.get(url)
if response.status_code == 503:
response = await client.get(url)
return response
@pytest.mark.asyncio
async def test_retries_once_after_503(respx_mock):
route = respx_mock.get("https://api.example.test/v1/flaky").mock(
side_effect=[httpx.Response(503), httpx.Response(200, json={"ok": True})]
)
async with httpx.AsyncClient() as client:
response = await get_with_one_retry(client, "https://api.example.test/v1/flaky")
assert response.json() == {"ok": True}
assert route.call_count == 2uv run pytest -q test_async_retry.py. [100%]
1 passed in 0.02sসিক্রেট ফাঁস না করে লগিং
রাত ৩টায় যখন কোনো LLM কল ফেল করে, তখন লগই আপনার একমাত্র ভরসা, তাই প্রতিটা রিকোয়েস্টের মেথড, পাথ, স্ট্যাটাস আর সময় লগে চাই। কিন্তু API কি, বেয়ারার টোকেন, কুকি বা ব্যবহারকারীর ব্যক্তিগত প্রম্পট লগ করা চলবে না: লগ কপি হয়ে লগ সার্ভিসে যায়, টিকিটে শেয়ার হয়, মাসের পর মাস রাখা হয়। httpx-এর ইভেন্ট হুক প্রতিটা রিকোয়েস্ট আর রেসপন্সে আপনার ফাংশন চালায়, তাই লগিং কোড একবারই লিখতে হয়:
SENSITIVE_HEADERS = {"authorization", "x-api-key", "cookie", "set-cookie", "proxy-authorization"}
def mask(value):
"""দুটো কি আলাদা করে চেনার মতো অংশটুকুই রাখে।"""
return "***" + value[-4:] if len(value) > 8 else "***"
def safe_headers(headers):
return {k: (mask(v) if k.lower() in SENSITIVE_HEADERS else v) for k, v in headers.items()
if k.lower() in SENSITIVE_HEADERS | {"content-type", "x-request-id"}}
def log_request(request):
print("->", request.method, request.url.path, safe_headers(request.headers))
def log_response(response):
print("<-", response.status_code, response.request.method, response.request.url.path)
with httpx.Client(base_url=BASE_URL, event_hooks={"request": [log_request], "response": [log_response]}) as logged:
token = logged.post("/v1/token", json={"username": "nadia", "password": "s3cret-pass"}).json()["access_token"]
logged.get("/v1/me", headers={"Authorization": f"Bearer {token}", "X-Request-ID": "req-42"})
logged.post("/v1/reviews", headers={"X-API-Key": "practice-key-123"},
json={"course_id": 8, "rating": 5, "comment": "Clear and practical"})-> POST /v1/token {'content-type': 'application/json'}
<- 200 POST /v1/token
-> GET /v1/me {'authorization': '***-001', 'x-request-id': 'req-42'}
<- 200 GET /v1/me
-> POST /v1/reviews {'x-api-key': '***-123', 'content-type': 'application/json'}
<- 201 POST /v1/reviews- পাসওয়ার্ড গেছে JSON বডিতে, যা হুক কখনো প্রিন্ট করে না। বডি লগ করুন কেবল ব্যক্তিগত ডেটা সচেতনভাবে সরানোর পর; LLM অ্যাপে প্রম্পট নয়, লগ করুন টোকেনের সংখ্যা আর প্রম্পটের একটা id বা হ্যাশ।
- httpx নিজেও
INFOলেভেলেHTTP Request: GET https://... "HTTP/1.1 200 OK"লগ করে, কোয়েরি স্ট্রিংসহ পুরো URL। URL-এ কখনো কি না রাখার (?api_key=...) এটা আরেকটা কারণ: সেটা চলে যায় আপনার লগে, সার্ভারের লগে আর মাঝের প্রতিটা প্রক্সিতে। AsyncClient-এর হুকগুলো অবশ্যইasync defফাংশন হতে হবে।
ক্লায়েন্টকে লাইব্রেরি হিসেবে প্যাকেজ করা
তিনটা সার্ভিস একই API কল করতে শুরু করলে bitbyte_client.py কপি-পেস্ট করা আর চলে না। এটাকে নিজস্ব ভার্সনসহ একটা প্যাকেজ বানান। uv init --lib স্ট্যান্ডার্ড src/ লেআউট বানিয়ে দেয়:
cd ~/projects
uv init -q --lib --vcs none bitbyte-client
cd bitbyte-client
cp ../api-clients/bitbyte_client.py src/bitbyte_client/client.py
uv add -q httpx pydantic
find . -path ./.venv -prune -o -type f -print | sort./.python-version
./README.md
./pyproject.toml
./src/bitbyte_client/__init__.py
./src/bitbyte_client/client.py
./src/bitbyte_client/py.typed
./uv.lockপ্যাকেজের __init__.py থেকে পাবলিক নামগুলো এক্সপোজ করুন, যাতে ব্যবহারকারীরা লিখতে পারেন from bitbyte_client import BitByteClient:
# file: src/bitbyte_client/__init__.py
"""BitByte Practice API-র টাইপ করা ক্লায়েন্ট।"""
from bitbyte_client.client import (
BitByteClient,
BitByteError,
Course,
CoursePage,
NotFound,
UpstreamError,
Weather,
)
__all__ = ["BitByteClient", "BitByteError", "Course", "CoursePage", "NotFound", "UpstreamError", "Weather"]
__version__ = "0.1.0"uv build -q
ls dist
uv run python -c "import bitbyte_client; print(bitbyte_client.__version__, bitbyte_client.BitByteClient.__name__)"bitbyte_client-0.1.0-py3-none-any.whl
bitbyte_client-0.1.0.tar.gz
0.1.0 BitByteClientআরেকটা প্রজেক্ট এখন ডেভেলপমেন্টের সময় পাথ দিয়ে এর ওপর নির্ভর করতে পারে (uv add ../bitbyte-client, বা pip install -e ../bitbyte-client), Git URL দিয়েও (uv add git+https://github.com/you/bitbyte-client), আর পাবলিশ করার পর একটা প্রাইভেট প্যাকেজ ইনডেক্স থেকেও। কয়েকটা নিয়ম মানলে একটা ক্লায়েন্ট লাইব্রেরির ওপর নির্ভর করা আরামদায়ক হয়:
- কলারকে কনফিগার করতে দিন: বেস URL, টাইমআউট, আর ঐচ্ছিক একটা ট্রান্সপোর্ট বা ক্লায়েন্ট। লাইব্রেরির ভেতরে কখনো গ্লোবাল সেটিং পড়বেন না বা
httpx2.alias_httpx()কল করবেন না। - নিজের এক্সেপশন তুলুন (একটা বেস ক্লাসসহ), আর আসলটাকে
from excদিয়ে জুড়ে দিন, যাতে মূল কারণটাও দেখা যায়। logging.getLogger(__name__)দিয়ে লগ করুন, কিন্তু হ্যান্ডলার কখনো কনফিগার করবেন না: লগ কোথায় যাবে তা ঠিক করবে অ্যাপ্লিকেশন।- টাইপ দিন:
uv init --libআগেইpy.typedমার্কার যোগ করেছে, তাই এডিটর আর টাইপ চেকার আপনার অ্যানোটেশন ব্যবহার করবে। - ভার্সন দিন (সেমান্টিক ভার্সনিং) আর একটা চেঞ্জলগ রাখুন: একটা ফিল্ডের নাম বদলানো মানে যেসব সার্ভিস এটা ব্যবহার করে তাদের সবার জন্য ব্রেকিং চেঞ্জ।
AI-এর কাজে এগুলো কোথায় আসে
- এমবেডিং পাইপলাইন হাজার হাজার চাঙ্ক একটা এমবেডিং API-তে পাঠায়
AsyncClientদিয়ে, প্রোভাইডারের কনকারেন্সি সীমা অনুযায়ী সেট করা সেমাফোরসহ, আর 429-এRetry-Afterমেনে রিট্রাই করে। - এজেন্ট আর টুল কলিং
gatherবাTaskGroupদিয়ে কয়েকটা টুল কল সমান্তরালে চালায়, একটা ডেডলাইনের অধীনে, যাতে এজেন্টের একটা পালা ঝুলে না থাকে। - চ্যাট ব্যাকএন্ড
client.stream()দিয়ে প্রোভাইডারের টোকেন স্ট্রিম করে ব্রাউজারে আবার পাঠায় (এটা বানাবেন স্ট্রিমিংসহ LLM ব্যাকএন্ড বানানো পাতায়)। - প্রোভাইডারের SDK (OpenAI, Anthropic) হুবহু এই ডিজাইন: httpx2-এর ওপর টাইপ করা একটা ক্লায়েন্ট, ভেতরে রিট্রাই, টাইমআউট আর স্ট্রিমিং। নিচের স্তরটা জানা থাকলে এদের
timeoutআরmax_retriesঅপশন বুঝেশুনে সেট করতে আর ডিবাগ করতে পারবেন।
সাধারণ ভুল
- প্রতি রিকোয়েস্টে নতুন ক্লায়েন্ট। লুপের ভেতরে
httpx.get(), বা এন্ডপয়েন্টের ভেতরেasync with AsyncClient()প্রতিবার হ্যান্ডশেকের দাম দেয়, আর চাপের মুখে সকেট ফুরিয়ে ফেলতে পারে। চালু হওয়ার সময় একটা ক্লায়েন্ট বানিয়ে সেটাই শেয়ার করুন। async def-এর ভেতরে ব্লকিং কল। কোরুটিনেrequests.get()বাtime.sleep()লুপের বাকি সব টাস্ক আটকে দেয়।AsyncClientআরasyncio.sleepব্যবহার করুন, অথবা ব্লকিং কাজasyncio.to_thread()দিয়ে একটা থ্রেডে পাঠান।- সবকিছুতে রিট্রাই। 400-এ রিট্রাই মানে একই এরর আবার; ইডেমপোটেন্ট নয় এমন POST রিট্রাই করলে কাজটা দুবার হতে পারে; ব্যাকঅফ ছাড়া রিট্রাই প্রোভাইডারের এক মিনিটের ঝামেলাকে পুরো আউটেজে পরিণত করে। শুধু সাময়িক ব্যর্থতায় রিট্রাই করুন, জিটার, সর্বোচ্চ সীমা আর বাজেটসহ।
- সীমাহীন
gather।gather(*[call(x) for x in ten_thousand_items])পুল যত দেয় তত রিকোয়েস্ট খুলে বসে, আর আপনাকে রেট-লিমিটেড করায়। সেমাফোর দিয়ে সীমা টানুন, ব্যাচে ভাগ করে চালান। - URL আর লগে সিক্রেট। কোয়েরি স্ট্রিংয়ের কি আর লগ করা হেডার ফাঁস হয়। কি পাঠান হেডারে, হুকে সংবেদনশীল হেডার ঢেকে দিন, আর কাঁচা প্রম্পট বা টোকেন কখনো লগ করবেন না।
নিজে চেষ্টা করুন
- সহজ: একটাই
httpx.Clientদিয়ে (বেস URL সেট করে) পাঁচটা শহরের আবহাওয়া আনুন, আর সবচেয়ে গরম শহরটা তার তাপমাত্রাসহ প্রিন্ট করুন। - মাঝারি: একটা async ফাংশন
course_titles(ids, limit)লিখুন, যা দেওয়া কোর্স id-গুলো একসাথে আনে (একসাথে সর্বোচ্চlimitটা রিকোয়েস্ট), আর{id: title}ডিকশনারি ফেরত দেয়; যেসব id-তে 404 আসে সেগুলো বাদ। ১ থেকে ১২ পর্যন্ত id-র জন্য ৪ সীমা দিয়ে চালান। - কঠিন: এই পাতার
request_with_retries-এর জন্য একটা pytest টেস্ট লিখুন (respx দিয়ে):/v1/flaky-এ প্রথম দুটো কল 503 আর তৃতীয়টা 200 ফেরত দেবে, আর টেস্ট যাচাই করবে যে শেষ স্ট্যাটাস 200 এবং রুটটা তিনবার কল হয়েছে। টেস্ট দ্রুত রাখতেbase_delay=0দিন।
উত্তর
# 1. সহজ
with httpx.Client(base_url=BASE_URL, timeout=5.0) as c:
reports = [c.get("/v1/weather", params={"city": city}).json() for city in cities]
hottest = max(reports, key=lambda r: r["temp_c"])
print(hottest["city"], hottest["temp_c"])
# 2. মাঝারি
async def course_titles(ids, limit):
semaphore = asyncio.Semaphore(limit)
async def one(aclient, course_id):
async with semaphore:
response = await aclient.get(f"/v1/courses/{course_id}")
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
async with httpx.AsyncClient(base_url=BASE_URL, timeout=10.0) as aclient:
courses = await asyncio.gather(*(one(aclient, i) for i in ids))
return {c["id"]: c["title"] for c in courses if c is not None}
titles = asyncio.run(course_titles(range(1, 13), limit=4))
print(len(titles), titles[10])Rajshahi 33.6
10 LLMOpsকঠিন অনুশীলনে respx নেটওয়ার্কের জায়গা নেয় আর base_delay=0 অপেক্ষা সরিয়ে দেয়। প্রথম প্রজেক্টে ফিরে যান, যেখানে retrying.py আছে:
cd ~/projects/api-clients# file: test_retrying.py
import httpx
from retrying import request_with_retries
def test_flaky_endpoint_succeeds_on_third_attempt(respx_mock):
route = respx_mock.get("https://api.example.test/v1/flaky").mock(
side_effect=[httpx.Response(503), httpx.Response(503), httpx.Response(200, json={"ok": True})]
)
with httpx.Client(base_url="https://api.example.test") as client:
response = request_with_retries(client, "GET", "/v1/flaky", base_delay=0, log=lambda msg: None)
assert response.status_code == 200
assert route.call_count == 3uv run pytest -q test_retrying.py. [100%]
1 passed in 0.02sসারসংক্ষেপ
- httpx দেয়
requests-এর API, সাথে async, স্ট্রিমিং আর HTTP/2; ডিফল্ট টাইমআউট ৫ সেকেন্ড, আর রিডাইরেক্ট নিজে থেকে ফলো করে না। httpx2 একই API-সহ এর রক্ষণাবেক্ষণ করা ফর্ক (import httpx2 as httpx), যা OpenAI আর Anthropic-এর SDK ইতিমধ্যে ব্যবহার করছে। - কানেকশন পুলিংয়ের জন্য প্রতিটা আপস্ট্রিম সার্ভিসে একটা করে
Client/AsyncClientশেয়ার করুন;base_url, হেডার, লিমিট আর স্পষ্ট connect/read টাইমআউট সেট করুন। - রিট্রাই শুধু সাময়িক ব্যর্থতায় (কানেকশন এরর, 429, 502–504), এক্সপোনেনশিয়াল ব্যাকঅফ, জিটার,
Retry-Afterআর বাজেটসহ; POST কখনো চোখ বুজে রিট্রাই নয়। - asyncio অপেক্ষাগুলোকে একে অপরের ওপর ফেলে: সেমাফোরসহ
gatherকনকারেন্সি সীমিত রাখে,return_exceptions=TrueবাTaskGroupঠিক করে ব্যর্থতা কীভাবে সামলাবেন,asyncio.timeout()ডেডলাইন দেয়;client.stream()+aiter_lines()দিয়ে SSE পড়া হয়। - প্রতিটা API-কে টাইপ করা একটা ক্লায়েন্টে মুড়ে দিন, যা Pydantic দিয়ে রেসপন্স যাচাই করে আর নিজের এক্সেপশন তোলে; respx বা
MockTransportদিয়ে টেস্ট করুন, সিক্রেট ঢেকে দেওয়া হুক দিয়ে লগ করুন, আরuv init --libওuv buildদিয়ে প্যাকেজ করুন।
এরপর: FastAPI আর্কিটেকচার: রাউটার, স্কিমা ও এরর হ্যান্ডলিং পাতায় আপনি পাশ বদলাবেন: API কল করা থেকে নিজের একটা FastAPI সার্ভিস এমনভাবে সাজানো, যাতে সেটা বড় হতে পারে।