IlmHamroh
Data Science va sun'iy intellekt/MLOps va deploy8/14-dars42 daqiqa
Mundarija (27)

27.8-dars: API ni mustahkamlash va testlash

27-QISM — MLOPS VA DEPLOY · 8-dars


1. Kirish va motivatsiya

Oldingi darsda (27.7) Predictor ni FastAPI orqali taqdim etdik: sxemalar, to'rt endpoint, lifespan, xato kodlari. Ishlab chiqish muhitida u a'lo ishlaydi. Ishlab chiqarish esa boshqa dunyo:

  • mijoz bitta so'rovda 50 000 qator yuboradi va xotira tugaydi;
  • model ba'zan 300 ms "o'ylab qoladi" (disk, xotira tozalash), mijoz esa 100 ms dan ortiq kuta olmaydi;
  • mobil ilova tarmoq uzilganda so'rovni qayta yuboradi — va mijozga ikki marta SMS ketadi;
  • tunda xato bo'ldi, lekin loglarda faqat ERROR: something went wrong — qaysi so'rov, qaysi mijoz, noma'lum; yoki aksincha, loglarda mijozlarning telefon raqamlari ochiq turibdi;
  • API ga internetdan istalgan odam murojaat qila oladi;
  • bitta mijoz skripti sekundiga 1000 so'rov yuboradi va boshqalar uchun xizmat qotib qoladi.

Bundan tashqari, API ning har o'zgarishi (yangi model, yangi maydon, kutubxona yangilanishi) biror narsani jimgina buzishi mumkin. Buni faqat testlar ushlaydi — va model API uchun oddiy birlik testlaridan tashqari maxsus testlar kerak: kontrakt (sxema buzilmadimi?), golden (ma'lum kirish — ma'lum chiqish), chegara holatlar (bo'sh, NaN, cheksiz, noma'lum kategoriya), batch va yakka mosligi.

Real vaziyat. Jamoa FastAPI xizmatini chiqardi va testlarida hamma narsa yashil edi. Bir hafta o'tib monitoringda 500 xatolar paydo bo'ldi. Sabab: bir mijoz tizimi bo'sh maydonlarni NaN sifatida yuborardi. Pydantic bu qiymatni to'g'ri rad etdi, lekin FastAPI ning standart 422 javobi xato tafsilotida kiruvchi qiymatni (NaN) qaytarishga urindi — NaN esa standart JSON da yo'q, javobni yasashning o'zi yiqildi va mijoz 422 o'rniga 500 oldi. Chegara holatlar testi bu tuzoqni birinchi kunning o'zida ko'rsatgan bo'lardi. Bu darsning 1-misolida biz uni aynan shunday topamiz.

Bu darsda API uchun testlar to'plamini yozamiz va uni ishonchlilik, kuzatuvchanlik va xavfsizlik qatlamlari bilan mustahkamlaymiz — hammasi deterministik, virtual soat bilan.

Bu darsda:

  • API testlari: birlik, kontrakt, golden, chegara holatlar, batch/yakka mosligi
  • NaN tuzog'i va o'z validatsiya xatosi ishlovchisi
  • Kiruvchi so'rovni cheklash: batch hajmi va tana hajmi
  • Timeout va zaxira javob (fallback), kesh — virtual soat bilan
  • Idempotentlik kaliti
  • Strukturali JSON loglar: so'rov id, shaxsiy ma'lumotni niqoblash
  • API kaliti bilan autentifikatsiya: muhit o'zgaruvchisi, 401 va 403
  • Rate limiting: token chelak (token bucket) noldan
  • Yuklama sinovi g'oyasi: navbat modeli, throughput va kechikish persentillari
  • Tuzoqlar

ℹ Misollar real FastAPI 0.141, pydantic 2.13, sklearn bilan (Python 3.14). Server ishga tushirilmaydi — TestClient. Barcha kechikishlar virtual soat bilan simulyatsiya qilinadi: time.sleep yo'q, natijalar har safar bir xil.


2. Nazariya — chuqur tushuntirish

2.1. Model API uchun testlar xaritasi

text
TUR               NIMA TEKSHIRADI                           MISOL
BIRLIK            alohida funksiya/klass, HTTP siz          ehtimol [0, 1] da; sinf = p >= chegara
KONTRAKT          API shartnomasi buzilmadimi               endpointlar ro'yxati; javob kalitlari
                  (sxema, javob shakli, orqaga moslik)      va turlari; eski majburiy maydonlar
GOLDEN            ma'lum kirish -> ma'lum chiqish            5 mijoz -> saqlangan ehtimollar (tol 1e-6)
                  (nazorat namunalarining HTTP versiyasi)
CHEGARA HOLATLAR  "g'alati" kirishlar                        bo'sh batch, NaN, cheksiz, 1e308,
                                                            noma'lum kategoriya, maks batch
MOSLIK            bir xil narsa ikki yo'lda bir xilmi        batch vs yakka; API vs batch skript
YUKLAMA           ko'p so'rov ostida kechikish va             p50/p95/p99, throughput, xatolar
                  o'tkazuvchanlik                             ulushi 2.12-bob

PIRAMIDA: birlik va kontrakt - ko'p va tez; golden va chegara - har PR da;
          yuklama - reliz oldidan va muntazam

Model API testining o'ziga xosligi: kod o'zgarmasa ham model o'zgaradi (qayta o'qitish, yangi versiya). Shuning uchun golden testlar ikki xil ma'noga ega: kod o'zgarganda — "bashorat o'zgarmasligi kerak edi, o'zgardi — regressiya"; model almashganda — "bashorat o'zgarishi kutilgan, golden qiymatlar ongli ravishda yangilanadi va sharh bilan commit qilinadi".

Har test turi o'z xatosini ushlaydi. Kontrakt — mijozlar bilan kelishuvni, golden — bashoratlarni, chegara holatlar — "hech kim o'ylamagan" kirishlarni.

2.2. Kontrakt testlari va orqaga moslik

text
ORQAGA MOS (mijozlar buzilmaydi):         ORQAGA MOS EMAS (MAJOR - 27.5):
  yangi IXTIYORIY kirish maydoni            yangi MAJBURIY kirish maydoni
  javobga yangi maydon qo'shish             maydonni o'chirish yoki qayta nomlash
  chegara kengaytirish (le=600 -> 1000)     chegara toraytirish (le=600 -> 100)
  enum ga yangi qiymat (kirishda)           maydon turini o'zgartirish (int -> str)

KONTRAKT TESTI (snapshot bilan):
  reliz paytida:  sxemaning qisqa "surati" saqlanadi (maydonlar, turlar, majburiylar)
  har PR da:      yangi sxema <-> surat:
    eski maydonlar hammasi bormi, turlari o'shami?
    yangi majburiy maydon yo'qmi?
  -> buzilish bo'lsa - test YIQILADI, versiya MAJOR ga ko'tariladi yoki o'zgarish qaytariladi

Surat bilan to'liq tenglikni emas, orqaga moslik qoidalarini tekshiring — aks holda har ixtiyoriy maydon qo'shilishi testni buzadi va jamoa surat faylini o'ylamasdan yangilashga o'rganib qoladi.

2.3. Golden testlar

text
GOLDEN FAYL (reliz paytida yoziladi, git da saqlanadi):
  [{"kirish": {...}, "ehtimol": 0.826812}, ...]      5-20 ta xilma-xil mijoz

TEST:
  har kirish -> POST /predict -> |ehtimol - golden| <= 1e-6

TOLERANTLIK:
  API javobi yaxlitlangan (4 xona) -> golden ham shunday aniqlikda solishtiriladi
  aynan tenglik (==) - platformalar arasi mayda farqlar (27.6 dagi 2.2e-16) tufayli mo'rt
  juda keng tolerantlik (1e-2) - haqiqiy regressiyani yashiradi

2.4. Chegara holatlar va NaN tuzog'i

text
RO'YXAT (model API uchun minimal):
  bo'sh batch []                    -> 422
  maksimal batch (100)              -> 200 ;  101 -> 422
  NaN, Infinity (JSON da nostandart) -> 422, 500 EMAS
  juda katta son (1e308)            -> 422 (domen chegarasi)
  noma'lum kategoriya               -> siyosatga ko'ra: 422 yoki 200 + ogohlantirish
  ixtiyoriy maydon yo'q / null      -> 200 (sukut / imputatsiya)
  noma'lum maydon                   -> 422 (extra="forbid")
  noto'g'ri JSON                    -> 422 (json_invalid)
  tana juda katta (MB lar)          -> 413 (modelga yetib bormasdan)

NaN TUZOG'I (bu mashinadagi FastAPI da tekshirilgan):
  Python json NaN ni o'qiydi -> pydantic to'g'ri rad etadi
  standart 422 ishlovchisi xatoga "input": NaN ni qo'shadi
  javobni JSON ga o'girish NaN da YIQILADI -> mijoz 500 oladi
  YECHIM: o'z RequestValidationError ishlovchingiz - faqat loc, type, msg
          (+ Field(allow_inf_nan=False) - xato turi aniqroq bo'ladi)

Kiruvchi qiymatni xato javobida qaytarmaslikning ikkinchi foydasi — maxfiylik: xato javobi loglarga va mijoz tizimlariga tushadi, unda esa shaxsiy ma'lumot bo'lishi mumkin.

2.5. Kiruvchi so'rovni cheklash

text
QATLAM            CHEGARA                       JAVOB   NEGA
tana hajmi        content-length <= 256 KB      413     JSON ni o'qish/parslashdan OLDIN
batch hajmi       1 <= len(mijozlar) <= 100      422     xotira va kechikish bashorat qilinadi
maydon uzunligi   max_length=40                  422     "10 MB lik ism" hujumi
so'rov tezligi    token chelak 2.11-bob            429     bitta mijoz hammani bosib qolmasin

Katta batch kerak bo'lsa — bu allaqachon batch vazifa (27.7, 2.1): fayl yoki navbat orqali, API orqali emas.

2.6. Timeout va zaxira javob (fallback)

text
MUAMMO: model odatda 10 ms, lekin ba'zan 300 ms (disk, GC, qo'shni jarayon)
        mijozning byudjeti 100 ms; kutish - uning ham ishini to'xtatadi

YECHIM:
  model chaqiruvi BYUDJET bilan: 100 ms ichida javob bo'lmasa -> bekor
  bekor bo'lsa -> ZAXIRA javob:
    oddiy qoida modeli ("oylik tarif va shikoyat >= 2 -> 0.7, aks holda 0.2")
    yoki oxirgi ma'lum natija (keshdan)
    yoki "baholab bo'lmadi" + standart qaror
  javobda: "manba": "model" | "zaxira" | "kesh"  -> mijoz va monitoring biladi

