Використовуйте SDK у застосунку на FastAPI або Django

Python

Один клієнт на робочий процес, його копія, яка пише журнал з ідентифікатором кожного запиту, запуски, передані Slen без очікування в обробнику, і клієнт, який ніколи не переходить через fork.

6 хв читання

Ця стаття — про SDK для Python.

Python SDK не має власної інтеграції з фреймворком: застосунок на FastAPI або Django збирає клієнт і викликає його, як будь-який інший код. Серверу потрібне місце, де збирається клієнт, його копія для кожного запиту й обробник, який не чекає запуску. Ця сторінка показує обидва фреймворки; тести SDK запускають їхній код у тестовому клієнті кожного.

Один клієнт на процес

Сервер працює в кількох робочих процесах, і кожен обслуговує багато запитів. Зберіть у кожному процесі один клієнт і обслуговуйте ним кожен запит цього процесу.

  • Клієнт зберігає свої з’єднання й налаштування, але ніколи — стан виклику, тому один клієнт обслуговує кожен запит свого процесу — його нитки й задачі водночас.

  • Клієнт збирають у тому процесі, який ним користується, і ніколи — під час імпорту модуля: клієнт, зібраний до того, як сервер породить робочі процеси через fork, ділив би між ними свої з’єднання, — нижче.

  • AsyncSlen належить циклу подій, у якому його зібрали, тому сервер на asyncio збирає його всередині цього циклу.

FastAPI

Python
import logging
from collections.abc import AsyncGenerator, MutableMapping
from contextlib import asynccontextmanager
from typing import Any

from fastapi import FastAPI, Request

from slen import AsyncSlen


# One client per worker process, built in the worker's own event loop when it starts and closed when it stops —
# never when the module is imported, so workers forked from one process share no connections.
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    async with AsyncSlen() as slen:
        app.state.slen = slen
        yield


app = FastAPI(lifespan=lifespan)


# Keeps the fields of each event of the SDK and adds the id of the request being served.
class RequestLog(logging.LoggerAdapter[logging.Logger]):
    def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]:
        return msg, {**kwargs, "extra": {**(kwargs.get("extra") or {}), **(self.extra or {})}}


def slen_of(request: Request) -> AsyncSlen:
    # A copy of the worker's client that logs with this request's id; the shared client does not change.
    shared: AsyncSlen = request.app.state.slen
    log = RequestLog(logging.getLogger("orders"), {"http_request_id": request.headers.get("x-request-id", "")})

    return shared.with_logger(log)
  • Клієнт збирається в lifespan застосунку, який кожен робочий процес виконує у своєму циклі подій під час запуску, а async with закриває його, коли процес зупиняється.

  • slen_of() дає кожному запиту копію клієнта — Журнал кожного запиту.

Python
@app.post("/orders/{order_id}/email", status_code=202)
async def email_the_order(order_id: int, request: Request) -> dict[str, str]:
    # The run goes to a worker and the request is answered at once: a handler does not wait for a run.
    run = await slen_of(request).operations.execute_async(
        "generate_email",
        {"messages": [{"role": "user", "content": f"Write a short email: order {order_id} has shipped."}]},
        idempotency_key=f"order-{order_id}:email",
    )

    return {"execution_id": run.execution_id, "status": run.status}
Python
@app.get("/runs/{execution_id}")
async def run_status(execution_id: str, request: Request) -> dict[str, str]:
    # One read of the run; its end arrives as the execution.finished webhook.
    run = await slen_of(request).executions.get(execution_id)

    return {"execution_id": run.execution_id, "status": run.status}

Django

Python
import functools
import logging
from collections.abc import MutableMapping
from typing import Any

from django.http import HttpRequest, JsonResponse

from slen import Slen


# One client per worker process, built by the first request the process serves — never when the module is
# imported or the app is ready, so workers forked after loading the application share no connections.
@functools.cache
def shared_slen() -> Slen:
    return Slen()


# Keeps the fields of each event of the SDK and adds the id of the request being served.
class RequestLog(logging.LoggerAdapter[logging.Logger]):
    def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]:
        return msg, {**kwargs, "extra": {**(kwargs.get("extra") or {}), **(self.extra or {})}}


