অধ্যায় 3 · প্রোডাকশন FastAPI
ডিপেন্ডেন্সি, লাইফস্প্যান, মিডলওয়্যার ও সেটিংস
- পৃষ্ঠা 9 / 22
- 22 মিনিট পড়া
আগের পাতার Prompt Library-র একটা কাঠামো আছে, কিন্তু সে এখনো স্ক্রিপ্টের মতো আচরণ করে। তার সার্ভিস একটা মডিউল-লেভেল সিঙ্গেলটন, যা প্রসেসের সব অ্যাপ ভাগ করে নেয়; সেটিংস হার্ড-কোড করা; সে কী করছে তা জানাতে পারে না; আর অন্য ওয়েবসাইটের ব্রাউজার তাকে কল করতে পারে না। এই পাতা তাকে এমন একটা সার্ভিসে বদলায়, যা চালানো যায়: ডিপেন্ডেন্সি ইনজেক্ট করা হয়, শেয়ার্ড রিসোর্স স্টার্টআপে তৈরি হয় ও শাটডাউনে বন্ধ হয়, প্রতিটা রিকোয়েস্ট একটা আইডি ও টাইমিং পায়, কনফিগারেশন আসে এনভায়রনমেন্ট থেকে, আর ডিপ্লয়মেন্ট প্ল্যাটফর্ম যে প্রশ্ন করে ("তুমি কি বেঁচে আছ?", "তুমি কি প্রস্তুত?") অ্যাপ তার উত্তর দেয়।
যা শিখবেন
AnnotatedওDependsদিয়ে ডিপেন্ডেন্সি ইনজেকশন: চেইন, ক্লাস ডিপেন্ডেন্সি,yieldক্লিনআপ, এবং কেন এতে টেস্ট করা সহজ হয়।lifespanদিয়ে শেয়ার্ড রিসোর্স, আর রিকোয়েস্ট আইডি, টাইমিং ও CORS-এর জন্য মিডলওয়্যার।- ব্যাকগ্রাউন্ড টাস্ক, এবং কখন তার বদলে জব কিউ লাগে।
defবনামasync defএন্ডপয়েন্ট, এবং ব্লকিং কোড কীভাবেasyncসার্ভারকে আটকে দেয়।- স্ট্রাকচার্ড JSON লগিং, হেলথ ও রেডিনেস এন্ডপয়েন্ট, এবং pydantic-settings দিয়ে সেটিংস।
প্রজেক্ট সেট আপ
এটা একটা নতুন ফোল্ডার, তাই পাতাটা নিজে থেকেই সম্পূর্ণ। errors.py, schemas.py, repository.py ও services.py আগের পাতার কোডের সংক্ষিপ্ত রূপ (একই লেয়ার, কম ফিচার)। আপনি যদি পূর্ণ Prompt Library বানিয়ে থাকেন, তাহলে সেটাই রাখুন এবং শুধু এই পাতায় যা নতুন বলে চিহ্নিত, তা বদলান।
mkdir -p ~/projects/prompt-api-ops
cd ~/projects/prompt-api-ops
uv init --bare -q
uv add -q "fastapi[standard]" pydantic-settings httpx2
# with pip: python -m venv .venv && source .venv/bin/activate && pip install -q "fastapi[standard]" pydantic-settings httpx2
mkdir -p app
touch app/__init__.py
cat >> pyproject.toml <<'EOF'
[tool.fastapi]
entrypoint = "app.main:app"
EOF# file: app/errors.py
"""Compact version of the previous page's errors: one JSON shape for every error."""
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AppError(Exception):
status_code = 400
code = "bad_request"
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
class NotFound(AppError):
status_code = 404
code = "not_found"
class Conflict(AppError):
status_code = 409
code = "conflict"
class Unauthorized(AppError):
status_code = 401
code = "unauthorized"
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
body = {"error": {"code": exc.code, "message": exc.message, "details": None}}
return JSONResponse(body, status_code=exc.status_code)# file: app/schemas.py
from pydantic import BaseModel, ConfigDict, Field
class PromptIn(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=3, max_length=60, pattern=r"^[a-z0-9-]+$")
template: str = Field(min_length=1, max_length=4000)
class PromptOut(BaseModel):
id: int
name: str
version: int
template: str# file: app/repository.py
class PromptRepository:
"""In-memory storage. Each instance has its own data: nothing is global."""
def __init__(self) -> None:
self._rows: dict[int, dict] = {}
def add(self, data: dict) -> dict:
row = {"id": len(self._rows) + 1, "version": 1, **data}
self._rows[row["id"]] = row
return row
def get(self, prompt_id: int) -> dict | None:
return self._rows.get(prompt_id)
def get_by_name(self, name: str) -> dict | None:
return next((row for row in self._rows.values() if row["name"] == name), None)
def list(self, *, after_id: int = 0, limit: int = 20) -> list[dict]:
rows = sorted(self._rows.values(), key=lambda row: row["id"])
return [row for row in rows if row["id"] > after_id][:limit]
def count(self) -> int:
return len(self._rows)# file: app/services.py
from app.errors import Conflict, NotFound
from app.repository import PromptRepository
from app.schemas import PromptIn
class PromptService:
def __init__(self, repo: PromptRepository, *, max_prompts: int) -> None:
self.repo = repo
self.max_prompts = max_prompts
def create(self, data: PromptIn) -> dict:
if self.repo.count() >= self.max_prompts:
raise Conflict(f"The library is full (limit: {self.max_prompts})")
if self.repo.get_by_name(data.name):
raise Conflict(f"A prompt named {data.name!r} already exists")
return self.repo.add(data.model_dump())
def get(self, prompt_id: int) -> dict:
row = self.repo.get(prompt_id)
if row is None:
raise NotFound(f"Prompt {prompt_id} not found")
return row
def list(self, *, after_id: int = 0, limit: int = 20) -> list[dict]:
return self.repo.list(after_id=after_id, limit=limit)আগের পাতা থেকে পার্থক্য: সার্ভিস আর ইমপোর্টের সময় তৈরি হয় না। সে তার রিপোজিটরি ও লিমিট কনস্ট্রাক্টরের মাধ্যমে পায়, আর নিচের নতুন অংশগুলো ঠিক করে কে তাকে বানাবে।
pydantic-settings দিয়ে সেটিংস
হার্ড-কোড করা মান (সোর্সে API কি, প্রোভাইডারের URL, অনুমোদিত ওয়েবসাইটের তালিকা) ডিপ্লয় করলে সবার আগে ভাঙে। twelve-factor নিয়মটা সহজ: কনফিগারেশন থাকে এনভায়রনমেন্টে, ল্যাপটপ থেকে সার্ভার পর্যন্ত কোড একই থাকে। pydantic-settings এনভায়রনমেন্ট ভেরিয়েবল (এবং ঐচ্ছিক .env ফাইল) পড়ে, যেকোনো Pydantic মডেলের মতো ভ্যালিডেট করে এবং টাইপ বদলে দেয়:
# file: app/settings.py
from functools import lru_cache
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PROMPT_", env_file=".env", extra="ignore")
api_key: SecretStr # required: no default
environment: str = "development"
upstream_url: str = "http://localhost:8800" # the model provider (the practice API here)
cors_origins: list[str] = ["http://localhost:3000"]
log_level: str = "INFO"
max_prompts: int = Field(default=100, ge=1)
@lru_cache
def get_settings() -> Settings:
"""One Settings object per process. Also a dependency (see below)."""
return Settings()env_prefix="PROMPT_" থাকলে api_key ফিল্ড পড়া হয় PROMPT_API_KEY থেকে। ডিফল্টহীন ফিল্ড বাধ্যতামূলক, তাই সিক্রেট না থাকলে অ্যাপ প্রথম রিকোয়েস্টে ব্যর্থ হওয়ার বদলে স্টার্টআপেই থেমে যায়:
from pydantic import ValidationError
from app.settings import Settings
try:
Settings(_env_file=None)
except ValidationError as exc:
print([(error["loc"], error["type"]) for error in exc.errors()])[(('api_key',), 'missing')]এবার একটা মান দিন, যেভাবে আসল ডিপ্লয়মেন্টে দেওয়া হয়। .env ফাইল শুধু লোকাল ডেভেলপমেন্টের জন্য: এটা Git-এর বাইরে রাখুন, আর প্রোডাকশনে সত্যিকারের এনভায়রনমেন্ট ভেরিয়েবল (বা সিক্রেট, দেখুন Docker Compose: API, PostgreSQL ও Redis একসাথে) ব্যবহার করুন।
cat > .env <<'EOF'
PROMPT_API_KEY=dev-key-123
PROMPT_ENVIRONMENT=development
PROMPT_UPSTREAM_URL=http://localhost:8800
EOF
echo ".env" >> .gitignoreimport os
os.environ["PROMPT_ENVIRONMENT"] = "staging"
os.environ["PROMPT_CORS_ORIGINS"] = '["http://localhost:3000", "https://app.bitbyte.example"]'
settings = Settings()
print(settings.environment, settings.cors_origins, settings.max_prompts)
print(settings.api_key, "|", settings.api_key.get_secret_value())
print(settings)
os.environ.pop("PROMPT_ENVIRONMENT"), os.environ.pop("PROMPT_CORS_ORIGINS")staging ['http://localhost:3000', 'https://app.bitbyte.example'] 100
********** | dev-key-123
api_key=SecretStr('**********') environment='staging' upstream_url='http://localhost:8800' cors_origins=['http://localhost:3000', 'https://app.bitbyte.example'] log_level='INFO' max_prompts=100- অগ্রাধিকার (সবচেয়ে বেশি থেকে):
Settings(...)-এ দেওয়া আর্গুমেন্ট, আসল এনভায়রনমেন্ট ভেরিয়েবল,.envফাইল, ডিফল্ট। এনভায়রনমেন্টের staging মান.env-এরdevelopment-কে হারিয়েছে। - লিস্ট ও অন্যান্য জটিল মান এনভায়রনমেন্ট ভেরিয়েবলে JSON হিসেবে লেখা হয়।
SecretStrলগ ও repr-এ**********হিসেবে ছাপা হয়; মান পেতে আপনাকে ইচ্ছা করেget_secret_value()ডাকতে হয়। এই একটা অভ্যাস অনেক কি ফাঁস হওয়া ঠেকায়।get_settings()lru_cacheদিয়ে ক্যাশ করা, তাই এনভায়রনমেন্ট একবারই পার্স হয়। এটা সাধারণ ফাংশন বলে ডিপেন্ডেন্সিও হতে পারে, আর টেস্ট এটাকে বদলে দিতে পারে।
যে লগ মেশিন পড়তে পারে
print আর ফ্রি-টেক্সট লগ লাইন ("user 5 failed login from 1.2.3.4") দশটা সার্ভার হলে খুঁজে পাওয়া কঠিন। স্ট্রাকচার্ড লগিং প্রতি লাইনে একটা JSON অবজেক্ট লেখে, প্রতিবার একই কি দিয়ে, যাতে লগ সিস্টেম status=500 বা request_id=... দিয়ে ফিল্টার করতে পারে। রিকোয়েস্ট আইডি হলো সেই সুতো, যা একটা রিকোয়েস্টের সব লাইন জোড়া দেয়; একটা ContextVar সেটাকে প্রতিটা ফাংশনে পাস না করেই যেখানে লগ হচ্ছে সেখানে পৌঁছে দেয়:
# file: app/logging_setup.py
import json
import logging
from contextvars import ContextVar
from datetime import datetime, timezone
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
data = {
"ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(timespec="milliseconds"),
"level": record.levelname,
"logger": record.name,
"request_id": request_id_var.get(),
"message": record.getMessage(),
**getattr(record, "fields", {}), # extra={"fields": {...}}
}
if record.exc_info:
data["exception"] = self.formatException(record.exc_info)
return json.dumps(data)
def configure_logging(level: str) -> None:
handler = logging.StreamHandler() # stderr: the platform collects it
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("prompt_api")
logger.handlers[:] = [handler]
logger.setLevel(level)
logger.propagate = Falseইভেন্ট লগ করুন এবং পরিবর্তনশীল অংশ extra-তে রাখুন: log.info("audit recorded", extra={"fields": {"name": "faq"}})। সিক্রেট, ব্যক্তিগত তথ্য থাকতে পারে এমন পুরো প্রম্পট, বা গোটা রিকোয়েস্ট বডি কখনো লগ করবেন না; লগ করুন আইডি, সাইজ, সংখ্যা ও সময়। (API নিরাপত্তা ও OWASP API Top 10 পাতায় প্রাইভেসি-নিরাপদ লগিং নিয়ে আবার আলোচনা আছে।)
মিডলওয়্যার: রিকোয়েস্ট আইডি, টাইমিং ও CORS
মিডলওয়্যার প্রতিটা রিকোয়েস্টকে মুড়ে রাখে: call_next-এর আগের কোড ঢোকার পথে চলে, পরের কোড বেরোনোর পথে। যা সবকিছুর ওপর প্রযোজ্য এবং কোনো নির্দিষ্ট রুটের নয়, তার জন্য এটাই ঠিক জায়গা:
# file: app/middleware.py
import logging
import time
import uuid
from fastapi import FastAPI, Request
from app.logging_setup import request_id_var
log = logging.getLogger("prompt_api.http")
def install_request_context(app: FastAPI) -> None:
@app.middleware("http")
async def request_context(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
token = request_id_var.set(request_id)
start = time.perf_counter()
status_code = 500 # what we log if the app crashes
try:
response = await call_next(request)
status_code = response.status_code
elapsed_ms = (time.perf_counter() - start) * 1000
response.headers["X-Request-ID"] = request_id
response.headers["Server-Timing"] = f"app;dur={elapsed_ms:.1f}"
return response
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
log.info("request", extra={"fields": {
"method": request.method, "path": request.url.path,
"status": status_code, "duration_ms": round(elapsed_ms, 1)}})
request_id_var.reset(token)- এটা আসা
X-Request-IDআবার ব্যবহার করে (গেটওয়ে বা ফ্রন্টএন্ড হয়তো একটা বসিয়ে দিয়েছে, ফলে একটা আইডি কয়েকটা সার্ভিস পেরিয়ে যায়) অথবা নতুন একটা বানায়, এবং ফেরত দেয় যাতে ব্যবহারকারী বাগ রিপোর্টে সেটা উল্লেখ করতে পারে। Server-Timingএকটা স্ট্যান্ডার্ড হেডার; ব্রাউজার তার নেটওয়ার্ক প্যানেলে এটা দেখায়। রিকোয়েস্ট ব্যর্থ হলেওfinallyব্লক লগ করে।
@app.middleware("http")দিয়ে যোগ করা মিডলওয়্যার সহজ, কিন্তু প্রতিটা রিকোয়েস্ট একটা বাড়তি লেয়ারের ভেতর দিয়ে যায়, আর যা স্ট্রিম করতে বা বডি পড়তে হবে তার জন্য এটা ঠিক টুল নয়। ক্রম গুরুত্বপূর্ণ: সবশেষে যোগ করা মিডলওয়্যার হয় সবচেয়ে বাইরের। CORS সবশেষে যোগ করুন, যাতে তার নিজের প্রিফ্লাইট উত্তর এবং আপনার এরর রেসপন্সেও CORS হেডার থাকে।
CORS
ব্রাউজার same-origin policy মানে: https://app.bitbyte.example-এর JavaScript https://api.bitbyte.example-এর রেসপন্স পড়তে পারে না, যতক্ষণ না API Access-Control-Allow-* হেডার দিয়ে অনুমতি দেয়। কাস্টম হেডারসহ রিকোয়েস্টের (যেমন X-API-Key) আগে ব্রাউজার অনুমতি চেয়ে একটা OPTIONS প্রিফ্লাইট পাঠায়। CORS ব্রাউজারের নিয়ম, আপনার API-র নিরাপত্তা নয়: curl ও অন্য সার্ভার এটা উপেক্ষা করে, তাই এটা অথেন্টিকেশনের বিকল্প কখনোই নয়। যে অরিজিনগুলোকে বিশ্বাস করেন সেগুলো হুবহু তালিকাভুক্ত করুন; allow_origins=["*"] শুধু সম্পূর্ণ পাবলিক API-র জন্য, আর কুকির সঙ্গে চলে না।
ডিপেন্ডেন্সি ইনজেকশন
ডিপেন্ডেন্সি হলো এমন ফাংশন, যাকে FastAPI আপনার হয়ে ডাকে এবং যার ফলাফল আপনার রুটে পাঠায়। এটা ঘোষণা করুন Annotated[Type, Depends(function)] দিয়ে; অ্যালিয়াসটা যেকোনো জায়গায় ব্যবহার করা যায়। একটা ডিপেন্ডেন্সি নিজেও ডিপেন্ডেন্সি ঘোষণা করতে পারে, আর FastAPI পুরো চেইন সমাধান করে এবং একাধিক জায়গা চাইলেও প্রতিটা ফাংশন প্রতি রিকোয়েস্টে একবার ডাকে:
# file: app/deps.py
import secrets
from typing import Annotated
import httpx
from fastapi import Depends, Header, Query, Request
from app.errors import Unauthorized
from app.repository import PromptRepository
from app.services import PromptService
from app.settings import Settings, get_settings
SettingsDep = Annotated[Settings, Depends(get_settings)]
def get_repo(request: Request) -> PromptRepository:
return request.app.state.repo # created in lifespan
def get_http(request: Request) -> httpx.AsyncClient:
return request.app.state.http # created in lifespan
def get_audit(request: Request) -> list[str]:
return request.app.state.audit
RepoDep = Annotated[PromptRepository, Depends(get_repo)]
HttpDep = Annotated[httpx.AsyncClient, Depends(get_http)]
AuditDep = Annotated[list[str], Depends(get_audit)]
def get_service(repo: RepoDep, settings: SettingsDep) -> PromptService:
return PromptService(repo, max_prompts=settings.max_prompts)
ServiceDep = Annotated[PromptService, Depends(get_service)]
class Pagination:
"""A class is a dependency too: its __init__ parameters become query parameters."""
def __init__(self, limit: Annotated[int, Query(ge=1, le=100)] = 20,
after_id: Annotated[int, Query(ge=0)] = 0) -> None:
self.limit = limit
self.after_id = after_id
PageDep = Annotated[Pagination, Depends()] # Depends() with no argument = the class itself
def require_api_key(settings: SettingsDep, x_api_key: Annotated[str | None, Header()] = None) -> None:
"""Rejects the request unless X-API-Key matches. Compared in constant time."""
expected = settings.api_key.get_secret_value().encode()
if x_api_key is None or not secrets.compare_digest(x_api_key.encode(), expected):
raise Unauthorized("Missing or wrong X-API-Key")- চেইনটা হলো
route → ServiceDep → get_service → (RepoDep, SettingsDep)। রুট শুধু বলে "আমার একটা সার্ভিস দরকার"; রিপোজিটরি বা লিমিট কোথা থেকে এল, সে তা জানে না। - যে ডিপেন্ডেন্সির মান দরকার নেই (
require_api_keyকিছু ফেরত দেয় না), সেটাdependencies=[Depends(require_api_key)]দিয়ে পুরো রাউটারে জুড়ে দেওয়া যায়। - ডিপেন্ডেন্সি সাধারণ ফাংশন বলে টেস্ট যেকোনোটা বদলে দিতে পারে:
app.dependency_overrides[get_settings] = lambda: test_settings। পেশাদার API টেস্ট করা এর ওপর ভিত্তি করে এগোয়।
yieldসহ ডিপেন্ডেন্সি
যে ডিপেন্ডেন্সি একটা মান yield করে, রেসপন্স শেষ হলে (রুট এরর ছুড়লেও) yield-এর পরের কোড চলে। প্রতি রিকোয়েস্টে যা খুলতে ও বন্ধ করতে হয় তার জন্য এটাই প্যাটার্ন: ডেটাবেস সেশন (পরের পাতা, SQLAlchemy ও Alembic দিয়ে ডেটাবেস), ট্রানজেকশন, ফাইল। একটা ছোট আলাদা অ্যাপে ঘটনার ক্রম এরকম:
from typing import Annotated, Iterator
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
trace: list[str] = []
def get_session() -> Iterator[str]:
trace.append("open session")
try:
yield "session-1"
finally:
trace.append("close session")
SessionDep = Annotated[str, Depends(get_session)]
def get_unit(session: SessionDep) -> str:
return f"unit({session})"
demo = FastAPI()
@demo.get("/work")
def work(session: SessionDep, unit: Annotated[str, Depends(get_unit)]) -> dict[str, str]:
trace.append(f"handler got {session} and {unit}")
return {"ok": "yes"}
with TestClient(demo) as client:
client.get("/work")
print(trace)['open session', 'handler got session-1 and unit(session-1)', 'close session']সেশন খোলা হয়েছে একবার, যদিও রুট ও get_unit দুজনেই সেটা চেয়েছিল (ডিপেন্ডেন্সি প্রতি রিকোয়েস্টে ক্যাশ হয়), আর হ্যান্ডলার শেষ হওয়ার পর বন্ধ হয়েছে।
lifespan দিয়ে শেয়ার্ড রিসোর্স
কিছু অবজেক্ট প্রতি রিকোয়েস্টে নয়, প্রতি প্রসেসে একবার বানাতে হয়: মডেল প্রোভাইডারের পুল করা HTTP ক্লায়েন্ট, লোড করা ML মডেল, ডেটাবেস ইঞ্জিন, এই টিউটোরিয়ালের ইন-মেমরি স্টোর। lifespan ফাংশন সেটা করে। yield-এর আগের কোড স্টার্টআপে চলে (শেষ না হওয়া পর্যন্ত সার্ভার রিকোয়েস্ট নেয় না); পরের কোড শাটডাউনে চলে। অবজেক্টগুলো app.state-এ রাখুন এবং ডিপেন্ডেন্সির (ওপরের get_repo) মাধ্যমে রুটে দিন। এতে আগের পাতার মডিউল-লেভেল সিঙ্গেলটন চলে গেল, আর প্রতিটা create_app() পায় নিজের আলাদা স্টেট:
# file: app/routes.py
import asyncio
import logging
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Path, status
from app.deps import AuditDep, PageDep, ServiceDep, require_api_key
from app.schemas import PromptIn, PromptOut
log = logging.getLogger("prompt_api.routes")
router = APIRouter(prefix="/v1", tags=["prompts"], dependencies=[Depends(require_api_key)])
async def record_audit(audit: list[str], action: str, name: str) -> None:
await asyncio.sleep(0.5) # pretend: write an audit record, call a webhook...
audit.append(f"{action}:{name}")
log.info("audit recorded", extra={"fields": {"action": action, "name": name}})
@router.post("/prompts", status_code=status.HTTP_201_CREATED)
def create_prompt(data: PromptIn, service: ServiceDep, audit: AuditDep,
background: BackgroundTasks) -> PromptOut:
row = service.create(data)
background.add_task(record_audit, audit, "created", row["name"]) # runs after the response is sent
return row
@router.get("/prompts")
def list_prompts(service: ServiceDep, page: PageDep) -> list[PromptOut]:
return service.list(after_id=page.after_id, limit=page.limit)
@router.get("/prompts/{prompt_id}")
def get_prompt(prompt_id: Annotated[int, Path(ge=1)], service: ServiceDep) -> PromptOut:
return service.get(prompt_id)
@router.get("/audit")
def list_audit(audit: AuditDep) -> list[str]:
return audit# file: app/main.py
import logging
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from app import routes
from app.deps import HttpDep, RepoDep
from app.errors import install_error_handlers
from app.logging_setup import configure_logging
from app.middleware import install_request_context
from app.repository import PromptRepository
from app.settings import Settings, get_settings
log = logging.getLogger("prompt_api")
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging(settings.log_level)
app.state.repo = PromptRepository()
app.state.audit = []
async with httpx.AsyncClient(base_url=settings.upstream_url, timeout=2.0) as http:
app.state.http = http # one pooled client for the whole process
log.info("startup", extra={"fields": {"environment": settings.environment}})
yield
log.info("shutdown") # the client is closed by now
app = FastAPI(title="BitByte Prompt Library", lifespan=lifespan)
app.dependency_overrides[get_settings] = lambda: settings
install_error_handlers(app)
install_request_context(app)
app.add_middleware( # added last = outermost
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_methods=["GET", "POST"],
allow_headers=["X-API-Key", "Content-Type", "X-Request-ID"],
expose_headers=["X-Request-ID"],
max_age=600,
)
app.include_router(routes.router)
@app.get("/health", tags=["ops"])
def health() -> dict[str, str]:
"""Liveness: the process is up. Cheap, and never touches other services."""
return {"status": "ok"}
@app.get("/ready", tags=["ops"])
async def ready(repo: RepoDep, http: HttpDep, response: Response) -> dict:
"""Readiness: can this instance do its job right now?"""
checks = {"prompt_store": "ok" if repo.count() >= 0 else "down"}
try:
(await http.get("/health")).raise_for_status()
checks["upstream"] = "ok"
except httpx.HTTPError:
checks["upstream"] = "down"
is_ready = all(value == "ok" for value in checks.values())
if not is_ready:
response.status_code = 503
return {"status": "ready" if is_ready else "not_ready", "checks": checks}
return app
app = create_app()- পুরোনো
@app.on_event("startup")ডিপ্রিকেটেড;lifespan-ই সমর্থিত উপায়, আর ভেতরেরasync withনিশ্চিত করে ক্লায়েন্ট বন্ধ হবে। create_app(settings)সেটিংস আর্গুমেন্ট হিসেবে নেয় এবংget_settings-এর ওভাররাইড হিসেবেও প্রকাশ করে, ফলে রুট ও ডিপেন্ডেন্সি একই অবজেক্ট দেখে। মডিউল-লেভেলapp = create_app()-ইfastapi runচালায়; সে ইমপোর্টের সময় আসল এনভায়রনমেন্ট পড়ে, আর সেজন্যই কি না থাকলে সার্ভার সঙ্গে সঙ্গে থেমে যায়।- লাইফস্প্যান সার্ভার শুরুর সময় চলে, আর টেস্টে চলে কেবল
with TestClient(app)ব্যবহার করলে।
আলাদাভাবে থাকার সুবিধা ফিরে এসেছে। এক প্রসেসের দুটো অ্যাপের স্টোর আলাদা, আর টেস্ট নিজের সেটিংস দিতে পারে (এখানে সর্বোচ্চ একটা প্রম্পট আর প্রায় কোনো লগ নয়):
from app.main import create_app
test_settings = Settings(api_key="test-key", max_prompts=1, log_level="CRITICAL")
headers = {"X-API-Key": "test-key"}
body = {"name": "faq-answer", "template": "FAQ: {input}"}
with TestClient(create_app(test_settings)) as client:
print(client.post("/v1/prompts", json=body, headers=headers).status_code)
print(client.post("/v1/prompts", json={**body, "name": "second"}, headers=headers).json())
with TestClient(create_app(test_settings)) as another:
print(another.get("/v1/prompts", headers=headers).json())201
{'error': {'code': 'conflict', 'message': 'The library is full (limit: 1)', 'details': None}}
[]দ্বিতীয় অ্যাপ খালি অবস্থায় শুরু হয়েছে: আগের পাতার শেষের সেই ফাঁস আর নেই।
চালিয়ে সবকিছু দেখুন
uv run fastapi run --port 8000 > server.log 2>&1 &
export SERVER_PID=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health; echo
curl -s -w ' %{http_code}\n' http://localhost:8000/ready{"status":"ok"}
{"status":"ready","checks":{"prompt_store":"ok","upstream":"ok"}} 200কি এসেছে .env থেকে; কমান্ড লাইনে কিছু লিখতে হয়নি। এবার API কি ডিপেন্ডেন্সি ও রিকোয়েস্ট আইডি। কি ছাড়া রাউটার-লেভেল ডিপেন্ডেন্সি স্ট্যান্ডার্ড এরর শেপে 401 দেয়; কি ও নিজের আইডিসহ রেসপন্স আইডি ফেরত দেয় এবং সময় জানায় (সংখ্যাটা ঢেকে দেওয়া হয়েছে, কারণ প্রতিবার আলাদা):
curl -s -w ' %{http_code}\n' -X POST http://localhost:8000/v1/prompts \
-H 'Content-Type: application/json' -d '{"name": "faq-answer", "template": "FAQ: {input}"}'
curl -s -D - -X POST http://localhost:8000/v1/prompts -H 'X-API-Key: dev-key-123' -H 'X-Request-ID: nadia-req-001' \
-H 'Content-Type: application/json' -d '{"name": "faq-answer", "template": "FAQ: {input}"}' \
| tr -d '\r' | sed -E 's/dur=[0-9.]+/dur=N/' | grep -iE '^(HTTP|x-request-id|server-timing|\{)'
sleep 1{"error":{"code":"unauthorized","message":"Missing or wrong X-API-Key","details":null}} 401
HTTP/1.1 201 Created
x-request-id: nadia-req-001
server-timing: app;dur=N
{"id":1,"name":"faq-answer","version":1,"template":"FAQ: {input}"}হেডারগুলো দেখুন: আমাদের nadia-req-001 ফিরে এসেছে, আর Server-Timing আছে। X-Request-ID হেডার না থাকলে মিডলওয়্যার একটা বানিয়ে নিত।
ব্যাকগ্রাউন্ড টাস্ক, এবং কখন তা যথেষ্ট নয়
প্রম্পট বানানোর অনুরোধকে ধীর আনুষঙ্গিক কাজের জন্য অপেক্ষা করানো উচিত নয়, যেমন অডিট রেকর্ড লেখা, নোটিফিকেশন পাঠানো বা এমবেডিং হিসাব করা। BackgroundTasks একটা ফাংশন চালায় রেসপন্স পাঠানো হয়ে যাওয়ার পর। routes.py-তে টাস্কটা ধীর কাজের প্রতিনিধি হিসেবে আধা সেকেন্ড ঘুমায়। একটা প্রম্পট বানিয়ে সঙ্গে সঙ্গে অডিট তালিকা চান, তারপর এক সেকেন্ড পরে আবার:
curl -s -o /dev/null -X POST http://localhost:8000/v1/prompts -H 'X-API-Key: dev-key-123' \
-H 'Content-Type: application/json' -d '{"name": "summarise", "template": "Summarise: {input}"}'
curl -s http://localhost:8000/v1/audit -H 'X-API-Key: dev-key-123'; echo
sleep 1
curl -s http://localhost:8000/v1/audit -H 'X-API-Key: dev-key-123'; echo["created:faq-answer"]
["created:faq-answer","created:summarise"]ক্লায়েন্ট তার 201 সঙ্গে সঙ্গে পেয়েছে; অডিট এন্ট্রি এসেছে পরে। async def টাস্ক ইভেন্ট লুপে await হয়, আর সাধারণ def টাস্ক থ্রেড পুলে চলে। এই সুবিধার কড়া সীমা আছে, কারণ টাস্কটা আপনার ওয়েব প্রসেসের ভেতরেই থাকে:
BackgroundTasks | জব কিউ (Celery, RQ, Arq, Dramatiq বা ক্লাউড কিউ) | |
|---|---|---|
| ক্র্যাশ বা ডিপ্লয় টিকে থাকে | না: কিউতে থাকা কাজ প্রসেসের সঙ্গে হারিয়ে যায় | হ্যাঁ: জব Redis, RabbitMQ বা SQS-এ অপেক্ষা করে |
| রিট্রাই ও শিডিউল | না | হ্যাঁ: ব্যাকঅফসহ রিট্রাই, পরে চালানো, প্রতি রাতে চালানো |
| ভারী CPU কাজ (কর্পাসের এমবেডিং) | একই প্রসেসে রিকোয়েস্টের সঙ্গে প্রতিযোগিতা করে | আলাদা ওয়ার্কার প্রসেস বা মেশিন |
| স্ট্যাটাস ও ফলাফল | নেই: "জব ৪২ শেষ হয়েছে?" কেউ জিজ্ঞেস করতে পারে না | আছে: জব আইডি, অবস্থা, ফলাফল, ডেড-লেটার কিউ |
| সেটআপ | নেই | একটা ব্রোকার ও একটা ওয়ার্কার, যা চালাতে ও মনিটর করতে হয় |
ছোট, হারালে ক্ষতি নেই এমন কাজে BackgroundTasks ব্যবহার করুন (লগ লাইন, ক্যাশ ওয়ার্ম-আপ, আবার পাঠানো যায় এমন ইমেইল)। যে কাজ ব্যবসার জন্য হারানো চলে না বা যা কয়েক সেকেন্ডের বেশি লাগে, তার জন্য কিউ ব্যবহার করুন: ইনভয়েস পাঠানো, আপলোড করা ডকুমেন্ট প্রসেস করা, বাল্ক LLM মূল্যায়ন। প্রচলিত কাঠামো: এন্ডপয়েন্ট একটা জব রো জমা রাখে, জব আইডিসহ 202 Accepted ফেরত দেয়, ওয়ার্কার রো আপডেট করে; ক্লায়েন্ট GET /v1/jobs/{id} পোল করে বা ওয়েবহুক পায় (দেখুন ওয়েবহুক ও সার্ভার-সেন্ট ইভেন্ট দিয়ে স্ট্রিমিং)।
def নাকি async def?
FastAPI একটা async def রুট চালায় ইভেন্ট লুপে, অর্থাৎ যে একটাই থ্রেড সব কানেকশন সামলায় সেখানে, আর সাধারণ def রুট চালায় থ্রেড পুলে (ডিফল্টে ৪০টা থ্রেড)। কাজে সময় লাগলে কী ঘটবে, তা এটাই ঠিক করে:
| আপনার রুট কী করে... | লিখুন | কেন |
|---|---|---|
async লাইব্রেরি await করে (httpx.AsyncClient, async DB ড্রাইভার, asyncio.sleep) | async def | অপেক্ষায় কোনো খরচ নেই; হাজারো রিকোয়েস্ট একসঙ্গে অপেক্ষা করতে পারে |
ব্লকিং লাইব্রেরি ডাকে (requests, sync DB ড্রাইভার, time.sleep, ফাইল পড়া) | def | FastAPI সেটাকে ওয়ার্কার থ্রেডে সরায়; লুপ ফাঁকা থাকে |
| ভারী CPU কাজ (মডেল ইনফারেন্স, বড় pandas কাজ) | বড়জোর def; আসল লোডের জন্য আলাদা প্রসেস বা কিউ | থ্রেডগুলো GIL ভাগ করে, তাই CPU কাজ তখনও সবকিছু ধীর করে |
| নিশ্চিত নন | def | ভুল def সামান্য ধীর; ভুল async def সার্ভারকে জমিয়ে দেয় |
বিপজ্জনক পরিস্থিতি হলো async def-এর ভেতরে ব্লকিং কল। মেপে দেখুন: তিনটা এন্ডপয়েন্ট, প্রতিটা ০.৩ সেকেন্ড "কাজ" করে, আর প্রতিটায় একই মুহূর্তে পাঁচটা রিকোয়েস্ট পাঠানো হয়। httpx.ASGITransport একই প্রসেসে অ্যাপ ডাকে, তাই সার্ভার লাগে না:
import asyncio
import time
import httpx
from fastapi import FastAPI
lab = FastAPI()
@lab.get("/async-blocking")
async def async_blocking() -> dict[str, str]:
time.sleep(0.3) # blocks the whole event loop
return {"done": "yes"}
@lab.get("/sync-blocking")
def sync_blocking() -> dict[str, str]:
time.sleep(0.3) # blocks only one worker thread
return {"done": "yes"}
@lab.get("/async-waiting")
async def async_waiting() -> dict[str, str]:
await asyncio.sleep(0.3) # yields to the event loop while waiting
return {"done": "yes"}
async def burst(app: FastAPI, path: str, requests: int = 5) -> float:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://lab") as client:
start = time.perf_counter()
await asyncio.gather(*(client.get(path) for _ in range(requests)))
return time.perf_counter() - start
for path in ("/async-blocking", "/sync-blocking", "/async-waiting"):
seconds = asyncio.run(burst(lab, path))
print(f"{path:16} 5 requests ran", "one after another" if seconds > 0.9 else "at the same time")/async-blocking 5 requests ran one after another
/sync-blocking 5 requests ran at the same time
/async-waiting 5 requests ran at the same timeasync-blocking-এ ০.৩ সেকেন্ডের পাঁচটা রিকোয়েস্টে ১.৫ সেকেন্ড লেগেছে: একটা রিকোয়েস্ট ঘুমানোর সময় লুপ আর কিছুই করতে পারেনি, /health-এর উত্তর দেওয়াও নয়। আসল সার্ভিসে এটাই "সবকিছু ধীর, তারপর টাইমআউট" বিপর্যয়, আর প্রায়ই তখন দেখা দেয় যখন কেউ async def রুটে একটা requests.get(...) বসায়। async কোড থেকে ব্লকিং কোড ডাকতেই হলে await asyncio.to_thread(function, *args) বা run_in_threadpool ব্যবহার করুন।
CORS ও লগ
চলমান সার্ভারে ফিরে আসি। localhost:3000-এর একটা ব্রাউজার অ্যাপ প্রিফ্লাইট দিয়ে অনুমতি চায়; অচেনা অরিজিন প্রত্যাখ্যাত হয়:
curl -s -D - -o /dev/null -X OPTIONS http://localhost:8000/v1/prompts \
-H 'Origin: http://localhost:3000' -H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: x-api-key,content-type' | tr -d '\r' | grep -iE '^(HTTP|access-control)' | sort
curl -s -w ' %{http_code}\n' -X OPTIONS http://localhost:8000/v1/prompts \
-H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: POST'HTTP/1.1 200 OK
access-control-allow-headers: Accept, Accept-Language, Content-Language, Content-Type, X-API-Key, X-Request-ID
access-control-allow-methods: GET, POST
access-control-allow-origin: http://localhost:3000
access-control-max-age: 600
Disallowed CORS origin 400এবার লগ। uvicorn একই ফাইলে নিজের নন-JSON লাইনও লেখে, তাই স্ক্রিপ্টটা শুধু আমাদের JSON লাইন রাখে এবং প্রতিবার বদলে যাওয়া টাইমস্ট্যাম্প ও সময়কাল বাদ দেয়:
import json
for line in open("server.log"):
if line.startswith("{"):
record = json.loads(line)
extra = {k: v for k, v in record.items()
if k not in ("ts", "level", "logger", "request_id", "message", "duration_ms")}
rid = record["request_id"]
shown = rid if rid == "-" or rid.startswith("nadia-") else "(generated)"
print(f'{shown:14} {record["level"]:5} {record["message"]:15}', extra)- INFO startup {'environment': 'development'}
(generated) INFO request {'method': 'GET', 'path': '/health', 'status': 200}
(generated) INFO request {'method': 'GET', 'path': '/ready', 'status': 200}
(generated) INFO request {'method': 'POST', 'path': '/v1/prompts', 'status': 401}
nadia-req-001 INFO request {'method': 'POST', 'path': '/v1/prompts', 'status': 201}
nadia-req-001 INFO audit recorded {'action': 'created', 'name': 'faq-answer'}
(generated) INFO request {'method': 'POST', 'path': '/v1/prompts', 'status': 201}
(generated) INFO request {'method': 'GET', 'path': '/v1/audit', 'status': 200}
(generated) INFO audit recorded {'action': 'created', 'name': 'summarise'}
(generated) INFO request {'method': 'GET', 'path': '/v1/audit', 'status': 200}প্রতিটা লাইনে রিকোয়েস্ট আইডি আছে, তাই grep nadia-req-001 server.log (বা লগ সিস্টেমে একটা কোয়েরি) সেই রিকোয়েস্টের সবকিছু দেখায়, রেসপন্সের পরে চলা অডিট টাস্কসহ। এখানে বাদ দেওয়া duration_ms ফিল্ডই ধীর এন্ডপয়েন্ট খুঁজতে চার্টে আঁকা হয়।
হেলথ ও রেডিনেস
একটা প্ল্যাটফর্ম (Kubernetes, Render, লোড ব্যালান্সার, Docker Compose) আপনার অ্যাপকে দুটো আলাদা প্রশ্ন করে:
| চেক | প্রশ্ন | ব্যর্থ হলে | কী যাচাই করা উচিত |
|---|---|---|---|
লাইভনেস (/health) | প্রসেস কি চলছে ও সাড়া দিচ্ছে? | প্ল্যাটফর্ম রিস্টার্ট করে | বাইরের কিছু নয়: সাড়া দিলেই সে জীবিত |
রেডিনেস (/ready) | এখন কি ট্রাফিক সামলাতে পারবে? | প্ল্যাটফর্ম রিকোয়েস্ট পাঠানো বন্ধ করে, কিন্তু রিস্টার্ট করে না | যা তার দরকার: ডেটাবেস, ক্যাশ, মডেল লোড হয়েছে কি না, আপস্ট্রিম নাগালে আছে কি না (ছোট টাইমআউটসহ) |
লাইভনেসও যদি ডেটাবেস চেক করত, ডেটাবেস বসে গেলে প্ল্যাটফর্ম সব সুস্থ অ্যাপ ইনস্ট্যান্সকে লুপে রিস্টার্ট করত, আর পরিস্থিতি আরও খারাপ হতো। এই সার্ভার থামিয়ে এমন আপস্ট্রিমসহ দ্বিতীয় একটা চালু করুন, যার অস্তিত্বই নেই:
kill "$SERVER_PID"
wait "$SERVER_PID" 2>/dev/null
PROMPT_UPSTREAM_URL=http://localhost:9 uv run fastapi run --port 8001 > server2.log 2>&1 &
export SERVER_PID=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8001/health; echo
curl -s -w ' %{http_code}\n' http://localhost:8001/ready
kill "$SERVER_PID"{"status":"ok"}
{"status":"not_ready","checks":{"prompt_store":"ok","upstream":"down"}} 503এনভায়রনমেন্ট ভেরিয়েবল .env ও ডিফল্টকে হারিয়েছে। লাইভনেস বলছে প্রসেস ঠিক আছে; রেডিনেস বলছে এখনো ট্রাফিক পাওয়া উচিত নয়। Docker Compose: API, PostgreSQL ও Redis একসাথে তার হেলথ চেকে ঠিক এই ভাগটাই ব্যবহার করে।
AI কাজে এটা কোথায় আসে
- মডেল সার্ভিং: মডেল লোড করুন
lifespan-এ, লোড না হওয়া পর্যন্ত/ready-তে 503 ফেরত দিন, আর রুটdefরাখুন কারণ ইনফারেন্স CPU-নির্ভর। - LLM ব্যাকএন্ড: প্রোভাইডারের জন্য একটা পুল করা
AsyncClient, তার API কিSecretStrসেটিং হিসেবে, প্রোভাইডার কলে রিকোয়েস্ট আইডি পাঠানো, এবং স্ট্রাকচার্ড লগে টোকেন ও লেটেন্সির সংখ্যা। - দীর্ঘ জব যেমন ডকুমেন্ট ইনজেশন ও ব্যাচ মূল্যায়ন যায় কিউতে এবং স্ট্যাটাস জানায়।
সাধারণ ভুল
async def-এর ভেতরে ব্লকিং কল।requests.get,time.sleepবা sync ডেটাবেস কল প্রতিটা রিকোয়েস্ট জমিয়ে দেয়।awaitসহ async লাইব্রেরি ব্যবহার করুন, নয়তো রুটকে সাধারণdefকরুন।- প্রতি রিকোয়েস্টে ক্লায়েন্ট ও মডেল বানানো। প্রতি কলে নতুন HTTP ক্লায়েন্ট বা মডেল লোড কানেকশন ও সেকেন্ড নষ্ট করে।
lifespan-এ বানান, ডিপেন্ডেন্সির মাধ্যমে ভাগ করুন। - কোডে, Git-এ বা লগে সিক্রেট। এনভায়রনমেন্ট থেকে পড়ুন,
.env.gitignore-এ রাখুন,SecretStrব্যবহার করুন। একবার কমিট হওয়া কি ফাঁস হয়েছে ধরে নিতে হবে। - CORS-কে নিরাপত্তা ভাবা, বা সর্বত্র
"*"। CORS শুধু ব্রাউজার নিয়ন্ত্রণ করে। API-কে রক্ষা করে অথেন্টিকেশন; যে অরিজিন লাগে ঠিক সেগুলোই অনুমতি দিন। - জরুরি কাজে
BackgroundTasksভরসা করা। রিস্টার্টে কাজ নিঃশব্দে হারিয়ে যায়। কাজটা ঘটতেই হলে কিউ ব্যবহার করুন।
নিজে চেষ্টা করুন
- সহজ: দেখান যে
Settings(api_key="k", max_prompts=0)প্রত্যাখ্যাত হয়, এবং এরর মেসেজ ছাপুন। - মাঝারি: বর্তমান রিকোয়েস্ট আইডি ফেরত দেয় এমন একটা ডিপেন্ডেন্সি
get_request_idএবং সেটা ফেরত দেয় এমন একটা রুট/whoamiযোগ করুন।TestClientদিয়ে যাচাই করুন যে আপনার নিজেরX-Request-IDবডি ও হেডার দুটোতেই ফিরে আসে। - কঠিন: ব্লকিং কোড ডাকে এমন একটা
async defরুট যোগ করুন,asyncio.to_threadদিয়ে ঠিক করুন, এবংburstদিয়ে দেখান যে পাঁচটা রিকোয়েস্ট এখন একই সময়ে চলে।
উত্তর
# 1. Easy
try:
Settings(api_key="k", max_prompts=0)
except ValidationError as exc:
print(exc.errors()[0]["msg"])
# 2. Medium
from app.logging_setup import request_id_var
def get_request_id() -> str:
return request_id_var.get()
app = create_app(test_settings)
@app.get("/whoami")
def whoami(request_id: Annotated[str, Depends(get_request_id)]) -> dict[str, str]:
return {"request_id": request_id}
with TestClient(app) as client:
response = client.get("/whoami", headers={"X-Request-ID": "my-id-42"})
print(response.json(), response.headers["X-Request-ID"])
# 3. Hard
def slow_work() -> str:
time.sleep(0.3)
return "done"
@lab.get("/async-fixed")
async def async_fixed() -> dict[str, str]:
return {"done": await asyncio.to_thread(slow_work)} # blocking code moved to a thread
seconds = asyncio.run(burst(lab, "/async-fixed"))
print("5 requests ran", "one after another" if seconds > 0.9 else "at the same time")Input should be greater than or equal to 1
{'request_id': 'my-id-42'} my-id-42
5 requests ran at the same timeসারসংক্ষেপ
Annotated[..., Depends(...)]-এ ঘোষিত ডিপেন্ডেন্সি একটা চেইন গড়ে, যা FastAPI প্রতি রিকোয়েস্টে একবার সমাধান করে;yieldডিপেন্ডেন্সি পরে ক্লিনআপ করে, আরdependency_overridesদিয়ে টেস্ট যেকোনোটা বদলাতে পারে।lifespanশেয়ার্ড রিসোর্স স্টার্টআপে একবার বানায় ও শাটডাউনে বন্ধ করে; মডিউল-লেভেল গ্লোবালের বদলে সেগুলোapp.state-এ রাখুন, তাহলে প্রতিটাcreate_app()আলাদা থাকে।- মিডলওয়্যার প্রতিটা রিকোয়েস্টকে আইডি ও টাইমিং দেয়, আর সেই আইডিসহ একটাই JSON লগ ফরম্যাট রিকোয়েস্ট ট্রেস করা সম্ভব করে;
CORSMiddlewareবিশ্বস্ত অরিজিন হুবহু তালিকাভুক্ত করে এবং এটা ব্রাউজারের নিয়ম, অথেন্টিকেশন নয়। BackgroundTasksছোট, হারালে ক্ষতি নেই এমন কাজের জন্য; টেকসই, রিট্রাইযোগ্য বা ভারী কাজে জব কিউ লাগে।async defশুধুawaitকরা কোডের সঙ্গে, আর ব্লকিং কোডের জন্য সাধারণdef।/health(লাইভনেস) সস্তা থাকে;/readyআসল ডিপেন্ডেন্সি যাচাই করে ও ব্যর্থ হলে 503 দেয়। কনফিগারেশন আসে pydantic-settings থেকে, সিক্রেটSecretStrহিসেবে, আর বাধ্যতামূলক মান না থাকলে দ্রুত ব্যর্থ হয়।
এরপর: SQLAlchemy ও Alembic দিয়ে ডেটাবেস Prompt Library-কে একটা আসল ডেটাবেস দেয়, ইঞ্জিন ও প্রতি রিকোয়েস্টের সেশনের জন্য এই পাতার ঠিক এই ডিপেন্ডেন্সি ও লাইফস্প্যান প্যাটার্ন ব্যবহার করে।