HAQIQIY KOD (g'oya):
  natija = await asyncio.wait_for(model_chaqir(x), timeout=0.1)
  except TimeoutError: natija = zaxira(x)

VIRTUAL SOAT (darsda): haqiqiy kutish o'rniga kechikish hisoblagichi -
  natijalar deterministik, test bir necha millisekundda o'tadi

Zaxira javobning sifati past bo'lishi tabiiy — muhimi, u tez va oldindan ma'lum. Zaxira ulushini monitoring qiling 27.11-bob: u o'sib ketsa — asosiy model bilan muammo bor.

2.7. Kesh

text
KALIT:   sha256(kanonik JSON(kirish) + model versiyasi)
           kanonik: sort_keys=True - maydonlar tartibi kalitga ta'sir qilmaydi
           versiya kalitda - yangi model chiqqanda eski javoblar avtomatik "eskiradi"
SIYOSAT: LRU (eng uzoq ishlatilmagan chiqariladi), hajm chegarasi, TTL
QACHON FOYDALI: bir xil kirishlar ko'p takrorlanadi (mashhur mahsulotlar, qayta so'rovlar)
QACHON ZARARLI: kirishda vaqt/tasodif bo'lsa; model real vaqt belgilaridan foydalansa

2.8. Idempotentlik kaliti

text
MUAMMO: mijoz so'rov yubordi, javob tarmoqda yo'qoldi, mijoz QAYTA yubordi
        "chegirma SMS yubor" endpointi -> mijozga ikki SMS

YECHIM:  mijoz har mantiqiy amal uchun noyob kalit yuboradi:
           Idempotency-Key: 7f3a-...
         server:  kalit -> (tana xeshi, javob) ni saqlaydi
           kalit yangi                -> bajaradi, saqlaydi
           kalit bor, tana o'sha       -> SAQLANGAN javobni qaytaradi (qayta bajarmaydi)
           kalit bor, tana boshqa      -> 409 Conflict (mijoz xatosi)

SOF BASHORAT (/predict) - tabiatan idempotent (yon ta'sir yo'q)
YON TA'SIRLI (yozish, SMS, to'lov) - idempotentlik kaliti SHART

2.9. Strukturali JSON loglar

text
YOMON:  "Prediction done for Ali +998901234567 p=0.83"
          - qidirib bo'lmaydi, shaxsiy ma'lumot ochiq

YAXSHI: {"daraja": "INFO", "xabar": "bashorat", "sorov_id": "req-0003",
         "yol": "/predict", "status": 200, "kechikish_ms": 12.0,
         "mijoz": "crm", "telefon": "+998*****4567", "versiya": "1.2.0"}

QOIDALAR:
  har qator - bitta JSON obyekt (mashina o'qiydi: Loki, ELK, CloudWatch)
  sorov_id - har so'rovga (kelgan X-Request-ID yoki yangi), javob sarlavhasida ham
  shaxsiy ma'lumot NIQOBLANADI: telefon, email, ism, pasport; kalitlar UMUMAN yozilmaydi
  daraja statusga ko'ra: 5xx -> ERROR, 4xx -> WARNING, qolgani -> INFO
  stdout ga - konteyner muhiti 27.9-bob loglarni o'zi yig'adi

2.10. API kaliti bilan autentifikatsiya

text
KALIT QAYERDA:
  kodda / git da            - HECH QACHON
  muhit o'zgaruvchisi        - MODEL_API_KALITLARI (sir boshqaruvchisidan keladi: 27.10)
  ishga tushishda o'qiladi;  yo'q bo'lsa - ilova ISHGA TUSHMAYDI

TEKSHIRISH:
  X-API-Key sarlavhasi yo'q      -> 401 Unauthorized (+ WWW-Authenticate sarlavhasi)
  kalit noto'g'ri                -> 403 Forbidden
  kalit to'g'ri, lekin roli yetmaydi (crm -> /admin) -> 403
  taqqoslash: hmac.compare_digest (vaqt bo'yicha sizib chiqmaydi)

LOGDA: kalitning o'zi emas, egasining NOMI ("crm")
AYLANTIRISH (rotation): bir vaqtda ikki kalit amal qiladi -> mijoz yangisiga o'tadi -> eskisi o'chiriladi

401 va 403 farqi: 401 — "siz kimsiz? o'zingizni tanishtiring"; 403 — "kim ekaningizni bilaman (yoki kalitingiz yaroqsiz), lekin bunga ruxsat yo'q". Kengroq tizimlarda API kaliti o'rniga OAuth2/JWT tokenlari ishlatiladi — g'oya o'sha.

2.11. Rate limiting: token chelak

text
CHELAK: sig'im S token, sekundiga R token bilan to'ladi (S dan oshmaydi)
  so'rov keldi: token >= 1 -> token -= 1, RUXSAT
                token < 1  -> 429 Too Many Requests,
                              Retry-After = (1 - token) / R  sekund

XOSSALAR:
  qisqa portlash (burst) S tagacha ruxsat - foydalanuvchi tajribasi yaxshi
  uzoq muddatda o'rtacha tezlik R dan oshmaydi
  har mijoz (kalit) uchun ALOHIDA chelak - biri boshqasini bosib qolmaydi

TAQSIMLANGAN: bir necha server bo'lsa - chelaklar umumiy omborda (Redis)

2.12. Yuklama sinovi: navbat modeli

text
BELGILAR:  lambda - so'rovlar tezligi (so'rov/s), E[S] - o'rtacha xizmat vaqti,
           c - parallel ishchilar soni
YUKLANISH: rho = lambda * E[S] / c
           rho < 1  -> barqaror, navbat cheklangan
           rho -> 1 -> kechikish KESKIN o'sadi (ayniqsa p99)
           rho > 1  -> navbat cheksiz o'sadi, throughput = c / E[S] (sig'im) da to'xtaydi

QOIDA: o'rtachaga emas, PERSENTILLARGA qarang - p95, p99
       sig'imni rho ~ 0.6-0.7 ga mo'ljallang (zaxira bilan)

HAQIQIY VOSITALAR (bu mashinada yo'q):
  locust, k6, wrk - haqiqiy serverga haqiqiy so'rovlar
  darsda: diskret hodisali SIMULYATSIYA - xuddi shu savollar, virtual vaqtda

Haqiqiy yuklama sinovi shunday ko'rinadi (faqat tanishish uchun):

bash
# k6 bilan: 50 virtual foydalanuvchi, 2 daqiqa
k6 run --vus 50 --duration 2m yuklama.js

# locust bilan: veb-interfeyssiz, 200 foydalanuvchi, sekundiga 20 tadan qo'shiladi
locust -f yuklama.py --headless -u 200 -r 20 --run-time 3m --host http://localhost:8000

Kechikish yuklanishga chiziqli emas. rho = 0.5 dan 0.9 ga o'tish kechikishni ikki emas, o'n barobar oshirishi mumkin — shuning uchun sig'im zaxira bilan rejalashtiriladi va rate limiting ortiqcha yuklamani kesadi.

2.13. Tuzoqlar

Asosiy tuzoqlar: faqat "baxtli yo'l" testlari; golden testni aynan tenglik bilan yozish; kontrakt suratini har safar o'ylamasdan yangilash; standart 422 ishlovchisi bilan NaN -> 500; xato javobida kiruvchi qiymatni qaytarish; batch va tana hajmini cheklamaslik; timeout siz model chaqiruvi; zaxira javobni belgilamaslik (manba yo'q); kesh kalitida model versiyasi yo'qligi; yon ta'sirli endpointda idempotentlik yo'qligi; matnli, sorov_id siz loglar; logda telefon, email yoki API kaliti; kalitni kodda yozish; 401 va 403 ni aralashtirish; == bilan kalit taqqoslash; umumiy (hamma uchun bitta) rate limit; o'rtacha kechikishga qarab sig'im rejalash.


3. Tez ma'lumotnoma

python
import hashlib
import hmac
import json
import logging
import os
import sys

from fastapi import FastAPI, Header, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

app = FastAPI()


@app.exception_handler(RequestValidationError)
async def validatsiya_xatosi(request, exc):
    # "input" ni qaytarmaymiz: NaN -> 500 tuzog'i va maxfiylik
    return JSONResponse(status_code=422, content={"detail": [
        {"loc": list(e["loc"]), "type": e["type"], "msg": e["msg"]}
        for e in exc.errors()]})


def kalit_tekshir(x_api_key: str | None = Header(default=None)):
    kalit = os.environ.get("MODEL_API_KALITI", "")
    if x_api_key is None:
        raise HTTPException(status_code=401, detail="API kaliti kerak",
                            headers={"WWW-Authenticate": "ApiKey"})
    if not kalit or not hmac.compare_digest(x_api_key, kalit):
        raise HTTPException(status_code=403, detail="kalit yaroqsiz")


def kesh_kaliti(kirish, versiya):
    matn = json.dumps(kirish, sort_keys=True) + versiya
    return hashlib.sha256(matn.encode("utf-8")).hexdigest()


class TokenChelak:
    def __init__(self, sigim, tezlik):
        self.sigim, self.tezlik = sigim, tezlik
        self.tokenlar, self.oxirgi = sigim, 0.0

    def ol(self, hozir):
        self.tokenlar = min(self.sigim,
                            self.tokenlar + (hozir - self.oxirgi) * self.tezlik)
        self.oxirgi = hozir
        if self.tokenlar >= 1:
            self.tokenlar -= 1
            return True, 0.0
        return False, (1 - self.tokenlar) / self.tezlik


logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")

Tuzilma xulosasi

testlar: birlik, kontrakt (orqaga moslik), golden (tol), chegara, batch=yakka, yuklama
422 ishlovchisi: loc/type/msg, input siz (NaN -> 500 tuzog'i)
cheklovlar: 413 tana, 422 batch, 429 tezlik
timeout + zaxira ("manba"), kesh (kalitda versiya), idempotentlik kaliti (409)
JSON loglar: sorov_id, niqoblash, kalit yo'q; stdout
API kaliti muhitdan: 401 yo'q, 403 yaroqsiz/ruxsatsiz; compare_digest
token chelak: sig'im + tezlik, har mijozga alohida; rho -> 1 da p99 portlaydi

4. Batafsil misollar

Misollar real FastAPI/pydantic/sklearn bilan (Python 3.14). Server ishga tushirilmaydi — TestClient. Vaqt — virtual soat, hamma natija deterministik.

Misol 1 — API test to'plami: kontrakt, golden, chegara holatlar

python
"""Model API test to'plami: uch variant ilovada 15 test va natija jadvali."""

import json
import warnings
from contextlib import asynccontextmanager
from enum import Enum

import numpy as np
import pandas as pd
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field, create_model
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    from fastapi.testclient import TestClient

SONLI, KATEGORIAL = ["oylar", "tolov"], ["tarif", "hudud"]


def malumot(n, seed):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "oylar": rng.integers(1, 72, n), "tolov": rng.normal(60, 20, n).clip(10, 150),
        "tarif": rng.choice(["oylik", "yillik"], n),
        "hudud": rng.choice(["Toshkent", "Buxoro", "Andijon"], n)})
    logit = -1 - 0.04 * df["oylar"] + 0.03 * (df["tolov"] - 60) + np.where(
        df["tarif"] == "oylik", 1.0, -0.5)
    df["ketdi"] = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    return df