def slen_of(request: HttpRequest) -> Slen:
    # A copy of the process's client that logs with this request's id; the shared client does not change.
    log = RequestLog(logging.getLogger("orders"), {"http_request_id": request.headers.get("X-Request-Id", "")})

    return shared_slen().with_logger(log)
  • shared_slen() збирає клієнт на першому запиті, який обслуговує процес, а functools.cache зберігає його для решти — не під час імпорту модуля й не тоді, коли застосунок готовий: сервер, який завантажує застосунок до того, як породить робочі процеси, робить це до fork.

  • Подання Django синхронне, тому воно викликає Slen.

Python
def email_the_order(request: HttpRequest, order_id: int) -> JsonResponse:
    # The run goes to a worker and the request is answered at once: a view does not wait for a run.
    run = slen_of(request).operations.execute_async(
        "generate_email",
        {"messages": [{"role": "user", "content": f"Write a short email: order {order_id} has shipped."}]},
        idempotency_key=f"order-{order_id}:email",
    )

    return JsonResponse({"execution_id": run.execution_id, "status": run.status}, status=202)
Python
def run_status(request: HttpRequest, execution_id: str) -> JsonResponse:
    # One read of the run; its end arrives as the execution.finished webhook.
    run = slen_of(request).executions.get(execution_id)

    return JsonResponse({"execution_id": run.execution_id, "status": run.status})
Python
from django.urls import path
from django.views.decorators.http import require_GET, require_POST

urlpatterns = [
    path("orders/<int:order_id>/email", require_POST(email_the_order)),
    path("runs/<str:execution_id>", require_GET(run_status)),
]

Журнал кожного запиту

Журнал SDK іде в один логер на весь процес. Щоб знайти записи одного запиту, with_logger() дає кожному запиту копію спільного клієнта, яка пише у свій логер. Копія надсилає запити через ті самі з’єднання й записує спани тим самим записувачем; власних з’єднань у неї немає, а спільний клієнт лишається таким, яким був, — Журнал.

  • RequestLog додає до кожного запису ідентифікатор запиту, який обслуговує ваш сервер, і зберігає поля, які несе кожна подія SDK. Звичайний logging.LoggerAdapter до Python 3.13 замінює extra виклику своїм, і поля події загубилися б. Від 3.13 звичайний адаптер із merge_extra=True об’єднує їх сам; приклади лишають RequestLog, бо SDK працює і на 3.11 та 3.12.

  • Ваш ідентифікатор іде під http_request_id. request_id запису SDK — ідентифікатор відповіді Slen, той, за яким його підтримка знаходить запит.

Не чекайте в обробнику

executions.wait() тримає запит вашого викликача відкритим, поки триває запуск, і весь цей час займає нитку чи задачу робочого процесу. Обробник передає запуск Slen через operations.execute_async() і одразу відповідає 202. Закінчення запуску приходить вебхуком execution.finished — Вебхуки, — або його чекає задача вашої черги. Читання статусу, executions.get(), — один запит, і йому місце в обробнику.

Запуск, якого обробник усе ж чекає, operations.execute(), отримує свій бюджет: timeout і retries виклику, а не хвилини клієнта — Повтори й тайм-аути.

Закрийте клієнт

async with AsyncSlen() as slen: у lifespan закриває з’єднання клієнта, коли робочий процес зупиняється, а close() робить те саме там, де такого блоку немає. Копія, зроблена with_logger(), власних з’єднань не має, тому її закриття нічого не закриває. httpx.Client, який ви передали як http_client, лишається відкритим: закриваєте його ви.

Fork і --preload

Клієнт, зібраний до fork, ділить свої з’єднання з кожним нащадком, і тоді процеси читають і пишуть ті самі сокети. gunicorn --preload і будь-який сервер, який завантажує застосунок до того, як породить робочі процеси, роблять це з клієнтом, зібраним під час імпорту модуля. Збирайте клієнт у робочому процесі, як обидва приклади. В інший процес клієнт теж не переходить: його серіалізація через pickle чи копіювання кидає TypeError.

Що читати далі