অধ্যায় 2 · API ডিজাইন ও API ক্লায়েন্ট
ওয়েবহুক ও সার্ভার-সেন্ট ইভেন্ট দিয়ে স্ট্রিমিং
- পৃষ্ঠা 6 / 22
- 24 মিনিট পড়া
আগের পাতা শেষ হয়েছিল এমন একটা ক্লায়েন্ট দিয়ে, যে বারবার একটা জব পোল করে, আর এমন একজন ব্যবহারকারী দিয়ে, যে পুরো উত্তরের জন্য বসে থাকে। দুটো প্যাটার্ন এই অপেক্ষা দূর করে। ওয়েবহুক (webhook) পোলিংকে উল্টে দেয়: আপনি অন্য সার্ভিসকে একটা URL দিয়ে রাখেন, আর কিছু ঘটলে (পেমেন্ট সফল হলো, ডকুমেন্ট প্রসেস হয়ে গেল, বিল্ড ফেল করল) সে আপনাকে কল করে। স্ট্রিমিং (streaming) সার্ভারকে দেয় রেসপন্স তৈরি হতে থাকা অবস্থাতেই টুকরো টুকরো করে পাঠানোর ক্ষমতা; ChatGPT-র মতো ইন্টারফেসে স্পিনারের বদলে একটা একটা করে শব্দ ফুটে ওঠে ঠিক এই কারণে। সময়সাপেক্ষ ঘটনার খবর পাওয়ার জন্য একটা AI প্রোডাক্টের এই দুটোই প্রধান উপায়, আর দুটোরই ধারালো দিক আছে: ওয়েবহুক URL হলো ইন্টারনেটের একটা খোলা দরজা যাতে যে কেউ টোকা দিতে পারে, আর স্ট্রিম হলো এমন কানেকশন যা মাঝপথে ভেঙে যেতে পারে।
এই পাতায় আপনি FastAPI-তে ওয়েবহুকের দুই প্রান্তই বানাবেন: সাইন করে ও রিট্রাই করে পাঠানো একটা সেন্ডার, আর যাচাই করে, রিপ্লে ঠেকিয়ে ও ডুপ্লিকেট এড়িয়ে চলা একটা রিসিভার। তারপর FastAPI-র বিল্ট-ইন Server-Sent Events সাপোর্ট দিয়ে স্ট্রিম করবেন, আর BitByte Practice API-র /v1/chat থেকে স্ট্রিম পড়বেন। practice API অন্য টার্মিনালে না চললে চালু করুন (fastapi run practice_api.py --port 8800)।
যা শিখবেন
- ওয়েবহুক ডেলিভারি কীভাবে কাজ করে, আর টাইমস্ট্যাম্প ও বডির ওপর HMAC-SHA256 দিয়ে তা কীভাবে সাইন করতে হয়।
- সিগনেচার কেন constant-time-এ তুলনা করবেন, টাইমস্ট্যাম্প-উইন্ডো দিয়ে রিপ্লে কীভাবে ঠেকাবেন, আর ইভেন্ট id ধরে ডুপ্লিকেট কীভাবে বাদ দেবেন।
- সেন্ডার ব্যর্থ ডেলিভারি কীভাবে রিট্রাই করে, আর রিসিভারকে কেন দ্রুত উত্তর দিতে এবং idempotent হতে হয়।
- স্ট্রিমিং রেসপন্স ও Server-Sent Events (SSE)-এর ওয়্যার ফরম্যাট কেমন, এবং
fastapi.sseথেকেEventSourceResponseওServerSentEventব্যবহার। requestsআরhttpxদিয়ে SSE স্ট্রিম পড়া,Last-Event-IDদিয়ে আবার শুরু করা, এবং এরর ও ডিসকানেক্ট সামলানো।
প্রজেক্ট সেটআপ
mkdir -p ~/projects/webhooks-demo
cd ~/projects/webhooks-demo
uv init --bare
uv add -q "fastapi[standard]" requests httpx
# uv-এর বদলে pip দিয়ে:
# python -m venv .venv && source .venv/bin/activate
# pip install -q "fastapi[standard]" requests httpxInitialized project `webhooks-demo`আগের মতোই Python অংশগুলো নোটবুকের সেলের মতো একটার পর একটা চলবে: এই ফোল্ডার থেকে uv run python-এ চালান।
ওয়েবহুক: অন্য সার্ভিস আপনাকে কল করে
পোলিংয়ে আপনি কয়েক সেকেন্ড পরপর জিজ্ঞেস করেন "হয়েছে কি?", আর বেশিরভাগ উত্তরই "না"। ওয়েবহুকে আপনি একবার একটা URL নিবন্ধন করেন, আর কোনো ইভেন্ট (event) ঘটলে প্রোভাইডার সেখানে একটা HTTP POST পাঠায়। Stripe, GitHub, Slack, Twilio আর বেশিরভাগ পেমেন্ট, মেসেজিং ও AI-প্ল্যাটফর্ম সার্ভিস এভাবেই কাজ করে; একটা ফাইন-টিউনিং প্রোভাইডার মডেল তৈরি হলে আপনাকে জানাতে পারে।
| পোলিং | ওয়েবহুক | |
|---|---|---|
| কল কে শুরু করে | আপনি | প্রোভাইডার |
| বিলম্ব | সর্বোচ্চ একটা পোলিং ব্যবধান | প্রায় নেই |
| অপচয় হওয়া রিকোয়েস্ট | অনেক | নেই |
| আপনাকে কী চালাতে হবে | শুধু একটা ক্লায়েন্ট | সবসময় চালু থাকা একটা পাবলিক HTTPS এন্ডপয়েন্ট |
| প্রধান ঝুঁকি | লোড আর দেরি | জাল, পুনরাবৃত্ত, দেরিতে আসা ও হারানো ইভেন্ট |
ওই শেষ সারিটাই এই পাতার মূল কথা। ওয়েবহুক এন্ডপয়েন্ট ইন্টারনেটের ওপর একটা অ-যাচাইকৃত URL, তাই যে কেউ সেখানে জাল "পেমেন্ট সফল" পাঠাতে পারে। আর ডেলিভারিও কখনও নিখুঁত নয়: কানেকশন মরে গেলে প্রোভাইডার জানতে পারে না আপনার সার্ভার ইভেন্টটা পেয়েছে কি না, তাই সে আবার পাঠায়। তাই ভালো ওয়েবহুক ব্যবস্থার চারটা অংশ থাকে:
- খাঁটিত্ব (authenticity): প্রতিটা ডেলিভারিতে এমন একটা সিগনেচার থাকে যা শুধু সেন্ডার আর রিসিভারই হিসাব করতে পারে।
- সতেজতা (freshness): সিগনেচার একটা টাইমস্ট্যাম্পকেও ঢেকে রাখে, ফলে ধরে রাখা একটা ডেলিভারি এক ঘণ্টা পরে আবার চালানো যায় না।
- রিট্রাই:
2xxনা পাওয়া পর্যন্ত সেন্ডার ব্যাকঅফসহ ডেলিভারি পুনরাবৃত্তি করে। - ডিডুপ্লিকেশন: প্রতিটা ইভেন্টের একটা স্থির id থাকে, আর রিসিভার প্রতিটা id একবারই প্রসেস করে।
সাইন করা: টাইমস্ট্যাম্প ও বডির ওপর HMAC-SHA256
সেন্ডার ও রিসিভার একটা সিক্রেট ভাগ করে নেয় (এই টিউটোরিয়ালে whsec_demo; বাস্তবে একটা লম্বা র্যান্ডম স্ট্রিং, একবার আদান-প্রদান করে এনভায়রনমেন্ট ভেরিয়েবলে রাখা)। HMAC সিক্রেটকে মেসেজের হ্যাশের সঙ্গে মিশিয়ে দেয়, ফলে সিক্রেট ছাড়া ফলাফল বানানো বা যাচাই করা যায় না। সাইন করা মেসেজ হলো টাইমস্ট্যাম্প, একটা ডট, আর বডির কাঁচা বাইট। দুটো সার্ভারই ইম্পোর্ট করবে এমন একটা ছোট মডিউলে এটা রাখুন:
# file: signing.py
import hashlib
import hmac
class SignatureError(Exception):
def __init__(self, message: str, status: int = 401):
super().__init__(message)
self.status = status
def sign(secret: str, timestamp: int, body: bytes) -> str:
message = str(timestamp).encode() + b"." + body
return "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
def verify_signature(secret: str, timestamp: str | None, signature: str | None,
body: bytes, now: int, tolerance: int = 300) -> None:
if not timestamp or not signature:
raise SignatureError("Missing signature headers", 400)
try:
sent_at = int(timestamp)
except ValueError:
raise SignatureError("Invalid timestamp", 400)
if abs(now - sent_at) > tolerance:
raise SignatureError("Timestamp outside the allowed window", 400)
expected = sign(secret, sent_at, body)
if not hmac.compare_digest(expected.encode(), signature.encode()):
raise SignatureError("Signature does not match", 401)নির্দিষ্ট একটা টাইমস্ট্যাম্প দিয়ে চেষ্টা করুন, যাতে প্রতিটা সংখ্যা বারবার একই আসে। চেক ফাংশন now আর্গুমেন্ট হিসেবে নেয় একই কারণে: ভেতরে ঘড়ি পড়ে এমন কোড টেস্ট করা কঠিন।
import json
from signing import SignatureError, sign, verify_signature
SECRET = "whsec_demo"
TIMESTAMP = 1767225600
body = b'{"id":"evt_0001","type":"order.paid"}'
signature = sign(SECRET, TIMESTAMP, body)
print(signature)
def check(label, secret=SECRET, ts=TIMESTAMP, sig=signature, payload=body, now=TIMESTAMP + 10):
try:
verify_signature(secret, str(ts), sig, payload, now)
print(f"{label}: accepted")
except SignatureError as exc:
print(f"{label}: rejected {exc.status} ({exc})")
check("untouched")
check("body changed", payload=b'{"id":"evt_0001","type":"order.refunded"}')
check("wrong secret", secret="whsec_other")
check("an hour later", now=TIMESTAMP + 3600)
check("new timestamp, old signature", ts=TIMESTAMP + 3600, now=TIMESTAMP + 3600)
check("re-serialised JSON", payload=json.dumps(json.loads(body)).encode())
check("no headers", ts="", sig="")sha256=ecb28056ad4c9ecb70a48f76e50c5fb959fea17833ae3ce76e4fc58efc825916
untouched: accepted
body changed: rejected 401 (Signature does not match)
wrong secret: rejected 401 (Signature does not match)
an hour later: rejected 400 (Timestamp outside the allowed window)
new timestamp, old signature: rejected 401 (Signature does not match)
re-serialised JSON: rejected 401 (Signature does not match)
no headers: rejected 400 (Missing signature headers)প্রতিটা প্রত্যাখ্যান একটা নিয়ম শেখায়:
- Body changed: পে-লোডে যেকোনো পরিবর্তন সিগনেচার ভেঙে দেয়, তাই রিসিভার যা পার্স করে তা বিশ্বাস করতে পারে।
- An hour later: একটা বৈধ ডেলিভারি কেবল একটা সময়-জানালার (এখানে ৫ মিনিট) মধ্যে বৈধ। সিগনেচারের ভেতরে টাইমস্ট্যাম্প না থাকলে একটা আসল মেসেজ রেকর্ড করে রাখা আক্রমণকারী সেটা চিরকাল আবার পাঠাতে পারত। টাইমস্ট্যাম্প এগিয়ে দিয়েও লাভ হয় না (new timestamp, old signature), কারণ টাইমস্ট্যাম্প সাইন করা অংশেরই অংশ।
- Re-serialised JSON: একই ডেটা ভিন্ন স্পেসে মানে ভিন্ন বাইট, আর ভিন্ন বাইট মানে ভিন্ন সিগনেচার। তাই রিসিভারকে কাঁচা রিকোয়েস্ট বডি ঠিক যেমন এসেছে তেমনই যাচাই করতে হয়, Pydantic মডেলে পার্স করে আবার ডাম্প করার আগে।
- Constant-time তুলনা: ইনপুট যা-ই হোক
hmac.compare_digestএকই সময় নেয়। সাধারণ==প্রথম অমিল অক্ষরেই থেমে যায়, আর অনেক রেসপন্সের সময় মাপতে পারা আক্রমণকারী ধীরে ধীরে একেকটা অক্ষর করে সঠিক সিগনেচার জেনে ফেলতে পারে (timing attack)। ফাংশনটাbytes(বা ASCIIstr) চায়, তাই কোডে দুই পক্ষকেই এনকোড করা হয়েছে।
রিসিভার
রিসিভার পাঁচটা কাজ করে, এই ক্রমে: কাঁচা বডি পড়ে, সিগনেচার ও টাইমস্ট্যাম্প যাচাই করে, আগে দেখা ইভেন্ট বাদ দেয়, আসল কাজ ব্যাকগ্রাউন্ড টাস্কে দেয়, আর দ্রুত একটা 2xx দিয়ে উত্তর দেয়। দ্রুত উত্তর দিতে হয় (প্রোভাইডাররা কয়েক সেকেন্ড পরে টাইমআউট করে আবার পাঠায়), তাই ধীর কাজ কখনও রিকোয়েস্টের ভেতরে হয় না। /admin/fail এন্ডপয়েন্টটা শুধু এই পাঠের জন্য বিভ্রাট নকল করতে আছে; আসল অ্যাডমিন এন্ডপয়েন্টে অথেন্টিকেশন লাগে।
# file: receiver.py
import json
import os
import time
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from pydantic import BaseModel
from signing import SignatureError, verify_signature
app = FastAPI(title="Webhook receiver")
SECRET = os.environ["WEBHOOK_SECRET"]
SEEN: set[str] = set()
PROCESSED: list[str] = []
OUTAGE = {"remaining": 0}
class Outage(BaseModel):
count: int
def handle(event: dict) -> None:
PROCESSED.append(event["id"])
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/webhooks/orders")
async def receive(request: Request, background: BackgroundTasks):
body = await request.body()
try:
verify_signature(
SECRET,
request.headers.get("x-webhook-timestamp"),
request.headers.get("x-webhook-signature"),
body,
now=int(time.time()),
)
except SignatureError as exc:
raise HTTPException(exc.status, str(exc))
if OUTAGE["remaining"] > 0:
OUTAGE["remaining"] -= 1
raise HTTPException(503, "Temporarily unavailable")
event = json.loads(body)
if event["id"] in SEEN:
return {"status": "duplicate"}
SEEN.add(event["id"])
background.add_task(handle, event)
return {"status": "accepted"}
@app.post("/admin/fail")
def fail_next(outage: Outage):
OUTAGE["remaining"] = outage.count
return OUTAGE
@app.get("/processed")
def processed():
return {"processed": PROCESSED}দুটো খুঁটিনাটি আরেকবার দেখার মতো। আগে-দেখা ইভেন্ট চেকে বডির id ব্যবহার করা হয়েছে, যা নিরাপদ কেবল এই কারণে যে সিগনেচার আগেই প্রমাণ করেছে বডিটা খাঁটি। আর "seen" এখানে মেমরিতে রাখা; প্রোডাকশন কোড ইভেন্ট id রাখে unique constraint-সহ একটা ডেটাবেস টেবিলে (বা মেয়াদসহ Redis SET NX-এ), যাতে ডিডুপ্লিকেশন রিস্টার্টের পরেও টেকে আর একাধিক ওয়ার্কারে কাজ করে।
সেন্ডার
সেন্ডার হলো যে সার্ভিসের কাছে ইভেন্ট আছে। এর /orders/{id}/pay এন্ডপয়েন্ট একটা order.paid ইভেন্ট বানায় এবং নিজের কলারকে উত্তর দেওয়ার পরে ব্যাকগ্রাউন্ড টাস্কে প্রতিটা সাবস্ক্রাইব করা URL-এ তা পৌঁছে দেয়। ডেলিভারি বডি সাইন করে, পোস্ট করে, আর যেকোনো ব্যর্থতায় বাড়তে থাকা বিরতিসহ আবার চেষ্টা করে (আগের পাতার নীতি: সীমিত চেষ্টা, ব্যাকঅফ)। প্রতিটা চেষ্টায় নতুন টাইমস্ট্যাম্প ও সিগনেচার যায়, কিন্তু ইভেন্ট id আর বডি একই থাকে। এখানে বিরতিগুলো খুব ছোট, যাতে পাঠ দ্রুত চলে; আসল সেন্ডার অপেক্ষা করে সেকেন্ড, মিনিট এবং ঘণ্টা, আর একদিন বা তার বেশি পরে হাল ছাড়ে।
# file: sender.py
import itertools
import json
import time
import requests
from fastapi import BackgroundTasks, FastAPI
from pydantic import BaseModel
from signing import sign
app = FastAPI(title="Webhook sender")
SUBSCRIPTIONS: list[dict] = []
DELIVERIES: list[dict] = []
EVENT_IDS = itertools.count(1)
RETRY_DELAYS = [0.05, 0.1, 0.2]
class SubscriptionIn(BaseModel):
url: str
secret: str
def deliver(event: dict, subscription: dict) -> None:
body = json.dumps(event, separators=(",", ":"), sort_keys=True).encode()
for attempt in range(1, len(RETRY_DELAYS) + 2):
timestamp = int(time.time())
headers = {
"Content-Type": "application/json",
"X-Webhook-Id": event["id"],
"X-Webhook-Timestamp": str(timestamp),
"X-Webhook-Signature": sign(subscription["secret"], timestamp, body),
}
try:
status = requests.post(subscription["url"], data=body, headers=headers, timeout=(2, 5)).status_code
except requests.RequestException:
status = None
DELIVERIES.append({"event_id": event["id"], "attempt": attempt, "status": status})
if status is not None and 200 <= status < 300:
return
if attempt <= len(RETRY_DELAYS):
time.sleep(RETRY_DELAYS[attempt - 1])
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/subscriptions", status_code=201)
def subscribe(subscription: SubscriptionIn):
SUBSCRIPTIONS.append(subscription.model_dump())
return {"subscriptions": len(SUBSCRIPTIONS)}
@app.post("/orders/{order_id}/pay")
def pay(order_id: int, background: BackgroundTasks):
event = {"id": f"evt_{next(EVENT_IDS):04d}", "type": "order.paid",
"data": {"order_id": order_id, "amount": 25000}}
for subscription in SUBSCRIPTIONS:
background.add_task(deliver, event, subscription)
return {"event_id": event["id"]}
@app.get("/deliveries")
def deliveries():
return DELIVERIESদুটো সার্ভার চালু করুন। তারা সিক্রেট ভাগ করে এনভায়রনমেন্ট ভেরিয়েবলের মাধ্যমে, আসল সার্ভিস যেভাবে করে (কখনও হার্ড-কোড করবেন না, কখনও কমিটও করবেন না):
export WEBHOOK_SECRET=whsec_demo
uv run fastapi run receiver.py --port 8001 > receiver.log 2>&1 &
echo $! > receiver.pid
uv run fastapi run sender.py --port 8000 > sender.log 2>&1 &
echo $! > sender.pid
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8001/health; echo
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health; echo{"status":"ok"}
{"status":"ok"}বিভ্রাটের মধ্যে একটা ডেলিভারি
রিসিভারকে সেন্ডারের কাছে নিবন্ধন করুন, তারপর রিসিভারকে বলুন পরের দুটো ডেলিভারিতে 503 দিতে, যেমন ডিপ্লয়ের সময় একটা আসল সার্ভার দেয়। একটা অর্ডারের জন্য পেমেন্ট করুন এবং দেখুন সেন্ডার কী করে:
import requests, time
SENDER, RECEIVER = "http://localhost:8000", "http://localhost:8001"
print(requests.post(f"{SENDER}/subscriptions",
json={"url": f"{RECEIVER}/webhooks/orders", "secret": "whsec_demo"}).json())
print(requests.post(f"{RECEIVER}/admin/fail", json={"count": 2}).json())
print(requests.post(f"{SENDER}/orders/1001/pay").json())
def wait_until(check, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = check()
if value:
return value
time.sleep(0.05)
raise TimeoutError("condition never became true")
deliveries = wait_until(lambda: (d := requests.get(f"{SENDER}/deliveries").json()) and d[-1]["status"] == 200 and d)
for delivery in deliveries:
print(delivery)
print(requests.get(f"{RECEIVER}/processed").json()){'subscriptions': 1}
{'remaining': 2}
{'event_id': 'evt_0001'}
{'event_id': 'evt_0001', 'attempt': 1, 'status': 503}
{'event_id': 'evt_0001', 'attempt': 2, 'status': 503}
{'event_id': 'evt_0001', 'attempt': 3, 'status': 200}
{'processed': ['evt_0001']}তিনবার চেষ্টা: দুটো 503, তারপর 200। রিসিভার ইভেন্টটা একবারই প্রসেস করেছে। বাস্তবে সমস্যা খুব কমই পরিষ্কার 503 হয়। বেশিরভাগ সময় আপনার সার্ভার ইভেন্টটা সামলে ফেলে, রেসপন্স হারিয়ে যায়, আর সেন্ডার টাইমআউট দেখে আবার পাঠায়। এ কারণেই রিসিভার ডিডুপ্লিকেট করে, আর এ কারণেই ডেলিভারি "at least once": পুনরাবৃত্তির জন্য আপনাকে তৈরি থাকতে হবে, আর প্রোভাইডার সাধারণত ইভেন্ট ক্রমানুসারে পৌঁছানোরও প্রতিশ্রুতি দেয় না। হ্যান্ডলারকে idempotent বানান (আগের পাতার ইডেমপোটেন্সি কি-র একই ধারণা), আর ক্রম যেখানে গুরুত্বপূর্ণ সেখানে ইভেন্টের টাইমস্ট্যাম্প বা ভার্সন দেখুন।
যে আক্রমণগুলো অবশ্যই ব্যর্থ হতে হবে
এবার আক্রমণকারীর ভূমিকায় নামুন। এখানে আপনি সিক্রেট জানেন (নিজের রিসিভার টেস্ট করছেন), কিন্তু উদ্দেশ্য হলো প্রতিটা প্রতিরক্ষা কাজ করছে কি না নিশ্চিত করা: একটা ডুপ্লিকেট, একটা পুরোনো মেসেজের রিপ্লে, একটা জাল বডি, আর নতুন টাইমস্ট্যাম্পসহ একটা চুরি করা সিগনেচার।
from signing import sign
import json, time
def send(event, secret="whsec_demo", age=0, signature=None, timestamp=None):
body = json.dumps(event, separators=(",", ":"), sort_keys=True).encode()
ts = timestamp if timestamp is not None else int(time.time()) - age
headers = {"X-Webhook-Timestamp": str(ts),
"X-Webhook-Signature": signature or sign(secret, ts, body)}
response = requests.post(f"{RECEIVER}/webhooks/orders", data=body, headers=headers)
return response.status_code, response.json()
event = {"id": "evt_9001", "type": "order.paid", "data": {"order_id": 7, "amount": 100}}
print("first delivery ", send(event))
print("same event again ", send(event))
print("recorded last hr ", send({**event, "id": "evt_9002"}, age=3600))
print("wrong secret ", send({**event, "id": "evt_9003"}, secret="whsec_guess"))
genuine = int(time.time())
old_signature = sign("whsec_demo", genuine - 3600, json.dumps(event, separators=(",", ":"), sort_keys=True).encode())
print("stolen signature ", send(event, signature=old_signature, timestamp=genuine))
print(requests.get(f"{RECEIVER}/processed").json())first delivery (200, {'status': 'accepted'})
same event again (200, {'status': 'duplicate'})
recorded last hr (400, {'detail': 'Timestamp outside the allowed window'})
wrong secret (401, {'detail': 'Signature does not match'})
stolen signature (401, {'detail': 'Signature does not match'})
{'processed': ['evt_0001', 'evt_9001']}শুধু প্রথম ডেলিভারিটা প্রসেস হয়েছে। পুনরাবৃত্তি পেয়েছে 200 duplicate (সফল উত্তর দেওয়া মানে সেন্ডারকে রিট্রাই বন্ধ করতে বলা), পুরোনো মেসেজ আর ভুল সিক্রেটে সাইন করা অনুমান প্রত্যাখ্যাত হয়েছে, আর পুরোনো মেসেজ থেকে নেওয়া সিগনেচার নতুন টাইমস্ট্যাম্পের সামনে টেকে না। সত্যিকারের ওয়েবহুক চালানোর সময় এই নিয়মগুলো মনে রাখবেন:
- রিসিভার URL-এর জন্য HTTPS ব্যবহার করুন, নইলে বডি ও হেডার ক্লিয়ার টেক্সটে যায়। সেন্ডারের IP ঠিকানা মেলানো একটা কাজের বাড়তি স্তর, কিন্তু সিগনেচারের বিকল্প নয়।
- দ্রুত উত্তর দিন (প্রোভাইডারের টাইমআউটের অনেক আগে): ইভেন্ট সংরক্ষণ করুন, কাজ কিউতে দিন,
2xxফেরত দিন। ধীর হ্যান্ডলার প্রোভাইডারকে টাইমআউট করিয়ে ইভেন্ট আবার পাঠায়। - সঠিক কোড ফেরত দিন:
2xxমানে "রিট্রাই বন্ধ করো"। জাল বা ভুল রিকোয়েস্টে4xx, আর নিজের ব্যর্থতায়5xxদিন, যাতে রিট্রাই কেবল তখনই হয় যখন তাতে কাজ হতে পারে। - সিক্রেট ঘোরান (rotate): একটা বদলানোর সময় কিছুদিন দুটো সিক্রেটই গ্রহণ করুন। সিক্রেট বা পুরো সিগনেচার হেডার কখনও লগ করবেন না।
- ইভেন্ট লগ রাখুন, আর "redeliver" বোতাম বা এন্ডপয়েন্ট এবং সব রিট্রাই ব্যর্থ হওয়া ইভেন্টের জন্য একটা dead-letter তালিকা রাখুন। প্রোভাইডাররা ঘণ্টা বা দিন ধরে রিট্রাই করে, কিন্তু আজ না হোক কাল আপনাকে একটা ইভেন্ট হাতে ধরে আবার চালাতে হবে।
দুটো সার্ভারই বন্ধ করুন; পাতার দ্বিতীয় অর্ধেকে পোর্ট 8000 লাগবে অন্য একটা অ্যাপের জন্য।
kill $(cat receiver.pid) $(cat sender.pid)
while curl -s -o /dev/null http://localhost:8000/health; do sleep 0.1; done
echo stoppedstoppedস্ট্রিমিং রেসপন্স
সাধারণ রেসপন্স সম্পূর্ণ হলে তবেই পাঠানো হয়। স্ট্রিমিং রেসপন্স হেডার পাঠায় সঙ্গে সঙ্গে, তারপর বডি পাঠায় তৈরি হওয়ার সাথে সাথে টুকরো টুকরো করে; ক্লায়েন্ট সেগুলো আসামাত্র পড়ে। HTTP এটা স্বাভাবিকভাবেই পারে (chunked transfer encoding)। যখনই পুরো উত্তর ধীর কিন্তু শুরুটা ইতিমধ্যে কাজের, তখন এটা গুরুত্বপূর্ণ:
- LLM আউটপুট: লম্বা উত্তর শেষ হতে কয়েক দশ সেকেন্ড লাগে, কিন্তু প্রথম টোকেন আসে এক সেকেন্ডের কম সময়ে। স্ট্রিমিং প্রোডাক্টকে তাৎক্ষণিক মনে করায় এবং ব্যবহারকারীকে আগেই থামাতে দেয়।
- জবের প্রোগ্রেস ও লগ, লাইভ সার্চ রেজাল্ট, মেমরিতে ধরে রাখতে চান না এমন বড় এক্সপোর্ট।
- এজেন্ট: টুল চলার সময় "searching…", "reading page 3…" আর আংশিক উত্তর দেখানো।
FastAPI-তে সবচেয়ে নিচু স্তরের টুল হলো Starlette-এর StreamingResponse, যা যেকোনো জেনারেটরকে মুড়ে নেয়। এখানে একটা সাধারণ স্ট্রিমসহ ছোট একটা সার্ভার; এর অন্য এন্ডপয়েন্টগুলো একটু পরেই আসছে। এটা main.py নামে সেভ করুন:
# file: main.py
import asyncio
from collections.abc import AsyncIterable
from typing import Annotated
from fastapi import FastAPI, Header
from fastapi.responses import StreamingResponse
from fastapi.sse import EventSourceResponse, ServerSentEvent
app = FastAPI(title="Streaming demo")
WORDS = "Server-Sent Events are plain text over ordinary HTTP".split()
CLOSED: list[str] = []
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/plain")
async def plain():
async def chunks():
for word in WORDS:
await asyncio.sleep(0.01)
yield word + " "
return StreamingResponse(chunks(), media_type="text/plain")
@app.get("/stream", response_class=EventSourceResponse)
async def stream(
last_event_id: Annotated[int | None, Header()] = None,
fail_at: int | None = None,
) -> AsyncIterable[ServerSentEvent]:
start = last_event_id or 0
yield ServerSentEvent(comment="stream open", retry=3000)
for number, word in enumerate(WORDS[start:], start=start + 1):
await asyncio.sleep(0.02)
if number == fail_at:
yield ServerSentEvent(data={"message": "the model failed"}, event="error")
return
yield ServerSentEvent(data={"token": word}, event="token", id=str(number))
yield ServerSentEvent(data={"done": True}, event="done")
@app.get("/ticks", response_class=EventSourceResponse)
async def ticks() -> AsyncIterable[ServerSentEvent]:
try:
for number in range(1, 1000):
await asyncio.sleep(0.02)
yield ServerSentEvent(data={"tick": number})
finally:
CLOSED.append("ticks")
@app.get("/closed")
def closed():
return {"closed": len(CLOSED)}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; echo
curl -sN http://localhost:8000/plain; echo{"status":"ok"}
Server-Sent Events are plain text over ordinary HTTPশব্দগুলো একটা একটা করে এসেছে (-N ফ্ল্যাগ curl-কে বাফার না করতে বলে)। এটা সাধারণ chunked টেক্সট। কাজ করে, কিন্তু ব্রাউজার বা ক্লায়েন্ট জানে না একটা বার্তা কোথায় শেষ, সেটা কোন ধরনের, বা কোথা থেকে আবার শুরু করতে হবে। Server-Sent Events ঠিক এই ফাঁকটা ভরায়।
Server-Sent Events (SSE)
SSE হলো HTTP-র ওপরে একটা ছোট স্ট্যান্ডার্ড: রেসপন্সে থাকে Content-Type: text/event-stream আর বডি তৈরি হয় ইভেন্ট দিয়ে। প্রতিটা ইভেন্ট কয়েকটা field: value লাইন, আর একটা ফাঁকা লাইন ইভেন্ট শেষ করে।
| ফিল্ড | অর্থ |
|---|---|
data: | পে-লোড। এক ইভেন্টে একাধিক data: লাইন থাকলে সেগুলো নতুন লাইন দিয়ে জোড়া হয়। সাধারণত JSON। |
event: | ইভেন্টের নাম (ডিফল্ট message), যাতে ক্লায়েন্ট token, error আর done আলাদাভাবে সামলাতে পারে। |
id: | ক্লায়েন্ট যে id মনে রাখে। আবার সংযোগ করার সময় সে এটা Last-Event-ID হেডারে ফেরত পাঠায়, যাতে সার্ভার যেখানে থেমেছিল সেখান থেকে চালিয়ে যেতে পারে। |
retry: | ব্রাউজার আবার সংযোগ করার আগে কত মিলিসেকেন্ড অপেক্ষা করবে। |
: দিয়ে শুরু হওয়া লাইন | মন্তব্য, ক্লায়েন্ট উপেক্ষা করে। সার্ভার এটা keep-alive পিং হিসেবে ব্যবহার করে, যাতে প্রক্সি নিষ্ক্রিয় কানেকশন বন্ধ না করে। |
WebSocket-এর তুলনায় SSE একমুখী (সার্ভার থেকে ক্লায়েন্ট), সাধারণ HTTP-র ওপর চলে (তাই প্রক্সি, লোড ব্যালান্সার, অথ হেডার ও HTTP/2-এর সঙ্গে কাজ করে), আর রিকানেকশন এতে বিল্ট-ইন। টোকেন স্ট্রিমিংয়ের ঠিক এটাই দরকার। WebSocket-এর বাড়তি জটিলতা সার্থক হয় যখন ক্লায়েন্টকেও একটানা বার্তা পাঠাতে হয় (লাইভ অডিও, একসঙ্গে এডিটিং)। আসলটা দেখুন: practice API-র /v1/chat "stream": true দিয়ে উত্তরের প্রতিটা অংশে একটা করে data: লাইন পাঠায়, আর শেষ লাইনে থাকে টোকেন ব্যবহার:
curl -sN -X POST http://localhost:8800/v1/chat \
-H "Authorization: Bearer token-nadia-001" \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "What is RAG?"}], "stream": true}'data: {"delta": "This "}
data: {"delta": "is "}
data: {"delta": "a "}
data: {"delta": "practice "}
data: {"delta": "answer "}
data: {"delta": "about: "}
data: {"delta": "What "}
data: {"delta": "is "}
data: {"delta": "RAG. "}
data: {"delta": "A "}
data: {"delta": "real "}
data: {"delta": "model "}
data: {"delta": "would "}
data: {"delta": "say "}
data: {"delta": "more. "}
data: {"done": true, "usage": {"prompt_tokens": 3, "completion_tokens": 15, "total_tokens": 18}}আসল LLM প্রোভাইডাররা একই পরিবহন ব্যবহার করে আরও সমৃদ্ধ ইভেন্ট দিয়ে। OpenAI-র Responses API টাইপ করা ইভেন্ট স্ট্রিম করে, আর Anthropic-এর Messages API স্ট্রিম করে message_start, content_block_start, content_block_delta, content_block_stop, message_delta ও message_stop, সঙ্গে ping আর error। Anthropic-এর নকশার একটা খুঁটিনাটি লক্ষ করুন: error হলো এমন একটা স্ট্রিমের ভেতরের ইভেন্ট যা ইতিমধ্যে 200 ফেরত দিয়েছে। এটা এড়ানো যায় না: প্রথম বাইট পাঠানোর পর স্ট্যাটাস কোড আর বদলানো যায় না।
FastAPI-র বিল্ট-ইন SSE
FastAPI-তে SSE সাপোর্ট বাক্সেই আছে (বাড়তি প্যাকেজ লাগে না)। ওপরের /stream এন্ডপয়েন্টেই তিনটা অংশ দেখা যায়:
- রাউটে
response_class=EventSourceResponseদিলেContent-Type: text/event-streamবসে এবং SSE এনকোডিং চালু হয়। এটা যেকোনো HTTP মেথডে চলে,POST-সহ, যেটা LLM API-র দরকার কারণ প্রম্পট বড় হয়। - এন্ডপয়েন্ট একটা জেনারেটর: যে ফাংশনে
yieldআছে। এটাকেAsyncIterable[ServerSentEvent]হিসেবে অ্যানোটেট করলে সে কী পাঠায় তা ডকুমেন্ট হয়। - পূর্ণ নিয়ন্ত্রণের জন্য
ServerSentEvent(data=…, event=…, id=…, retry=…, comment=…)yield করুন, অথবা সাধারণ dict বা Pydantic মডেল yield করুন, তাহলে সেটা JSONdata:লাইন হয়। data সবসময় JSON-এনকোড হয় (তাই স্ট্রিং"hello"উদ্ধৃতি-চিহ্নসহ যায়); টেক্সট যেমন আছে তেমন পাঠাতেraw_data=ব্যবহার করুন।eventওidএক লাইনের হতে হবে, আর FastAPI নিজেইCache-Control: no-cacheওX-Accel-Buffering: noহেডার যোগ করে (দ্বিতীয়টা Nginx প্রক্সিকে স্ট্রিম বাফার না করতে বলে)।
এই এন্ডপয়েন্ট যে কাঁচা বাইট পাঠায় তা দেখুন। প্রথম লাইনটা একটা মন্তব্য যা retry সময়ও ঠিক করে, তারপর প্রতিটা টোকেন id-সহ একটা নামযুক্ত ইভেন্ট:
curl -sN http://localhost:8000/stream: stream open
retry: 3000
event: token
data: {"token": "Server-Sent"}
id: 1
event: token
data: {"token": "Events"}
id: 2
event: token
data: {"token": "are"}
id: 3
event: token
data: {"token": "plain"}
id: 4
event: token
data: {"token": "text"}
id: 5
event: token
data: {"token": "over"}
id: 6
event: token
data: {"token": "ordinary"}
id: 7
event: token
data: {"token": "HTTP"}
id: 8
event: done
data: {"done": true}একই এন্ডপয়েন্ট আবার শুরুও করতে পারে। ক্লায়েন্ট Last-Event-ID: 5 নিয়ে আবার সংযোগ করলে (ব্রাউজারের EventSource এটা নিজেই করে) সার্ভার ৬ নম্বর শব্দ থেকে চালিয়ে যায়:
curl -sN -H "Last-Event-ID: 5" http://localhost:8000/stream | grep -E "^(id|event: done)"id: 6
id: 7
id: 8
event: donePython থেকে SSE পড়া
ব্রাউজারে EventSource আছে, কিন্তু সে শুধু GET রিকোয়েস্ট পাঠাতে পারে, তাই চ্যাট UI সাধারণত স্ট্রিম রিডারসহ fetch ব্যবহার করে। Python-এ আপনি রেসপন্স লাইন ধরে ধরে পড়েন এবং ফরম্যাটটা নিজে প্রয়োগ করেন। নিচের পার্সার প্রায় বিশ লাইনের: ফাঁকা লাইন না আসা পর্যন্ত ফিল্ড জমায়, মন্তব্য উপেক্ষা করে, আর একাধিক লাইনের data জোড়ে। দুটো বিষয় গুরুত্বপূর্ণ: এনকোডিং UTF-8 সেট করুন (charset ছাড়া text/*-এর জন্য requests ধরে নেয় Latin-1, যা বাংলা লেখা গুলিয়ে দেয়), আর chunk_size=None দিন, যাতে বাফার ভরার অপেক্ষা না করে লাইনগুলো আসামাত্র আপনার হাতে আসে।
def parse_sse(lines):
"""Turn the lines of an SSE response into events: dicts with event, data and id."""
event = {"event": "message", "data": [], "id": None}
for line in lines:
if line == "":
if event["data"]:
yield {**event, "data": "\n".join(event["data"])}
event = {"event": "message", "data": [], "id": None}
elif line.startswith(":"):
continue
else:
field, _, value = line.partition(":")
value = value.removeprefix(" ")
if field == "data":
event["data"].append(value)
elif field in ("event", "id"):
event[field] = value
CHAT = "http://localhost:8800/v1/chat"
AUTH = {"Authorization": "Bearer token-nadia-001"}
question = {"messages": [{"role": "user", "content": "What is retrieval augmented generation?"}], "stream": True}
pieces, usage = [], None
with requests.post(CHAT, json=question, headers=AUTH, stream=True, timeout=(3, 30)) as response:
response.raise_for_status()
response.encoding = "utf-8"
for event in parse_sse(response.iter_lines(decode_unicode=True, chunk_size=None)):
data = json.loads(event["data"])
if data.get("done"):
usage = data["usage"]
else:
pieces.append(data["delta"])
print(len(pieces), "pieces; first three:", pieces[:3])
print("".join(pieces).strip())
print(usage)17 pieces; first three: ['This ', 'is ', 'a ']
This is a practice answer about: What is retrieval augmented generation. A real model would say more.
{'prompt_tokens': 5, 'completion_tokens': 17, 'total_tokens': 22}একই কাজ httpx দিয়ে, যা আপনি পরের-পরের পাতায় প্রোডাকশন ক্লায়েন্টের জন্য ব্যবহার করবেন। এর stream() কনটেক্সট ম্যানেজার আপনি না চাওয়া পর্যন্ত বডি পড়ে না, আর iter_lines() নিজেই UTF-8 ডিকোড করে। টাইমআউটটা প্রতি-চাংক read টাইমআউট, তাই কয়েক মিনিট চলা সুস্থ স্ট্রিম কখনও কাটা পড়ে না:
import httpx
with httpx.stream("POST", CHAT, json=question, headers=AUTH, timeout=httpx.Timeout(30.0, connect=3.0)) as response:
response.raise_for_status()
text = "".join(json.loads(e["data"]).get("delta", "") for e in parse_sse(response.iter_lines()))
print(text.strip())This is a practice answer about: What is retrieval augmented generation. A real model would say more.এরপর আমাদের নিজের এন্ডপয়েন্টের ইভেন্ট, নাম আর id-সহ। হেল্পারটা টোকেন পড়ে এবং কয়েকটা ইভেন্টের পর থামতে পারে (with ব্লক ছাড়লে কানেকশন বন্ধ হয়, ব্যবহারকারী "stop" চাপলে ঠিক এটাই ঘটে), আর একটা id থেকে আবার শুরু করতে পারে:
APP = "http://localhost:8000"
def read_tokens(stop_after=None, last_event_id=None, params=None):
headers = {"Last-Event-ID": last_event_id} if last_event_id else {}
got = []
with requests.get(f"{APP}/stream", params=params, headers=headers, stream=True, timeout=5) as response:
response.encoding = "utf-8"
for event in parse_sse(response.iter_lines(decode_unicode=True, chunk_size=None)):
if event["event"] == "token":
got.append((event["id"], json.loads(event["data"])["token"]))
if stop_after and len(got) >= stop_after:
break
elif event["event"] == "error":
got.append(("error", json.loads(event["data"])["message"]))
return got
first = read_tokens(stop_after=3)
print("connection dropped after:", first)
rest = read_tokens(last_event_id=first[-1][0])
print("resumed:", rest)
print("whole sentence:", " ".join(word for _, word in first + rest))
print("model failure:", read_tokens(params={"fail_at": 3}))connection dropped after: [('1', 'Server-Sent'), ('2', 'Events'), ('3', 'are')]
resumed: [('4', 'plain'), ('5', 'text'), ('6', 'over'), ('7', 'ordinary'), ('8', 'HTTP')]
whole sentence: Server-Sent Events are plain text over ordinary HTTP
model failure: [('1', 'Server-Sent'), ('2', 'Events'), ('error', 'the model failed')]আবার শুরু করা কাজ করে কারণ সার্ভার তার ইভেন্টে নম্বর দেয় এবং যেকোনো নম্বর থেকে পুনরায় শুরু করতে পারে। একটা LLM স্ট্রিম মডেল কলের মাঝখান থেকে আবার শুরু করতে পারে না, তাই AI ব্যাকএন্ড সাধারণত হয় জেনারেশন নতুন করে চালায়, নয়তো রিকোয়েস্ট id ধরে আংশিক টেক্সট জমা রাখে। শেষ লাইনে এরর প্যাটার্ন দেখা যাচ্ছে: HTTP স্ট্যাটাস ততক্ষণে 200, তাই ব্যর্থতা আসে error ইভেন্ট হয়ে, আর ক্লায়েন্টকে সেটা স্ট্রিমের অংশ হিসেবেই সামলাতে হয়।
ডিসকানেক্ট ও বাতিল করা
ক্লায়েন্ট চলে গেলে (ট্যাব বন্ধ, "stop generating", ফোনের কানেকশন মরে যাওয়া) সার্ভারের কাজ বন্ধ করা উচিত, কারণ যে LLM কল কেউ পড়ছে না তারও খরচ হয়। FastAPI ডিসকানেক্ট টের পেলে জেনারেটর বাতিল করে; /ticks-এর finally প্রমাণ করে সেটা চলেছে। নিচের স্ট্রিম নিজে থেকে কখনও শেষ হতো না; ক্লায়েন্ট তিনটা ইভেন্ট নিয়ে চলে যায়:
with requests.get(f"{APP}/ticks", stream=True, timeout=5) as response:
response.encoding = "utf-8"
for count, event in enumerate(parse_sse(response.iter_lines(decode_unicode=True, chunk_size=None)), start=1):
print(event["data"])
if count == 3:
break
print("server noticed:", wait_until(lambda: requests.get(f"{APP}/closed").json()["closed"] or None)){"tick": 1}
{"tick": 2}
{"tick": 3}
server noticed: 1ক্লিনআপ (আপস্ট্রিম প্রোভাইডার কল বাতিল, কত টোকেন লাগল তা রেকর্ড) লুপের চারপাশে try … finally-তে রাখুন। প্রোডাকশনের আরও কয়েকটা কথা: শান্ত স্ট্রিমে প্রতি ১৫ থেকে ৩০ সেকেন্ডে একটা কমেন্ট পিং পাঠান (জেনারেটর নিষ্ক্রিয় থাকলে FastAPI এটা আপনার হয়ে করে); স্ট্রিমের সামনের যেকোনো প্রক্সিতে রেসপন্স বাফারিং ও কম্প্রেশন বন্ধ রাখুন; আর মনে রাখুন প্রতিটা খোলা স্ট্রিম একটা কানেকশন ধরে রাখে, তাই একজন ব্যবহারকারী কয়টা খুলতে পারবে তার সীমা বাঁধুন। একটা পূর্ণ LLM ব্যাকএন্ডের স্ট্রিমিং চ্যাট এন্ডপয়েন্ট বানানো স্ট্রিমিংসহ LLM ব্যাকএন্ড বানানো পাতার প্রজেক্ট।
বাস্তব ব্যবহার
- পেমেন্ট, সোর্স-কন্ট্রোল ও চ্যাট প্ল্যাটফর্ম ওয়েবহুক দিয়ে ইভেন্ট পাঠায় (Stripe, GitHub, Slack)। তাদের লাইব্রেরি ঠিক ওপরের মতোই সিগনেচার যাচাই করে।
- AI প্ল্যাটফর্ম ব্যাচ জব, ফাইন-টিউন বা লম্বা এজেন্ট রান শেষ হলে ওয়েবহুক দিয়ে জানায়, আর ইন্টারঅ্যাকটিভ উত্তরের জন্য SSE-তে টোকেন স্ট্রিম করে।
- চ্যাট প্রোডাক্ট মডেলের উত্তর আপনার নিজের ব্যাকএন্ড হয়ে ব্রাউজারে স্ট্রিম করে, পথে অথ, ব্যবহার-সীমা ও লগিং যোগ করে।
- এজেন্ট ফ্রেমওয়ার্ক ও MCP টুল চলার সময় প্রোগ্রেস ও আংশিক ফলাফল পাঠাতে HTTP-র ওপর SSE-ধাঁচের স্ট্রিমিং ব্যবহার করে।
- আপনার নিজের সার্ভিসগুলো পোলিং ছাড়াই একে অপরকে "ডকুমেন্ট ইনডেক্স হয়েছে" বা "মডেল ডিপ্লয় হয়েছে" জানাতে ওয়েবহুক ব্যবহার করে।
সাধারণ ভুল
- কাঁচা বডির বদলে পার্স করা JSON যাচাই করা। আবার সিরিয়ালাইজ করলে বাইট বদলে যায় এবং সিগনেচার কখনও মেলে না (বা আরও খারাপ, জিনিসটা "চালানোর" জন্য আপনি যাচাই বাদ দেন)।
await request.body()পড়ুন, যাচাই করুন, তারপর পার্স করুন। ==দিয়ে সিগনেচার তুলনা করা বা টাইমস্ট্যাম্প বাদ দেওয়া। প্রথমটা সময় ফাঁস করে, দ্বিতীয়টা চিরকাল রিপ্লে করতে দেয়।hmac.compare_digestআর কয়েক মিনিটের সহনসীমা ব্যবহার করুন।- ওয়েবহুক রিকোয়েস্টের ভেতরেই কাজ করা। ধীর হ্যান্ডলারে সেন্ডার টাইমআউট করে রিট্রাই করে, ফলে ডুপ্লিকেট হয়। সংরক্ষণ করুন, কিউতে দিন,
2xxদিয়ে উত্তর দিন, ইভেন্ট id ধরে ডিডুপ্লিকেট করুন। - ধরে নেওয়া যে ওয়েবহুক একবারই এবং ক্রমানুসারে আসে। সেগুলো অন্তত একবার আসে, কখনও দেরিতে ও এলোমেলো ক্রমে। হ্যান্ডলার idempotent বানান এবং ভার্সন বা টাইমস্ট্যাম্প দেখুন।
- স্ট্রিমকে সাধারণ রেসপন্সের মতো ভাবা। বাফার করা প্রক্সি, read টাইমআউট ছাড়া ক্লায়েন্ট,
errorইভেন্ট না সামলানো, আর ডিসকানেক্টে ক্লিনআপ না থাকা।curl -Nদিয়ে টেস্ট করুন, বাফারিং বন্ধ করুন, আরtry … finallyব্যবহার করুন।
নিজে চেষ্টা করুন
- সহজ:
signing.pyথেকেsignব্যবহার করে সিক্রেটwhsec_exerciseআর টাইমস্ট্যাম্প1767225600দিয়ে বডিb'{"id":"evt_9"}'-এর সিগনেচার হিসাব করুন এবং প্রিন্ট করুন। - মাঝারি:
verify_signatureদিয়ে ওই সিগনেচার যাচাই করুনnow = 1767225610-এ (পাস করা উচিত), বডি বদলেb'{"id":"evt_8"}'করে (ফেল করা উচিত), এবংnow = 1767229600-এ (ফেল করা উচিত)। প্রতিটা চেক কী করে তা প্রিন্ট করুন। - মাঝারি:
httpxদিয়ে "Why use webhooks?" প্রশ্নের জন্য/v1/chatস্ট্রিম করুন এবং কতগুলো অংশ এল, জোড়া লাগানো উত্তর, আর শেষ ইভেন্ট থেকেtotal_tokensপ্রিন্ট করুন। - কঠিন:
read_until_done(drop_after)লিখুন, যেটা/streamপড়ে, প্রথম কানেকশনেdrop_afterটা টোকেন ইভেন্টের পর কানেকশন কাটার ভান করে,Last-Event-IDদিয়ে আবার সংযোগ করে, এবংdoneইভেন্ট না আসা পর্যন্ত চালিয়ে যায়। পুরো বাক্য আর মোট ব্যবহৃত কানেকশনের সংখ্যা ফেরত দিন।
উত্তর
# 1. Easy
exercise_body = b'{"id":"evt_9"}'
exercise_signature = sign("whsec_exercise", 1767225600, exercise_body)
print(exercise_signature)
# 2. Medium
def try_verify(payload, now):
try:
verify_signature("whsec_exercise", "1767225600", exercise_signature, payload, now)
return "accepted"
except SignatureError as exc:
return f"rejected: {exc}"
print(try_verify(exercise_body, 1767225610))
print(try_verify(b'{"id":"evt_8"}', 1767225610))
print(try_verify(exercise_body, 1767229600))
# 3. Medium
payload = {"messages": [{"role": "user", "content": "Why use webhooks?"}], "stream": True}
deltas, total = [], None
with httpx.stream("POST", CHAT, json=payload, headers=AUTH, timeout=30.0) as response:
response.raise_for_status()
for event in parse_sse(response.iter_lines()):
data = json.loads(event["data"])
if data.get("done"):
total = data["usage"]["total_tokens"]
else:
deltas.append(data["delta"])
print(len(deltas), "pieces")
print("".join(deltas).strip())
print("total_tokens:", total)
# 4. Hard
def read_until_done(drop_after):
tokens, last_id, connections = [], None, 0
while True:
connections += 1
headers = {"Last-Event-ID": last_id} if last_id else {}
seen, finished = 0, False
with requests.get(f"{APP}/stream", headers=headers, stream=True, timeout=5) as response:
response.encoding = "utf-8"
for event in parse_sse(response.iter_lines(decode_unicode=True, chunk_size=None)):
if event["event"] == "done":
finished = True
break
if event["event"] == "token":
tokens.append(json.loads(event["data"])["token"])
last_id = event["id"]
seen += 1
if connections == 1 and seen == drop_after:
break
if finished:
return " ".join(tokens), connections
print(read_until_done(drop_after=4))sha256=06ad48768c3077908ecc479f6b333b30dec6e25fe743f7490bbad8a40e7d4643
accepted
rejected: Signature does not match
rejected: Timestamp outside the allowed window
15 pieces
This is a practice answer about: Why use webhooks. A real model would say more.
total_tokens: 18
('Server-Sent Events are plain text over ordinary HTTP', 2)কাজ শেষ হলে সার্ভার বন্ধ করুন:
kill $(cat server.pid)সারসংক্ষেপ
- ওয়েবহুক হলো কোনো ইভেন্ট ঘটলে প্রোভাইডারের আপনার URL-কে কল করা। ডেলিভারি at least once, কখনও দেরিতে ও এলোমেলো ক্রমে, তাই যাচাই করুন, ডিডুপ্লিকেট করুন এবং হ্যান্ডলার idempotent রাখুন।
- শেয়ার করা সিক্রেট দিয়ে
timestamp + "." + raw body-কে HMAC-SHA256-এ সাইন করুন; রিসিভার কাঁচা বাইটের ওপর আবার হিসাব করে,hmac.compare_digestদিয়ে তুলনা করে, এবং কয়েক মিনিটের বাইরের টাইমস্ট্যাম্প বাতিল করে। রিট্রাই একই ইভেন্ট id ব্যবহার করে আর নতুন সিগনেচার পায়। - ওয়েবহুকে দ্রুত
2xxদিয়ে উত্তর দিন, কাজ ব্যাকগ্রাউন্ডে করুন, খারাপ রিকোয়েস্টে4xxআর নিজের ব্যর্থতায়5xxদিন, এবং রিডেলিভারিসহ ইভেন্ট লগ রাখুন। - স্ট্রিমিং তৈরি হতে থাকা অবস্থাতেই বডি পাঠায়। SSE তার স্ট্যান্ডার্ড ফরম্যাট:
data,event,idওretryফিল্ড, ইভেন্টের মাঝে ফাঁকা লাইন, আর keep-alive হিসেবে:মন্তব্য। FastAPI-তে এটা বিল্ট-ইন:response_class=EventSourceResponseআরServerSentEventyield করা জেনারেটর। requests(stream=True, UTF-8) বাhttpx.stream()দিয়ে স্ট্রিম লাইন ধরে পড়ুন;Last-Event-IDদিয়ে আবার শুরু করুন;errorইভেন্টকে স্ট্রিমের অংশ ধরুন; আর ক্লায়েন্ট ডিসকানেক্ট করলেfinally-তে ক্লিনআপ করুন।
এরপর: httpx ও asyncio দিয়ে পেশাদার API ক্লায়েন্ট পাতায় আপনি এ পর্যন্ত যা বানিয়েছেন তার ক্লায়েন্ট দিক, অর্থাৎ রিট্রাই, রেট লিমিট, পেজিনেশন ও স্ট্রিম, নিয়ে সেগুলোকে দ্রুত ও ভালোভাবে টেস্ট করা অ্যাসিনক্রোনাস ক্লায়েন্ট কোডে রূপ দেবেন।