class Predictor:
    versiya, chegara = "1.2.0", 0.35

    def __init__(self, C):
        df = malumot(3000, seed=0)
        pre = ColumnTransformer([("son", StandardScaler(), SONLI),
                                 ("kat", OneHotEncoder(handle_unknown="ignore"),
                                  KATEGORIAL)])
        self.model = Pipeline([("pre", pre), ("clf", LogisticRegression(C=C))])
        self.model.fit(df[SONLI + KATEGORIAL], df["ketdi"])

    def predict_proba(self, df):
        return self.model.predict_proba(df[SONLI + KATEGORIAL])[:, 1]


class Tarif(str, Enum):
    oylik = "oylik"
    yillik = "yillik"


class Mijoz(BaseModel):
    model_config = ConfigDict(extra="forbid")
    oylar: int = Field(ge=0, le=600)
    tolov: float = Field(gt=0, le=1000)
    tarif: Tarif
    hudud: str = Field(default="nomalum", min_length=2, max_length=40)


class MijozV2(Mijoz):
    kanal: str | None = None                  # yangi IXTIYORIY maydon


class Bashorat(BaseModel):
    ehtimol: float
    sinf: int
    versiya: str


def ilova_yasa(tuzatilgan, C=1.0, sxema=Mijoz):
    Partiya = create_model("Partiya", mijozlar=(list[sxema],
                                                Field(min_length=1, max_length=100)))

    @asynccontextmanager
    async def lifespan(app):
        app.state.predictor = Predictor(C)
        yield

    app = FastAPI(lifespan=lifespan)
    if tuzatilgan:
        @app.exception_handler(RequestValidationError)
        async def validatsiya_xatosi(request, exc):
            return JSONResponse(status_code=422, content={"detail": [
                {"loc": list(e["loc"]), "type": e["type"], "msg": e["msg"]}
                for e in exc.errors()]})

    def pr_ol(request: Request):
        return request.app.state.predictor

    def hisobla(pr, mijozlar):
        df = pd.DataFrame([m.model_dump(mode="json") for m in mijozlar])
        return [Bashorat(ehtimol=round(float(p), 4), sinf=int(p >= pr.chegara),
                         versiya=pr.versiya) for p in pr.predict_proba(df)]

    @app.post("/predict", response_model=Bashorat)
    def predict(mijoz: sxema, pr=Depends(pr_ol)):
        return hisobla(pr, [mijoz])[0]

    @app.post("/predict_batch")
    def predict_batch(partiya: Partiya, pr=Depends(pr_ol)):
        return {"natijalar": hisobla(pr, partiya.mijozlar)}

    return app


MIJOZ = {"oylar": 3, "tolov": 95.5, "tarif": "oylik", "hudud": "Buxoro"}
GOLDEN_KIRISH = [MIJOZ, {"oylar": 60, "tolov": 30, "tarif": "yillik"},
                 {"oylar": 12, "tolov": 70, "tarif": "oylik", "hudud": "Andijon"},
                 {"oylar": 0, "tolov": 150, "tarif": "oylik"},
                 {"oylar": 36, "tolov": 55.5, "tarif": "yillik", "hudud": "Toshkent"}]


def kontrakt_surati(app):
    s = app.openapi()["components"]["schemas"]["Mijoz" if "Mijoz" in
                                                app.openapi()["components"]["schemas"]
                                                else "MijozV2"]
    return {"majburiy": sorted(s["required"]),
            "turlar": {k: v.get("type", "ref") for k, v in s["properties"].items()}}


def json_post(k, yol, matn):
    return k.post(yol, content=matn, headers={"content-type": "application/json"})


# ---------------- TESTLAR ----------------
def test_ehtimol_oraligi(k, ctx):
    p = k.app.state.predictor.predict_proba(malumot(200, seed=5))
    assert ((p >= 0) & (p <= 1)).all(), "ehtimol [0, 1] dan tashqarida"


def test_sinf_chegarasi(k, ctx):
    j = k.post("/predict", json=MIJOZ).json()
    assert j["sinf"] == int(j["ehtimol"] >= 0.35), "sinf chegaraga mos emas"


def test_endpointlar(k, ctx):
    yollar = {(y, m) for y, a in k.app.openapi()["paths"].items() for m in a}
    assert yollar == {("/predict", "post"), ("/predict_batch", "post")}


def test_javob_shakli(k, ctx):
    j = k.post("/predict", json=MIJOZ).json()
    assert sorted(j) == ["ehtimol", "sinf", "versiya"], f"kalitlar: {sorted(j)}"
    assert isinstance(j["ehtimol"], float) and isinstance(j["sinf"], int)


def test_orqaga_moslik(k, ctx):
    eski, yangi = ctx["surat"], kontrakt_surati(k.app)
    for maydon, tur in eski["turlar"].items():
        assert yangi["turlar"].get(maydon) == tur, f"{maydon} o'zgardi"
    qoshilgan = set(yangi["majburiy"]) - set(eski["majburiy"])
    assert not qoshilgan, f"yangi majburiy: {qoshilgan}"


def test_golden(k, ctx):
    for kirish, kutilgan in zip(GOLDEN_KIRISH, ctx["golden"]):
        p = k.post("/predict", json=kirish).json()["ehtimol"]
        assert abs(p - kutilgan) <= 1e-6, f"{p} != {kutilgan}"


def test_bosh_batch(k, ctx):
    assert k.post("/predict_batch", json={"mijozlar": []}).status_code == 422


def test_maks_batch(k, ctx):
    r100 = k.post("/predict_batch", json={"mijozlar": [MIJOZ] * 100})
    r101 = k.post("/predict_batch", json={"mijozlar": [MIJOZ] * 101})
    assert (r100.status_code, r101.status_code) == (200, 422), \
        f"{r100.status_code}, {r101.status_code}"


def test_nan(k, ctx):
    r = json_post(k, "/predict", '{"oylar": 3, "tolov": NaN, "tarif": "oylik"}')
    assert r.status_code == 422, f"status {r.status_code}"


def test_cheksiz(k, ctx):
    r = json_post(k, "/predict", '{"oylar": 3, "tolov": 1e400, "tarif": "oylik"}')
    assert r.status_code == 422, f"status {r.status_code}"


def test_juda_katta(k, ctx):
    r = k.post("/predict", json={**MIJOZ, "tolov": 1e308})
    assert r.status_code == 422, f"status {r.status_code}"


def test_nomalum_tarif(k, ctx):
    assert k.post("/predict", json={**MIJOZ, "tarif": "haftalik"}).status_code == 422


def test_nomalum_hudud(k, ctx):
    r = k.post("/predict", json={**MIJOZ, "hudud": "Xiva"})
    assert r.status_code == 200 and 0 <= r.json()["ehtimol"] <= 1


def test_nomalum_maydon(k, ctx):
    assert k.post("/predict", json={**MIJOZ, "region": "X"}).status_code == 422


def test_batch_yakka_mos(k, ctx):
    rng = np.random.default_rng(3)
    ms = [{"oylar": int(rng.integers(0, 72)), "tolov": float(rng.uniform(10, 150)),
           "tarif": str(rng.choice(["oylik", "yillik"]))} for _ in range(30)]
    b = k.post("/predict_batch", json={"mijozlar": ms}).json()["natijalar"]
    y = [k.post("/predict", json=m).json() for m in ms]
    assert all(a == c for a, c in zip(b, y)), "batch va yakka farq qiladi"


TESTLAR = [("birlik", test_ehtimol_oraligi), ("birlik", test_sinf_chegarasi),
           ("kontrakt", test_endpointlar), ("kontrakt", test_javob_shakli),
           ("kontrakt", test_orqaga_moslik), ("golden", test_golden),
           ("chegara", test_bosh_batch), ("chegara", test_maks_batch),
           ("chegara", test_nan), ("chegara", test_cheksiz),
           ("chegara", test_juda_katta), ("chegara", test_nomalum_tarif),
           ("chegara", test_nomalum_hudud), ("chegara", test_nomalum_maydon),
           ("moslik", test_batch_yakka_mos)]


def toplamni_ishlat(app, ctx):
    natija = {}
    with TestClient(app, raise_server_exceptions=False) as k:
        for _, test in TESTLAR:
            try:
                test(k, ctx)
                natija[test.__name__] = ("ok", "")
            except AssertionError as xato:
                natija[test.__name__] = ("YIQ", str(xato)[:22])
    return natija


def main() -> None:
    reliz = ilova_yasa(tuzatilgan=True)
    with TestClient(reliz) as k:
        ctx = {"surat": kontrakt_surati(reliz),
               "golden": [k.post("/predict", json=m).json()["ehtimol"]
                          for m in GOLDEN_KIRISH]}

    print("=== 1. Reliz paytida saqlangan artefaktlar ===")
    print(f"  kontrakt surati: majburiy {ctx['surat']['majburiy']}")
    print(f"  golden ehtimollar: {ctx['golden']}")

    variantlar = {
        "A: standart 422": ilova_yasa(tuzatilgan=False),
        "B: tuzatilgan": ilova_yasa(tuzatilgan=True),
        "C: B + yangi model": ilova_yasa(tuzatilgan=True, C=0.001, sxema=MijozV2),
    }
    jadval = {nom: toplamni_ishlat(app, ctx) for nom, app in variantlar.items()}

    print("\n=== 2. Natija jadvali ===")
    print(f"  {'tur':<9}{'test':<23}{'A':<5}{'B':<5}{'C':<5}izoh")
    for tur, test in TESTLAR:
        n = test.__name__
        holatlar = [jadval[v][n] for v in variantlar]
        izoh = next((h[1] for h in holatlar if h[1]), "")
        print(f"  {tur:<9}{n[5:]:<23}" + "".join(f"{h[0]:<5}" for h in holatlar)
              + izoh)

    print("\n=== 3. Xulosa ===")
    for nom in variantlar:
        yiq = [n[5:] for n, (h, _) in jadval[nom].items() if h == "YIQ"]
        print(f"  {nom:<20} o'tdi {len(TESTLAR) - len(yiq)}/{len(TESTLAR)}"
              f"  yiqilgan: {yiq}")
    with TestClient(variantlar["A: standart 422"],
                    raise_server_exceptions=False) as k:
        r = json_post(k, "/predict", '{"oylar": 3, "tolov": NaN, "tarif": "oylik"}')
    print(f"  A da NaN javobi: {r.status_code} {r.text}")
    print("  ⭐ Chegara holatlar testi 'hech kim o'ylamagan' xatoni topdi")


