Files
robotex/src/main.py
T
protokeyandClaude Opus 5.5 1a4bd8d672 feature: API-ключи клиентов и журнал использования в Postgres (#4, #17)
- Postgres в compose; пакет db (SQLAlchemy async): clients, windows, requests.
- Все эндпоинты, кроме /health, — только с ключом в X-API-Key, иначе 401; без
  выпущенных ключей доступа нет. В базе — только sha256 ключа.
- Задача и окно сессии привязаны к клиенту: чужой ключ их не видит.
- Воркер пишет журнал: окно (открыто, последнее обращение, закрыто) и задачу
  (в очереди, начало, конец, итог, данные без секретов). Сбой записи не роняет задачу.
- python -m db.cli create|list|revoke|stats; make key-create/key-list/key-revoke/stats.
- smoke и loadtest с ключом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:48:48 +04:00

203 lines
8.4 KiB
Python

"""
Сервис api (#78): регистрирует задачи в очереди RabbitMQ и отдаёт результаты из Redis. Chrome здесь
нет — задачи выполняет сервис worker (src/worker).
* POST /v2/tasks, GET /v2/tasks/{id}, DELETE /v2/sessions/current — асинхронный API (#80, #82).
* POST /v1/solve — прежний синхронный контракт поверх той же очереди (#84).
Все эндпоинты, кроме /health, — только с ключом клиента в X-API-Key (#4); ключи — в Postgres (db.cli).
Задача и окно сессии видны только клиенту, который их создал.
"""
import asyncio
import logging
import os
import time
import uuid
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Cookie, Depends, FastAPI, HTTPException, Response, Security
from fastapi.responses import JSONResponse
from fastapi.security import APIKeyHeader
from starlette.concurrency import run_in_threadpool
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
from api.schemas import SolveRequest, SolveResponse, TaskCreated, TaskRequest, TaskResponse # noqa: E402
from db import clients # noqa: E402
from db.engine import connect as connect_db # noqa: E402
from db.models import Client # noqa: E402
from tasks import broker as queue # noqa: E402
from tasks.sessions import new_session_id, parse_session_id # noqa: E402
from tasks.store import Status, TaskStore, connect # noqa: E402
logger = logging.getLogger(__name__)
RETRY_AFTER = os.getenv("NO_SLOTS_RETRY_AFTER", "5")
# сколько /v1/solve ждёт, пока воркер возьмёт задачу, прежде чем ответить 503 no_slots
V1_QUEUE_WAIT = float(os.getenv("V1_QUEUE_WAIT", "30"))
POLL_INTERVAL = 0.2
store = TaskStore(connect())
database = connect_db()
@asynccontextmanager
async def lifespan(app: FastAPI):
await database.init()
yield
await database.close()
app = FastAPI(lifespan=lifespan)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def current_client(key: Annotated[str | None, Security(api_key_header)]) -> Client:
client = await clients.authenticate(database, key)
if client is None:
raise HTTPException(401, "invalid or missing X-API-Key")
return client
ClientDep = Annotated[Client, Depends(current_client)]
NO_SLOTS = {"status": "err", "code": "no_slots", "error": "No slots available"}
def no_slots_response() -> JSONResponse:
return JSONResponse(status_code=503, headers={"Retry-After": RETRY_AFTER}, content=NO_SLOTS)
def use_session(response: Response, cookie: str | None) -> str:
"""session_id из cookie или новый; cookie ставится в ответ."""
session_id = parse_session_id(cookie)
if cookie and not session_id:
logger.warning("invalid session_id cookie, issuing a new session")
session_id = session_id or new_session_id()
response.set_cookie("session_id", session_id)
return session_id
async def dispatch(message) -> None:
# pika блокирующий — отправка в пуле потоков, чтобы не останавливать event loop
await run_in_threadpool(queue.broker.enqueue, message)
async def submit(payload: TaskRequest, session_id: str, window: dict | None, client: Client, api: str) -> str:
"""Зарегистрировать задачу и поставить её в очередь: в персональную очередь воркера, где открыто
окно сессии (#83), иначе — в общую."""
task_id = str(uuid.uuid4())
await store.create(task_id, session_id, payload.url, client.id)
target = queue.personal_queue(window["worker_id"]) if window else queue.COMMON_QUEUE
await dispatch(queue.task_message(
task_id, session_id, payload.model_dump(mode="json"), payload.browser_idle_timeout, target,
meta={"client_id": client.id, "api": api},
))
logger.info("task %s [%s] -> %s: %s", task_id, session_id, target, payload.url or "<current page>")
return task_id
async def free_slots() -> int:
return sum(w["free_slots"] for w in await store.workers())
async def client_window(session_id: str, client: Client) -> dict | None:
"""Открытое окно сессии, если оно этого клиента. Чужое окно — как отсутствующее."""
window = await store.live_window(session_id)
return window if window and window.get("client_id") == str(client.id) else None
@app.get("/")
@app.get("/health")
async def health():
workers = await store.workers()
return {
"status": "ok",
"browser_slots": sum(w["browser_slots"] for w in workers),
"free_slots": sum(w["free_slots"] for w in workers),
"workers": workers,
}
@app.post("/v2/tasks", status_code=202, response_model=TaskCreated)
async def create_task(
payload: TaskRequest,
response: Response,
client: ClientDep,
session_id: Annotated[str | None, Cookie()] = None,
):
session_id = use_session(response, session_id)
window = await client_window(session_id, client)
if payload.url is None and window is None:
raise HTTPException(422, "url is required: the session has no open browser window")
task_id = await submit(payload, session_id, window, client, "v2")
return {"task_id": task_id, "status": Status.QUEUED, "url": f"/v2/tasks/{task_id}"}
@app.get("/v2/tasks/{task_id}", response_model=TaskResponse, response_model_exclude_none=True)
async def get_task(task_id: str, client: ClientDep, session_id: Annotated[str | None, Cookie()] = None):
# задача видна только своему клиенту и своей сессии; чужая и несуществующая неразличимы
task = await store.get(task_id) if parse_session_id(task_id) else None
if not task or task["session_id"] != parse_session_id(session_id) or task.get("client_id") != str(client.id):
raise HTTPException(404, "task not found")
return {"task_id": task_id, "status": task["status"], "error": task.get("error"), "result": task.get("result")}
@app.delete("/v2/sessions/current")
async def close_session(client: ClientDep, session_id: Annotated[str | None, Cookie()] = None):
"""Закрыть окно сессии досрочно и освободить слот."""
session_id = parse_session_id(session_id)
window = await store.get_session(session_id) if session_id else None
if not window or window.get("client_id") != str(client.id):
return {"closed": False}
await store.delete_session(session_id)
if await store.worker_alive(window["worker_id"]):
await dispatch(queue.close_message(session_id, queue.personal_queue(window["worker_id"])))
return {"closed": True}
@app.post("/v1/solve", response_model=SolveResponse, response_model_exclude_none=True)
async def solve(
payload: SolveRequest,
response: Response,
client: ClientDep,
session_id: Annotated[str | None, Cookie()] = None,
):
"""Синхронный контракт v1: задача ставится в ту же очередь, ответ — когда воркер её выполнит.
Если свободных слотов нет или воркер не взял задачу за V1_QUEUE_WAIT — 503, как и раньше."""
started = time.monotonic()
session_id = use_session(response, session_id)
window = await client_window(session_id, client)
if window is None and await free_slots() <= 0:
logger.warning("no free browser slots, rejecting %s", payload.url)
return no_slots_response()
task_id = await submit(TaskRequest.model_validate(payload.model_dump()), session_id, window, client, "v1")
while True:
task = await store.get(task_id)
if task is None:
result = {"status": "err", "code": "request_failed", "error": "task expired"}
break
if task["status"] == Status.DONE:
result = task["result"]
break
if task["status"] in (Status.FAILED, Status.CANCELLED):
result = {"status": "err", "code": "request_failed", "error": task.get("error") or task["status"]}
break
if (
task["status"] == Status.QUEUED
and time.monotonic() - started > V1_QUEUE_WAIT
and await store.cancel(task_id)
):
logger.warning("task %s was not picked up in %ss, rejecting", task_id, V1_QUEUE_WAIT)
return no_slots_response()
await asyncio.sleep(POLL_INTERVAL)
result.setdefault("actions", [])
result["duration_ms"] = int((time.monotonic() - started) * 1000)
return result