if __name__ == "__main__":
    main()

Natijaning muhim qismi:

text
=== 1. Reliz paytida saqlangan artefaktlar ===
  kontrakt surati: majburiy ['oylar', 'tarif', 'tolov']
  golden ehtimollar: [0.7148, 0.0085, 0.4767, 0.9367, 0.044]

=== 2. Natija jadvali ===
  tur      test                   A    B    C    izoh
  birlik   ehtimol_oraligi        ok   ok   ok
  birlik   sinf_chegarasi         ok   ok   ok
  kontrakt endpointlar            ok   ok   ok
  kontrakt javob_shakli           ok   ok   ok
  kontrakt orqaga_moslik          ok   ok   ok
  golden   golden                 ok   ok   YIQ  0.2529 != 0.7148
  chegara  bosh_batch             ok   ok   ok
  chegara  maks_batch             ok   ok   ok
  chegara  nan                    YIQ  ok   ok   status 500
  chegara  cheksiz                YIQ  ok   ok   status 500
  chegara  juda_katta             ok   ok   ok
  chegara  nomalum_tarif          ok   ok   ok
  chegara  nomalum_hudud          ok   ok   ok
  chegara  nomalum_maydon         ok   ok   ok
  moslik   batch_yakka_mos        ok   ok   ok

=== 3. Xulosa ===
  A: standart 422      o'tdi 13/15  yiqilgan: ['nan', 'cheksiz']
  B: tuzatilgan        o'tdi 15/15  yiqilgan: []
  C: B + yangi model   o'tdi 14/15  yiqilgan: ['golden']
  A da NaN javobi: 500 Internal Server Error
  ⭐ Chegara holatlar testi 'hech kim o'ylamagan' xatoni topdi

Natija tahlili. 1-bo'limda reliz paytida ikkita artefakt saqlandi: kontrakt surati (uchta majburiy maydon) va beshta golden ehtimol. Keyin bir xil 15 test uchta ilova variantida ishlatildi. 2-bo'lim jadvali darsning asosiy natijasi. A varianti (FastAPI ning standart validatsiya ishlovchisi) 15 testdan 13 tasidan o'tdi. Yiqilgan ikkitasi — NaN va cheksiz (1e400) — 422 o'rniga 500 qaytardi: pydantic qiymatni to'g'ri rad etdi, lekin standart ishlovchi uni xato javobiga input sifatida qo'shdi va NaN/inf ni JSON ga o'girib bo'lmagani uchun javob yasash yiqildi. Bu kirish qismidagi real vaziyatning aynan o'zi — va uni faqat chegara holatlar testi topdi: "baxtli yo'l" testlarining hammasi yashil edi. 1e308 esa chekli son, u xato javobiga sig'adi va A da ham to'g'ri 422 oldi. B varianti (loc, type, msg qaytaradigan o'z ishlovchimiz) 15/15. C varianti — B + boshqacha o'qitilgan model (C=0.001) + yangi ixtiyoriy kanal maydoni. Kontrakt testlari o'tdi: ixtiyoriy maydon qo'shilishi orqaga mos, eski maydonlar va majburiylar o'sha. Faqat golden test yiqildi: birinchi mijoz uchun 0.2529 va saqlangan 0.7148. Boshqa testlar (ehtimol oralig'i, sinf chegarasi, batch/yakka mosligi) "yangi model" da ham o'tdi — ular modelning ichki izchilligini tekshiradi, golden esa natijaning o'zini. Kod o'zgarmagan bo'lsa-yu golden yiqilsa — regressiya; model ongli ravishda almashtirilgan bo'lsa — golden fayl sharh bilan yangilanadi.

Misol 2 — Ishonchlilik: timeout, zaxira, kesh, idempotentlik, cheklovlar

python
"""Virtual soat bilan timeout + zaxira + kesh; FastAPI da idempotentlik va 413."""

import hashlib
import json
import warnings
from collections import OrderedDict

import numpy as np
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    from fastapi.testclient import TestClient

BYUDJET_MS = 100.0
VERSIYA = "1.2.0"


class VirtualSoat:
    def __init__(self):
        self.ms = 0.0

    def otkaz(self, ms):
        self.ms += ms


def asosiy_model(m):
    z = -1 - 0.04 * m["oylar"] + 0.03 * (m["tolov"] - 60) + (
        1.0 if m["tarif"] == "oylik" else -0.5)
    return 1 / (1 + np.exp(-z))


def zaxira_model(m):
    """Oddiy qoida: tez, oldindan ma'lum, sifati pastroq."""
    return 0.6 if (m["tarif"] == "oylik" and m["oylar"] < 12) else 0.2


class Kesh:
    def __init__(self, hajm):
        self.hajm, self.d = hajm, OrderedDict()
        self.topildi = self.topilmadi = 0

    @staticmethod
    def kalit(m):
        return hashlib.sha256((json.dumps(m, sort_keys=True) + VERSIYA)
                              .encode("utf-8")).hexdigest()

    def ol(self, m):
        k = self.kalit(m)
        if k in self.d:
            self.d.move_to_end(k)
            self.topildi += 1
            return self.d[k]
        self.topilmadi += 1
        return None

    def qoy(self, m, qiymat):
        self.d[self.kalit(m)] = qiymat
        self.d.move_to_end(self.kalit(m))
        if len(self.d) > self.hajm:
            self.d.popitem(last=False)


def xizmat(sorovlar, sekin, timeout, kesh):
    """Har so'rov uchun (ehtimol, manba, kechikish_ms) - virtual vaqtda."""
    natijalar = []
    for i, m in enumerate(sorovlar):
        soat = VirtualSoat()
        soat.otkaz(1.0)                                  # parslash, validatsiya
        if kesh is not None and (p := kesh.ol(m)) is not None:
            soat.otkaz(0.5)
            natijalar.append((p, "kesh", soat.ms))
            continue
        model_vaqti = 8.0 + (280.0 if sekin[i] else 0.0)
        if timeout and model_vaqti > BYUDJET_MS:
            soat.otkaz(BYUDJET_MS + 0.2)                 # byudjet tugadi -> zaxira
            natijalar.append((zaxira_model(m), "zaxira", soat.ms))
            continue
        soat.otkaz(model_vaqti)
        p = asosiy_model(m)
        if kesh is not None:
            kesh.qoy(m, p)
        natijalar.append((p, "model", soat.ms))
    return natijalar


class Mijoz(BaseModel):
    oylar: int = Field(ge=0, le=600)
    tolov: float = Field(gt=0, le=1000)
    tarif: str


class Kampaniya(BaseModel):
    mijozlar: list[Mijoz] = Field(min_length=1, max_length=100)


MAKS_BAYT = 8192
app = FastAPI()
app.state.yuborilgan_sms = 0
app.state.idempotent = {}


@app.middleware("http")
async def tana_chegarasi(request: Request, call_next):
    if int(request.headers.get("content-length") or 0) > MAKS_BAYT:
        return JSONResponse(status_code=413,
                            content={"detail": f"tana {MAKS_BAYT} baytdan katta"})
    return await call_next(request)


@app.post("/kampaniya")
def kampaniya(k: Kampaniya, idempotency_key: str | None = Header(default=None)):
    tana_xeshi = hashlib.sha256(k.model_dump_json().encode()).hexdigest()
    if idempotency_key is not None and idempotency_key in app.state.idempotent:
        eski_xesh, eski_javob = app.state.idempotent[idempotency_key]
        if eski_xesh != tana_xeshi:
            raise HTTPException(status_code=409,
                                detail="kalit boshqa tana bilan ishlatilgan")
        return {**eski_javob, "takror": True}
    tanlangan = sum(asosiy_model(m.model_dump()) >= 0.35 for m in k.mijozlar)
    app.state.yuborilgan_sms += int(tanlangan)            # YON TA'SIR
    javob = {"sms_yuborildi": int(tanlangan), "takror": False}
    if idempotency_key is not None:
        app.state.idempotent[idempotency_key] = (tana_xeshi, javob)
    return javob


def persentil(qiymatlar, q):
    return float(np.percentile(qiymatlar, q))


def main() -> None:
    rng = np.random.default_rng(7)
    hovuz = [{"oylar": int(rng.integers(0, 72)),
              "tolov": round(float(rng.uniform(10, 150)), 1),
              "tarif": str(rng.choice(["oylik", "yillik"]))} for _ in range(150)]
    ogirlik = 1 / np.arange(1, 151)                      # mashhurlar ko'p takrorlanadi
    tanlov = rng.choice(150, size=600, p=ogirlik / ogirlik.sum())
    sorovlar = [hovuz[i] for i in tanlov]
    sekin = rng.random(600) < 0.06                       # 6% so'rovda model "o'ylab qoladi"

    print("=== 1. Uch konfiguratsiya: bir xil 600 so'rov ===")
    konfiglar = {"oddiy": (False, None), "timeout+zaxira": (True, None),
                 "timeout+zaxira+kesh": (True, Kesh(64))}
    print(f"  {'konfiguratsiya':<21}{'p50':>6}{'p95':>7}{'p99':>7}{'max':>7}"
          f"  manbalar")
    natijalar = {}
    for nom, (timeout, kesh) in konfiglar.items():
        n = xizmat(sorovlar, sekin, timeout, kesh)
        natijalar[nom] = n
        kech = [x[2] for x in n]
        manba = {m: sum(x[1] == m for x in n) for m in ["model", "zaxira", "kesh"]}
        print(f"  {nom:<21}{persentil(kech, 50):6.1f}{persentil(kech, 95):7.1f}"
              f"{persentil(kech, 99):7.1f}{max(kech):7.1f}  {manba}")

    print("\n=== 2. Zaxiraning narxi: sifat ===")
    asl = np.array([asosiy_model(m) for m in sorovlar])
    for nom in ["timeout+zaxira", "timeout+zaxira+kesh"]:
        p = np.array([x[0] for x in natijalar[nom]])
        zaxira = np.array([x[1] == "zaxira" for x in natijalar[nom]])
        print(f"  {nom:<21} zaxira javoblarda |p - asl| o'rtacha "
              f"{np.abs(p - asl)[zaxira].mean():.3f}, qolganlarida "
              f"{np.abs(p - asl)[~zaxira].max():.1e}")
    kesh = konfiglar["timeout+zaxira+kesh"][1]
    print(f"  kesh: topildi {kesh.topildi}, topilmadi {kesh.topilmadi}, "
          f"ulush {kesh.topildi / (kesh.topildi + kesh.topilmadi):.1%}")

    print("\n=== 3. Kesh kaliti: tartib emas, mazmun va versiya ===")
    a = {"oylar": 3, "tolov": 95.5, "tarif": "oylik"}
    b = {"tarif": "oylik", "tolov": 95.5, "oylar": 3}
    print(f"  tartib boshqa, mazmun o'sha: kalitlar teng {Kesh.kalit(a) == Kesh.kalit(b)}")

    k = TestClient(app)
    tana = {"mijozlar": [{"oylar": 2, "tolov": 120, "tarif": "oylik"},
                         {"oylar": 50, "tolov": 20, "tarif": "yillik"},
                         {"oylar": 5, "tolov": 90, "tarif": "oylik"}]}

    print("\n=== 4. Idempotentlik: tarmoq uzildi, mijoz qayta yubordi ===")
    for kalit in [None, "kamp-001"]:
        app.state.yuborilgan_sms = 0
        sarlavha = {"Idempotency-Key": kalit} if kalit else {}
        javoblar = [k.post("/kampaniya", json=tana, headers=sarlavha).json()
                    for _ in range(3)]
        print(f"  kalit={str(kalit):<9} 3 urinish -> jami SMS: "
              f"{app.state.yuborilgan_sms}, takror: {[j['takror'] for j in javoblar]}")
    boshqa = {"mijozlar": tana["mijozlar"][:1]}
    r = k.post("/kampaniya", json=boshqa, headers={"Idempotency-Key": "kamp-001"})
    print(f"  o'sha kalit, boshqa tana: {r.status_code} {r.json()['detail']}")

    print("\n=== 5. Kiruvchi so'rov chegaralari ===")
    bitta = tana["mijozlar"][0]
    for soni in [3, 100, 101, 300]:
        r = k.post("/kampaniya", json={"mijozlar": [bitta] * soni})
        d = r.json().get("detail", "")
        izoh = d[0]["type"] if isinstance(d, list) else d
        print(f"  {soni:>3} mijoz, tana {len(r.request.content):>5} bayt -> "
              f"{r.status_code} {izoh}")
    print("  ⭐ Byudjet + zaxira dumni kesadi; idempotentlik - bitta SMS")


if __name__ == "__main__":
    main()

Natijaning muhim qismi:

text
=== 1. Uch konfiguratsiya: bir xil 600 so'rov ===
  konfiguratsiya          p50    p95    p99    max  manbalar
  oddiy                   9.0  289.0  289.0  289.0  {'model': 600, 'zaxira': 0, 'kesh': 0}
  timeout+zaxira          9.0  101.2  101.2  101.2  {'model': 567, 'zaxira': 33, 'kesh': 0}
  timeout+zaxira+kesh     1.5    9.0  101.2  101.2  {'model': 140, 'zaxira': 13, 'kesh': 447}

=== 2. Zaxiraning narxi: sifat ===
  timeout+zaxira        zaxira javoblarda |p - asl| o'rtacha 0.133, qolganlarida 0.0e+00
  timeout+zaxira+kesh   zaxira javoblarda |p - asl| o'rtacha 0.124, qolganlarida 0.0e+00
  kesh: topildi 447, topilmadi 153, ulush 74.5%

=== 3. Kesh kaliti: tartib emas, mazmun va versiya ===
  tartib boshqa, mazmun o'sha: kalitlar teng True

=== 4. Idempotentlik: tarmoq uzildi, mijoz qayta yubordi ===
  kalit=None      3 urinish -> jami SMS: 6, takror: [False, False, False]
  kalit=kamp-001  3 urinish -> jami SMS: 2, takror: [False, True, True]
  o'sha kalit, boshqa tana: 409 kalit boshqa tana bilan ishlatilgan

=== 5. Kiruvchi so'rov chegaralari ===
    3 mijoz, tana   134 bayt -> 200
  100 mijoz, tana  4014 bayt -> 200
  101 mijoz, tana  4054 bayt -> 422 too_long
  300 mijoz, tana 12014 bayt -> 413 tana 8192 baytdan katta
  ⭐ Byudjet + zaxira dumni kesadi; idempotentlik - bitta SMS

Natija tahlili. 1-bo'limda bir xil 600 so'rov (150 mijozlik hovuzdan, mashhurlari ko'proq takrorlanadi; 6% so'rovda model 280 ms "o'ylab qoladi") uch konfiguratsiyada virtual vaqtda o'tkazildi. Oddiy xizmatda p50 9.0 ms, lekin p95, p99 va max — 289.0 ms: sekin so'rovlar dumni butunlay egallaydi. Timeout + zaxira dumni byudjetga kesdi: 33 so'rov (5.5%) zaxira javob oldi va eng uzoq kechikish 101.2 ms. Kesh qo'shilganda 447 so'rov (74.5%) keshdan javob oldi, p50 1.5 ms ga, p95 esa 9.0 ms ga tushdi. Modelga faqat 140 marta murojaat qilindi, zaxira ham kamaydi (13 ta) — sekin so'rovlarning bir qismi keshga tushgan mijozlarga to'g'ri keldi. 2-bo'lim bu tezlikning narxini halol ko'rsatadi: zaxira javoblar asosiy modeldan o'rtacha 0.133 (0.124) ga farq qiladi. Qolgan javoblar (model va kesh) aynan asl qiymatlar, ya'ni kesh aniqlikni yo'qotmaydi. Zaxira ulushi monitoringda kuzatiladigan metrika bo'lishi kerak. 3-bo'limda kesh kaliti kanonik — maydonlar tartibi kalitga ta'sir qilmadi. 4-bo'lim idempotentlikni ko'rsatadi. Kalitsiz uchta urinish (javob "yo'qolgan", mijoz qayta yuborgan) 6 ta SMS yubordi — har urinishda 2 tadan. Idempotency-Key bilan esa faqat 2 ta ketdi: ikkinchi va uchinchi urinishlar saqlangan javobni takror: True bilan oldi. O'sha kalit boshqa tana bilan kelganda — 409. 5-bo'limda cheklov qatlamlari o'z joyida ishladi: 100 mijoz — 200, 101 — pydantic 422 too_long, 300 mijozlik 12014 baytli tana esa 413 oldi. Bu javob middleware da, JSON parslanishi va validatsiyadan oldin berildi, ya'ni katta tana xizmat resurslarini umuman ishlatmadi.

Misol 3 — Strukturali loglar va API kaliti

python
"""JSON loglar (sorov_id, niqoblash) va muhitdan olinadigan API kalitlari."""

import hmac
import itertools
import json
import logging
import os
import re
import secrets
import sys
import warnings
from contextlib import asynccontextmanager

from fastapi import Depends, FastAPI, Header, HTTPException, Request
from pydantic import BaseModel, Field

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    from fastapi.testclient import TestClient


class JsonFormat(logging.Formatter):
    def format(self, record):
        yozuv = {"daraja": record.levelname, "xabar": record.getMessage()}
        yozuv.update(getattr(record, "maydonlar", {}))
        return json.dumps(yozuv, ensure_ascii=False, sort_keys=True)


LOG = logging.getLogger("model_api")
LOG.setLevel(logging.INFO)
LOG.propagate = False
_chiqish = logging.StreamHandler(sys.stdout)
_chiqish.setFormatter(JsonFormat())
LOG.addHandler(_chiqish)
XOTIRA = []                                   # tahlil uchun nusxa


class XotiraHandler(logging.Handler):
    def emit(self, record):
        XOTIRA.append(json.loads(self.format(record)))


_xotira = XotiraHandler()
_xotira.setFormatter(JsonFormat())
LOG.addHandler(_xotira)


def telefon_niqobla(t):
    return re.sub(r"^(\+998)\d{5}(\d{4})$", r"\1*****\2", t) if t else None


def email_niqobla(e):
    if not e or "@" not in e:
        return None
    nom, domen = e.split("@", 1)
    return f"{nom[0]}***@{domen}"


class Mijoz(BaseModel):
    oylar: int = Field(ge=0, le=600)
    tarif: str
    telefon: str | None = Field(default=None, pattern=r"^\+998\d{9}$")
    email: str | None = None


SANOQ = itertools.count(1)
SOAT = {"ms": 0.0}


@asynccontextmanager
async def lifespan(app):
    matn = os.environ.get("MODEL_API_KALITLARI")
    if not matn:
        raise RuntimeError("MODEL_API_KALITLARI o'rnatilmagan")
    # format: "egasi:rol:kalit,egasi:rol:kalit"
    app.state.kalitlar = [tuple(q.split(":", 2)) for q in matn.split(",")]
    LOG.info("ishga tushdi", extra={"maydonlar": {
        "kalitlar_soni": len(app.state.kalitlar)}})
    yield


app = FastAPI(lifespan=lifespan)


def kalit_tekshir(request: Request, x_api_key: str | None = Header(default=None)):
    if x_api_key is None:
        raise HTTPException(status_code=401, detail="API kaliti kerak",
                            headers={"WWW-Authenticate": "ApiKey"})
    for egasi, rol, kalit in request.app.state.kalitlar:
        if hmac.compare_digest(x_api_key.encode(), kalit.encode()):
            request.state.egasi = egasi
            return rol
    raise HTTPException(status_code=403, detail="kalit yaroqsiz")


def admin_kerak(rol: str = Depends(kalit_tekshir)):
    if rol != "admin":
        raise HTTPException(status_code=403, detail="admin roli kerak")


@app.middleware("http")
async def sorov_logi(request: Request, call_next):
    sid = request.headers.get("x-request-id") or f"req-{next(SANOQ):04d}"
    request.state.sorov_id = sid
    boshlanish = SOAT["ms"]
    javob = await call_next(request)
    javob.headers["X-Request-ID"] = sid
    daraja = (logging.ERROR if javob.status_code >= 500 else
              logging.WARNING if javob.status_code >= 400 else logging.INFO)
    LOG.log(daraja, "sorov", extra={"maydonlar": {
        "sorov_id": sid, "metod": request.method, "yol": request.url.path,
        "status": javob.status_code,
        "kechikish_ms": round(SOAT["ms"] - boshlanish, 1),
        "egasi": getattr(request.state, "egasi", None)}})
    return javob


@app.post("/predict")
def predict(m: Mijoz, request: Request, rol: str = Depends(kalit_tekshir)):
    SOAT["ms"] += 12.0                                  # virtual model vaqti
    p = 0.72 if m.tarif == "oylik" and m.oylar < 12 else 0.18
    LOG.info("bashorat", extra={"maydonlar": {
        "sorov_id": request.state.sorov_id, "ehtimol": p,
        "telefon": telefon_niqobla(m.telefon), "email": email_niqobla(m.email)}})
    return {"ehtimol": p}


@app.post("/admin/qayta_yukla", dependencies=[Depends(admin_kerak)])
def qayta_yukla():
    return {"holat": "qayta yuklandi"}


def main() -> None:
    print("=== 1. Kalitsiz muhit: ilova ishga tushmaydi ===")
    os.environ.pop("MODEL_API_KALITLARI", None)
    try:
        with TestClient(app):
            pass
    except RuntimeError as xato:
        print(f"  RuntimeError: {xato}")

    print("\n=== 2. Kalitlar muhitdan (kodda emas) ===")
    crm, admin = secrets.token_hex(16), secrets.token_hex(16)
    os.environ["MODEL_API_KALITLARI"] = f"crm:oqish:{crm},ops:admin:{admin}"
    print(f"  kalitlar yaratildi: 2 ta, har biri {len(crm)} belgi (qiymatlari "
          f"chop etilmaydi)")

    with TestClient(app) as k:
        print("\n=== 3. So'rovlar (har biri stdout ga bitta JSON log) ===")
        mijoz = {"oylar": 3, "tarif": "oylik", "telefon": "+998901234567",
                 "email": "ali.valiyev@misol.uz"}
        holatlar = [
            ("kalitsiz", {}, "/predict"),
            ("noto'g'ri kalit", {"X-API-Key": "a" * 32}, "/predict"),
            ("crm kaliti", {"X-API-Key": crm, "X-Request-ID": "mobil-77"},
             "/predict"),
            ("crm -> admin", {"X-API-Key": crm}, "/admin/qayta_yukla"),
            ("ops -> admin", {"X-API-Key": admin}, "/admin/qayta_yukla"),
        ]
        natijalar = []
        for nom, sarlavha, yol in holatlar:
            r = k.post(yol, json=mijoz if yol == "/predict" else None,
                       headers=sarlavha)
            natijalar.append((nom, r.status_code, r.json().get("detail", "ok"),
                              r.headers.get("x-request-id"),
                              r.headers.get("www-authenticate")))

    print("\n=== 4. Javoblar ===")
    for nom, kod, izoh, sid, www in natijalar:
        print(f"  {nom:<16} {kod}  {izoh:<18} sorov_id={sid}"
              f"{'  WWW-Authenticate=' + www if www else ''}")

    print("\n=== 5. Loglarni tahlil qilish ===")
    darajalar = {}
    for y in XOTIRA:
        darajalar[y["daraja"]] = darajalar.get(y["daraja"], 0) + 1
    print(f"  jami yozuvlar: {len(XOTIRA)}, darajalar: {dict(sorted(darajalar.items()))}")
    mobil = [y["xabar"] for y in XOTIRA if y.get("sorov_id") == "mobil-77"]
    print(f"  sorov_id=mobil-77 yozuvlari: {mobil}")
    hammasi = json.dumps(XOTIRA, ensure_ascii=False)
    print(f"  logda ochiq telefon bormi: {'901234567' in hammasi}")
    print(f"  logda ochiq email bormi:   {'ali.valiyev' in hammasi}")
    print(f"  logda API kaliti bormi:    {crm in hammasi or admin in hammasi}")
    print("  ⭐ Log - mashina o'qiydigan JSON; sirlar va shaxsiy ma'lumot yo'q")


if __name__ == "__main__":
    main()

Natijaning muhim qismi:

text
=== 1. Kalitsiz muhit: ilova ishga tushmaydi ===
  RuntimeError: MODEL_API_KALITLARI o'rnatilmagan

=== 2. Kalitlar muhitdan (kodda emas) ===
  kalitlar yaratildi: 2 ta, har biri 32 belgi (qiymatlari chop etilmaydi)
{"daraja": "INFO", "kalitlar_soni": 2, "xabar": "ishga tushdi"}

=== 3. So'rovlar (har biri stdout ga bitta JSON log) ===
{"daraja": "WARNING", "egasi": null, "kechikish_ms": 0.0, "metod": "POST", "sorov_id": "req-0001", "status": 401, "xabar": "sorov", "yol": "/predict"}
{"daraja": "WARNING", "egasi": null, "kechikish_ms": 0.0, "metod": "POST", "sorov_id": "req-0002", "status": 403, "xabar": "sorov", "yol": "/predict"}
{"daraja": "INFO", "ehtimol": 0.72, "email": "a***@misol.uz", "sorov_id": "mobil-77", "telefon": "+998*****4567", "xabar": "bashorat"}
{"daraja": "INFO", "egasi": "crm", "kechikish_ms": 12.0, "metod": "POST", "sorov_id": "mobil-77", "status": 200, "xabar": "sorov", "yol": "/predict"}
{"daraja": "WARNING", "egasi": "crm", "kechikish_ms": 0.0, "metod": "POST", "sorov_id": "req-0003", "status": 403, "xabar": "sorov", "yol": "/admin/qayta_yukla"}
{"daraja": "INFO", "egasi": "ops", "kechikish_ms": 0.0, "metod": "POST", "sorov_id": "req-0004", "status": 200, "xabar": "sorov", "yol": "/admin/qayta_yukla"}

=== 4. Javoblar ===
  kalitsiz         401  API kaliti kerak   sorov_id=req-0001  WWW-Authenticate=ApiKey
  noto'g'ri kalit  403  kalit yaroqsiz     sorov_id=req-0002
  crm kaliti       200  ok                 sorov_id=mobil-77
  crm -> admin     403  admin roli kerak   sorov_id=req-0003
  ops -> admin     200  ok                 sorov_id=req-0004

=== 5. Loglarni tahlil qilish ===
  jami yozuvlar: 7, darajalar: {'INFO': 4, 'WARNING': 3}
  sorov_id=mobil-77 yozuvlari: ['bashorat', 'sorov']
  logda ochiq telefon bormi: False
  logda ochiq email bormi:   False
  logda API kaliti bormi:    False
  ⭐ Log - mashina o'qiydigan JSON; sirlar va shaxsiy ma'lumot yo'q

Natija tahlili. 1-bo'limda muhitda kalitlar yo'q edi — lifespan RuntimeError bilan ilovani ishga tushirmadi. Kalitsiz "ochiq" ishlaydigan xizmatdan ko'ra, umuman ishlamaydigan xizmat xavfsizroq. 2-bo'limda kalitlar ish vaqtida secrets.token_hex bilan yaratildi va muhit o'zgaruvchisiga yozildi; kodda ham, chiqishda ham ularning qiymati yo'q. Haqiqiy tizimda ularni sir boshqaruvchisi beradi 27.10-bob. Ishga tushish logida faqat kalitlar soni bor. 3-bo'limda har so'rov stdout ga bitta JSON qator yozdi: sorov_id, yo'l, status, virtual kechikish va kalit egasining nomi (crm, ops) — kalitning o'zi emas. Status bo'yicha daraja avtomatik tanlandi: 401/403 — WARNING, 200 — INFO. Bashorat logida telefon +998*****4567, email a***@misol.uz ko'rinishida niqoblangan. Mijoz o'z X-Request-ID sini (mobil-77) yuborganda xizmat aynan shu id ni ishlatdi va javob sarlavhasida qaytardi. Shu tufayli mobil ilova logidagi yozuvni server logi bilan bog'lash mumkin. 4-bo'lim 401 va 403 farqini aniq ko'rsatadi. Kalitsiz so'rov 401 va WWW-Authenticate sarlavhasini oldi ("o'zingizni tanishtiring"). Noto'g'ri kalit — 403. To'g'ri kalit, lekin yetarsiz rol (crm -> /admin) — ham 403. 5-bo'limda loglar dastur bilan tahlil qilindi: darajalar bo'yicha sanash, mobil-77 ning barcha yozuvlarini topish (bashorat va sorov) va avtomatik tekshiruv — loglarda ochiq telefon, email yoki API kaliti yo'q. Bunday tekshiruvni CI ga test sifatida qo'yish mumkin.

Misol 4 — Rate limiting va yuklama simulyatsiyasi

python
"""Token chelak (virtual soat), FastAPI da 429, navbat modeli bilan yuklama sinovi."""

import heapq
import math
import warnings

import numpy as np
from fastapi import Depends, FastAPI, Header, HTTPException

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    from fastapi.testclient import TestClient


class TokenChelak:
    def __init__(self, sigim, tezlik):
        self.sigim, self.tezlik = sigim, tezlik
        self.tokenlar, self.oxirgi = float(sigim), 0.0

    def ol(self, hozir):
        self.tokenlar = min(self.sigim,
                            self.tokenlar + (hozir - self.oxirgi) * self.tezlik)
        self.oxirgi = hozir
        if self.tokenlar >= 1:
            self.tokenlar -= 1
            return True, 0.0
        return False, (1 - self.tokenlar) / self.tezlik


SOAT = {"s": 0.0}
CHELAKLAR = {}
app = FastAPI()


def tezlik_chegarasi(x_api_key: str = Header()):
    chelak = CHELAKLAR.setdefault(x_api_key, TokenChelak(sigim=10, tezlik=5))
    ruxsat, kutish = chelak.ol(SOAT["s"])
    if not ruxsat:
        raise HTTPException(status_code=429, detail="juda ko'p so'rov",
                            headers={"Retry-After": str(math.ceil(kutish))})


@app.post("/predict", dependencies=[Depends(tezlik_chegarasi)])
def predict():
    return {"ehtimol": 0.42}


def navbat_simulyatsiyasi(lam, c, n=20000, seed=0, chelak=None):
    """M/G/c navbati: Puasson kelishlar, lognormal xizmat (o'rtacha 20 ms)."""
    rng = np.random.default_rng(seed)
    kelish = np.cumsum(rng.exponential(1 / lam, n))
    xizmat = rng.lognormal(np.log(0.020) - 0.5 ** 2 / 2, 0.5, n)
    bosh = [0.0] * c
    kechikish, rad = [], 0
    for t, s in zip(kelish, xizmat):
        if chelak is not None and not chelak.ol(t)[0]:
            rad += 1
            continue
        erta = heapq.heappop(bosh)
        tugash = max(t, erta) + s
        heapq.heappush(bosh, tugash)
        kechikish.append(tugash - t)
    k = np.array(kechikish) * 1000
    davom = kelish[-1] - kelish[0]
    return {"throughput": len(k) / davom, "p50": np.percentile(k, 50),
            "p95": np.percentile(k, 95), "p99": np.percentile(k, 99),
            "rad": rad / n}


def main() -> None:
    print("=== 1. Token chelak: sig'im 10, tezlik 5 token/s ===")
    ch = TokenChelak(sigim=10, tezlik=5)
    reja = [(0.0, 15), (1.0, 7), (3.0, 12)]
    for t, soni in reja:
        natija = [ch.ol(t) for _ in range(soni)]
        ruxsat = sum(r for r, _ in natija)
        kutish = max(k for _, k in natija)
        print(f"  t={t:.1f}s: {soni:>2} so'rov -> ruxsat {ruxsat:>2}, rad "
              f"{soni - ruxsat:>2}, eng uzoq Retry-After {kutish:.2f}s")
    ch2 = TokenChelak(sigim=10, tezlik=5)
    ruxsat = sum(ch2.ol(i / 8)[0] for i in range(80))
    print(f"  10 s davomida 8 so'rov/s: ruxsat {ruxsat} / 80 "
          f"(= 10 sig'im + 5 x 10 s ga yaqin)")

    print("\n=== 2. FastAPI: har kalitga alohida chelak, 429 ===")
    k = TestClient(app)
    for kalit, soni in [("crm", 12), ("mobil", 3)]:
        kodlar = [k.post("/predict", headers={"X-API-Key": kalit}) for _ in range(soni)]
        sanoq = {}
        for r in kodlar:
            sanoq[r.status_code] = sanoq.get(r.status_code, 0) + 1
        oxirgi = kodlar[-1]
        print(f"  {kalit:<6} {soni} so'rov: {dict(sorted(sanoq.items()))}, oxirgisi "
              f"{oxirgi.status_code} Retry-After={oxirgi.headers.get('retry-after')}")
    SOAT["s"] = 2.0
    r = k.post("/predict", headers={"X-API-Key": "crm"})
    print(f"  crm, 2 s dan keyin: {r.status_code} {r.json()}")

    print("\n=== 3. Yuklama: c=4 ishchi, E[S]=20 ms, sig'im 200 so'rov/s ===")
    print(f"  {'lambda':>6} {'rho':>5} {'throughput':>10} {'p50':>7} {'p95':>7} "
          f"{'p99':>8}")
    for lam in [60, 120, 160, 180, 190, 210]:
        r = navbat_simulyatsiyasi(lam, c=4)
        print(f"  {lam:>6} {lam * 0.020 / 4:5.2f} {r['throughput']:10.1f} "
              f"{r['p50']:7.1f} {r['p95']:7.1f} {r['p99']:8.1f}")

    print("\n=== 4. lambda=210 da ikki yechim ===")
    variantlar = {
        "hech narsa (c=4)": dict(c=4),
        "rate limit 170/s (c=4)": dict(c=4, chelak=TokenChelak(40, 170)),
        "ko'proq ishchi (c=6)": dict(c=6),
    }
    for nom, kw in variantlar.items():
        r = navbat_simulyatsiyasi(210, **kw)
        print(f"  {nom:<24} rad {r['rad']:6.1%}  p95 {r['p95']:8.1f} ms  "
              f"p99 {r['p99']:8.1f} ms")
    print("  ⭐ rho -> 1 da dum portlaydi; limit yoki sig'im uni tiyadi")


if __name__ == "__main__":
    main()

Natijaning muhim qismi:

text
=== 1. Token chelak: sig'im 10, tezlik 5 token/s ===
  t=0.0s: 15 so'rov -> ruxsat 10, rad  5, eng uzoq Retry-After 0.20s
  t=1.0s:  7 so'rov -> ruxsat  5, rad  2, eng uzoq Retry-After 0.20s
  t=3.0s: 12 so'rov -> ruxsat 10, rad  2, eng uzoq Retry-After 0.20s
  10 s davomida 8 so'rov/s: ruxsat 59 / 80 (= 10 sig'im + 5 x 10 s ga yaqin)

=== 2. FastAPI: har kalitga alohida chelak, 429 ===
  crm    12 so'rov: {200: 10, 429: 2}, oxirgisi 429 Retry-After=1
  mobil  3 so'rov: {200: 3}, oxirgisi 200 Retry-After=None
  crm, 2 s dan keyin: 200 {'ehtimol': 0.42}

=== 3. Yuklama: c=4 ishchi, E[S]=20 ms, sig'im 200 so'rov/s ===
  lambda   rho throughput     p50     p95      p99
      60  0.30       60.4    17.9    40.6     58.0
     120  0.60      120.9    19.8    45.8     64.4
     160  0.80      161.2    25.6    66.2     91.6
     180  0.90      181.3    37.0   106.2    150.2
     190  0.95      191.4    61.7   187.0    225.2
     210  1.05      211.5  2997.2  5617.0   5757.1

=== 4. lambda=210 da ikki yechim ===
  hech narsa (c=4)         rad   0.0%  p95   5617.0 ms  p99   5757.1 ms
  rate limit 170/s (c=4)   rad  19.4%  p95     55.8 ms  p99     83.4 ms
  ko'proq ishchi (c=6)     rad   0.0%  p95     45.8 ms  p99     63.9 ms
  ⭐ rho -> 1 da dum portlaydi; limit yoki sig'im uni tiyadi

Natija tahlili. 1-bo'limda token chelak virtual vaqtda ishladi. t=0 da 15 so'rovli portlashdan sig'im bo'yicha 10 tasi o'tdi, 5 tasi rad etildi va Retry-After 0.20 s bo'ldi — tezlik 5 token/s bo'lgani uchun bitta token 0.2 s da to'ladi. 1 s dan keyin chelakda 5 token bor edi: 7 so'rovdan 5 tasi o'tdi. 2 s tanaffusdan keyin chelak yana to'liq (10) bo'ldi. Uzoq muddatda 8 so'rov/s tezlik bilan 10 s da 80 so'rovdan 59 tasi o'tdi — bu nazariy 10 + 5 x 10 = 60 ga deyarli teng: portlash sig'imgacha ruxsat etiladi, o'rtacha tezlik esa R dan oshmaydi. 2-bo'limda FastAPI da har kalitning o'z chelagi bor. crm 12 so'rovdan 10 tasini o'tkazdi va 2 tasiga 429 hamda Retry-After: 1 oldi. mobil esa bundan umuman ta'sirlanmadi (3/3). 2 virtual sekunddan keyin crm yana xizmat oldi. 3-bo'lim navbat nazariyasining asosiy saboqini raqam bilan ko'rsatadi. rho = 0.30 dan 0.60 gacha p99 deyarli o'zgarmadi (58.0 -> 64.4 ms). 0.80 da 91.6, 0.90 da 150.2, 0.95 da 225.2 ms. rho = 1.05 da (sig'imdan 5% ortiq trafik) navbat cheksiz o'sa boshladi va p50 ham 2997.2 ms ga sakradi. Throughput esa trafikni kuzatib boraverdi — ya'ni throughput ga qarab "hammasi yaxshi" deyish mumkin, kechikish esa allaqachon halokatli. 4-bo'limda lambda = 210 uchun ikki yechim solishtirildi. 170 so'rov/s rate limit so'rovlarning 19.4% ini 429 bilan rad etdi, lekin qabul qilinganlar uchun p99 ni 83.4 ms da ushladi. Ikki qo'shimcha ishchi (c = 6, rho = 0.70) esa hech kimni rad etmasdan p99 ni 63.9 ms ga tushirdi. Rate limit — himoya vositasi (bir mijozning ortiqcha trafigi hammaga zarar yetkazmasligi uchun), sig'im esa haqiqiy yechim. Ikkalasi birga kerak.


5. To'g'ri va noto'g'ri tushunishlar

Noto'g'ri fikr To'g'risi
"Testlar yashil — API tayyor" Faqat baxtli yo'l testlari NaN, bo'sh batch va cheksiz qiymatni ko'rmaydi
"Golden test == bilan aniqroq" Mo'rt; tolerantlik javob aniqligiga mos bo'lsin
"Kontrakt testi — sxema bilan to'liq tenglik" Orqaga moslik qoidalari: ixtiyoriy maydon qo'shish buzilish emas
"Pydantic bor — 422 doim to'g'ri ishlaydi" Standart ishlovchi NaN da 500 beradi; o'z ishlovchingiz kerak
"Timeout — faqat mijoz tomonida" Server ham byudjet va zaxira javobga ega bo'lishi kerak
"Kesh kaliti — kirish JSON i" Kanonik JSON + model versiyasi; aks holda yangi modelga eski javob
"Qayta urinish zararsiz" Yon ta'sirli endpointda idempotentlik kalitisiz — ikki SMS
"Log — bu print" JSON, sorov_id, niqoblash; shaxsiy ma'lumot va kalit yo'q
"401 va 403 — bir xil" 401 — kimligi noma'lum; 403 — ruxsat yo'q / kalit yaroqsiz
"O'rtacha kechikish yaxshi — hammasi yaxshi" p99 rho 1 ga yaqinlashganda portlaydi

6. Keng tarqalgan xatolar va yechimlari

1. Standart 422 va NaN

python
app = FastAPI()                                                    # ⚠️ NaN -> 500
@app.exception_handler(RequestValidationError)
async def v(request, exc):                                         # ✅ input siz
    return JSONResponse(status_code=422, content={"detail": [
        {"loc": list(e["loc"]), "type": e["type"], "msg": e["msg"]} for e in exc.errors()]})

2. Mo'rt golden test

python
assert p == 0.8268                                                 # ⚠️
assert abs(p - golden) <= 1e-6                                     # ✅

3. Timeout siz model

python
p = model_chaqir(x)                                                # ⚠️ 300 ms ham kutadi
try: p = await asyncio.wait_for(model_chaqir(x), timeout=0.1)      # ✅
except TimeoutError: p = zaxira(x)

4. Versiyasiz kesh kaliti

python
kalit = json.dumps(x)                                              # ⚠️ tartib va versiya
kalit = sha256(json.dumps(x, sort_keys=True) + versiya)            # ✅

5. Shaxsiy ma'lumot logda

python
log.info(f"bashorat {m.telefon} {p}")                              # ⚠️
LOG.info("bashorat", extra={"maydonlar": {"telefon": telefon_niqobla(m.telefon)}})  # ✅

6. Kalit kodda va == bilan

python
if x_api_key == "sk-12345": ...                                    # ⚠️
if hmac.compare_digest(x_api_key.encode(), os.environ[...].encode()): ...  # ✅

7. Hamma uchun bitta limit

python
chelak = TokenChelak(100, 50)                                      # ⚠️ bitta mijoz hammani to'sadi
CHELAKLAR.setdefault(x_api_key, TokenChelak(10, 5))                # ✅ kalit bo'yicha

7. Integratsiya — bu bilim qayerda kerak bo'ladi

  • 18-qism (o'tilgan): persentillar va taqsimot dumlari — kechikishni o'lchash tili
  • 25.6-dars (o'tilgan): LLM API — o'shanda mijoz sifatida retry/backoff/kesh; endi server tomonini qurdik
  • 25.10-dars (o'tilgan): LLM regressiya testlari — golden testlarning boshqa ko'rinishi
  • 27.6-dars (o'tilgan): nazorat namunalari — golden testlarning manbasi
  • 27.7-dars (o'tilgan): FastAPI ilova — bu darsda uni mustahkamladik
  • 27.9-dars: Docker bilan konteynerlash — stdout loglar va muhit o'zgaruvchilari konteynerda tabiiy
  • 27.10-dars: Bulut va deploy strategiyalari — sirlar boshqaruvchisi, avtomasshtablash (rho ni pastda ushlash)
  • 27.11-dars: Monitoring — log va metrikalardan (zaxira ulushi, p99, 4xx/5xx) ogohlantirishlar

8. Eng yaxshi amaliyotlar

  1. Test to'plami besh qatlamli: birlik, kontrakt (orqaga moslik), golden (tolerantlik bilan), chegara holatlar, batch/yakka mosligi — har PR da.

  2. O'z 422 ishlovchingiz — loc, type, msg; kiruvchi qiymat qaytarilmaydi.

  3. Hamma kirish cheklangan: tana (413), batch (422), maydon uzunligi, tezlik (429).

  4. Model chaqiruvi byudjet bilan, zaxira javob oldindan belgilangan va manba bilan belgilanadi.

  5. Kesh kaliti — kanonik kirish + model versiyasi.

  6. Yon ta'sirli endpointlarda idempotentlik kaliti (409 — boshqa tana).

  7. JSON loglar stdout ga: sorov_id har joyda, shaxsiy ma'lumot niqoblangan, kalitlar yo'q.

  8. Kalitlar muhitdan, yo'q bo'lsa ilova ishga tushmaydi; compare_digest; 401/403 aniq.

  9. Rate limit har mijozga alohida, Retry-After bilan.

  10. Sig'imni p95/p99 bo'yicha, rho ~ 0.6-0.7 ga rejalang, yuklama sinovini muntazam o'tkazing.


9. Amaliy topshiriq

Vazifa 1: Bashorat qiling

python
1.  # kontrakt testi nimani tekshiradi?
2.  # yangi ixtiyoriy maydon - orqaga moslikni buzadimi?
3.  # golden testni nega == bilan yozmaymiz?
4.  # standart FastAPI da {"tolov": NaN} qaysi kod qaytaradi? tuzatilganda?
5.  # tana 1 MB bo'lsa qaysi kod?
6.  # timeout bo'lganda javobda nima bo'lishi kerak?
7.  # kesh kalitiga nega model versiyasi kiradi?
8.  # o'sha idempotentlik kaliti boshqa tana bilan kelsa?
9.  # logda telefon qanday ko'rinadi?
10. # X-API-Key yo'q / noto'g'ri - qaysi kodlar?
11. # token chelak (sig'im 10, tezlik 5): t=0 da 15 so'rov - nechtasi o'tadi?
12. # rho = 0.95 da p99 ga nima bo'ladi?
Javoblar
  1. API shartnomasi: endpointlar, javob shakli, eski maydonlar va majburiylar
  2. Yo'q
  3. Mayda suzuvchi nuqta farqlari testni mo'rt qiladi; tolerantlik aniqlikka mos
  4. 500; tuzatilganda 422
  5. 413
  6. Zaxira javob va "manba": "zaxira"
  7. Yangi model chiqqanda eski javoblar ishlatilmasligi uchun
  8. 409 Conflict
  9. +998*****4567
  10. 401 / 403
  11. 10 ta
  12. Keskin o'sadi — rho 1 ga yaqin, navbat uzun

Vazifa 2: Xatolarni tuzating

python
1.  assert r.json()["ehtimol"] == 0.8268

2.  kalit = json.dumps(kirish)

3.  API_KALIT = "sk-live-9f8e7d"

4.  log.info(f"mijoz {m.email} ehtimol {p}")

5.  mijozlar: list[Mijoz]
Javoblar
python
1.  assert abs(r.json()["ehtimol"] - golden) <= 1e-6

2.  kalit = sha256((json.dumps(kirish, sort_keys=True) + versiya).encode()).hexdigest()

3.  API_KALIT = os.environ["MODEL_API_KALITI"]   # yo'q bo'lsa - ishga tushmaslik

4.  LOG.info("bashorat", extra={"maydonlar": {"email": email_niqobla(m.email), "ehtimol": p}})

5.  mijozlar: list[Mijoz] = Field(min_length=1, max_length=100)

Vazifa 3: Kontrakt testini kuchaytiring

1-misoldagi test_orqaga_moslik ga qo'shing: (1) Field chegaralari toraymagan bo'lsin (masalan, oylar uchun maximum kamaymagan), (2) javob sxemasidan (Bashorat) maydon o'chirilmagan bo'lsin. oylar chegarasini le=100 ga o'zgartirgan D variant bilan sinang.

Vazifa 4: Zaxira siyosati

2-misolda zaxira model o'rniga "oxirgi ma'lum natija" siyosatini qo'shing: keshda bu mijoz uchun eski natija bo'lsa (hatto boshqa model versiyasida) — uni "manba": "eski_kesh" bilan qaytaring, bo'lmasa qoida modeli. Uchala siyosatni zaxira javoblardagi o'rtacha xato bo'yicha solishtiring.

Vazifa 5: Log so'rovlari

3-misol loglariga kechikishni qo'shib, 200 ta so'rov yuboring (virtual kechikish 5-50 ms). Loglardan: (1) status bo'yicha taqsimot, (2) har egasi uchun p95 kechikish, (3) eng sekin 3 ta sorov_id ni chiqaring. Nega bu savollarga matnli loglardan javob berish qiyin?

Vazifa 6: Taqsimlangan limit

4-misoldagi CHELAKLAR lug'ati bitta jarayonda yashaydi. Ikki "server" (ikkita FastAPI ilova, umumiy lug'atsiz) orasida so'rovlar navbat bilan taqsimlansa, mijoz aslida qancha so'rov o'tkaza oladi? Umumiy ombor (bitta umumiy lug'at — Redis o'rnida) bilan solishtiring.

Vazifa 7: O'ylash

Model API ning p99 kechikishi 40 ms, SLA esa "p99 < 150 ms". Marketing katta aksiya e'lon qildi — trafik 3 barobar oshishi kutilmoqda. Rahbar so'radi: "hozir o'rtacha yuklanish 30% — demak 3 barobar ham bemalol ko'taramiz, to'g'rimi?"

Javob

Qisqa javob: yo'q, bunday xulosa xavfli. 30% × 3 = 90% — bu rho = 0.9. 4-misoldagi simulyatsiya ko'rsatdiki, rho 0.3 dan 0.9 ga o'tganda p99 chiziqli emas, ko'p barobar o'sadi. 40 ms dan 150 ms dan ortiqqa chiqish ehtimoli yuqori.

1. Nega "30% -> 90%" yetarli emas

  • Navbat nazariyasida kutish vaqti taxminan 1 / (1 - rho) ga proporsional: rho = 0.3 da bu 1.4, rho = 0.9 da — 10. Dum (p99) undan ham tezroq o'sadi.
  • "O'rtacha 30%" — kun bo'yicha o'rtacha. Aksiya boshlanishidagi eng yuqori daqiqada trafik 3 barobardan ham ko'p bo'lishi mumkin.
  • Kechikish taqsimoti dumli: model ba'zan sekinlashadi (2-misol) — yuqori rho da bu sekin so'rovlar navbatni uzoq to'sib qo'yadi.

2. Nima qilish kerak

Qadam Nima beradi
Yuklama sinovi (k6/locust) 1x, 2x, 3x, 4x trafikda Haqiqiy p99 egri chizig'i — taxmin emas
Sig'imni rho ~ 0.6 ga oshirish (ko'proq ishchi/replika) 3x trafikda ham zaxira
Avtomasshtablash 27.10-bob — CPU/kechikish bo'yicha Portlashlarga moslashish
Rate limiting va ustuvorlik Ortiqcha yuklama boshqalarni to'smaydi
Timeout + zaxira javob (2-misol) Eng yomon holatda ham p99 byudjet ichida
Keshni isitish (mashhur mijozlar) Model chaqiruvlari kamayadi

3. Qanday hisobot beriladi

"Hozirgi sig'im bilan 3x trafikda rho ≈ 0.9, simulyatsiya/sinov bo'yicha p99 ≈ X ms — SLA buziladi. 6 ta replika bilan rho ≈ 0.6, p99 ≈ Y ms. Qo'shimcha narx — Z (faraziy, bulut narxlarini rasmiy sahifadan tekshiring)." Raqamlar yuklama sinovidan olinadi.

Xulosa: yuklanish foizi kechikish haqida to'g'ridan-to'g'ri gapirmaydi. Sig'im p99 bo'yicha, yuklama sinovi bilan va zaxira bilan rejalashtiriladi; o'rtacha yuklanishni trafik koeffitsientiga ko'paytirish — navbat nazariyasining eng keng tarqalgan xatosi.

Nimani mustahkamlaydi: 2.4, 2.6, 2.9, 2.12-bo'limlar.


Xulosa

Bu darsda model API uchun testlar to'plamini yozdik va uni ishonchlilik, kuzatuvchanlik va xavfsizlik qatlamlari bilan mustahkamladik — hammasi virtual vaqtda, deterministik.

Eng muhim uch fikr:

  1. Chegara holatlar testlari "hech kim o'ylamagan" xatolarni topadi. 1-misolda FastAPI ning standart validatsiya ishlovchisi NaN va 1e400 uchun 422 o'rniga 500 qaytardi — bu 15 testdan faqat chegara holatlaridagi ikkitasida ko'rindi. input siz o'z ishlovchimiz bilan 15/15. Kontrakt testi ixtiyoriy yangi maydonni orqaga mos deb o'tkazdi, golden test esa model almashganini (0.2529 va 0.7148) darhol ushladi.

  2. Byudjet, zaxira, kesh va idempotentlik — dum va takrorlarga qarshi. 2-misolda timeout va zaxira javob max kechikishni 289.0 dan 101.2 ms ga tushirdi (5.5% so'rov zaxira bilan, o'rtacha 0.133 sifat narxi). Kesh 74.5% so'rovga javob berib, p95 ni 9.0 ms ga tushirdi. Idempotentlik kaliti uchta urinishda 6 o'rniga 2 ta SMS yubordi, katta tana esa validatsiyagacha 413 oldi. 3-misolda JSON loglar sorov_id bilan bog'landi, shaxsiy ma'lumot niqoblandi, kalitlar faqat muhitdan olindi va loglarga tushmadi; 401 va 403 aniq ajratildi.

  3. Kechikish yuklanishga chiziqli emas. 4-misolda p99 rho = 0.6 da 64.4 ms, 0.9 da 150.2 ms, sig'imdan 5% ortiqda esa soniyalar bo'ldi. Token chelak har mijozni alohida chekladi, rate limit ortiqcha yuklamada qabul qilinganlar uchun p99 ni ushlab qoldi, qo'shimcha sig'im esa muammoni rad etishsiz hal qildi.

Keyingi darsda Docker bilan konteynerlash: API, paket va muhitni bitta takrorlanadigan obrazga joylaymiz — Dockerfile tuzilishi, qatlamlar keshi, .dockerignore, root bo'lmagan foydalanuvchi va muhit o'zgaruvchilari orqali konfiguratsiya.

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
27.8-dars: API ni mustahkamlash va testlash — IlmHamroh