From 34d5714898c18cdf299719553deb57a13078f4c2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:19:54 +0400 Subject: [PATCH] =?UTF-8?q?fix(hub):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20Content-Length=20=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B5=D0=BB=20=D1=82=D0=B5=D0=BB=D0=B0,=20=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F,=20=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA,=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/api.py | 49 ++++++++++++++++++++++--------- tests/test_api.py | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 01771fe..dd71448 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -2,6 +2,7 @@ Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).""" import hmac import json +import logging import re import threading import time @@ -15,6 +16,11 @@ from .commands import CommandError from .paths import backups_dir as default_backups_dir from .queue import ClaimedError +log = logging.getLogger(__name__) + +MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт +SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит + class ApiError(Exception): def __init__(self, status, error, detail=None): @@ -34,6 +40,18 @@ class Hub: def conn(self): return db.connect(self.db_path) + def tick(self): + """Один шаг фонового автообновления; любой сбой логируется и не прерывает цикл.""" + conn = None + try: + conn = self.conn() + queue.maybe_auto_refresh(conn) + except Exception: # noqa: BLE001 + log.exception("автообновление: сбой тика") + finally: + if conn: + conn.close() + def make_backup(self, label): conn = self.conn() try: @@ -48,6 +66,13 @@ def _int(m, i=1): return int(m.group(i)) +def _int_param(q, name, default): + try: + return int(q.get(name, default)) + except (TypeError, ValueError): + raise ApiError(400, "bad_param", f"{name}: ожидается целое число") + + def _not_found(obj, what): if obj is None: raise ApiError(404, "not_found", what) @@ -101,7 +126,7 @@ ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "la def h_issues(hub, conn, m, q, body): filters = {k: q.get(k) for k in ISSUE_FILTERS} - return 200, {"issues": mirror.list_issues(conn, filters, int(q.get("limit", 50)), int(q.get("offset", 0)))} + return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0))} def h_issue(hub, conn, m, q, body): @@ -126,7 +151,7 @@ def h_batches_post(hub, conn, m, q, body): def h_batches_get(hub, conn, m, q, body): - return 200, {"batches": queue.list_batches(conn, int(q.get("limit", 20)))} + return 200, {"batches": queue.list_batches(conn, _int_param(q, "limit", 20))} def h_batch_get(hub, conn, m, q, body): @@ -175,7 +200,7 @@ def h_agent_get(hub, conn, m, q, body): def h_sync_runs(hub, conn, m, q, body): - rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (int(q.get("limit", 20)),)).fetchall() + rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (_int_param(q, "limit", 20),)).fetchall() return 200, {"runs": [dict(r) for r in rows]} @@ -220,6 +245,7 @@ _COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] class Handler(BaseHTTPRequestHandler): + timeout = SOCKET_TIMEOUT hub: Hub = None # подставляется в make_server def do_GET(self): @@ -249,7 +275,12 @@ class Handler(BaseHTTPRequestHandler): url = urlparse(self.path) try: query = {k: v[0] for k, v in parse_qs(url.query).items()} - length = int(self.headers.get("Content-Length") or 0) + raw_len = (self.headers.get("Content-Length") or "0").strip() + if not raw_len.isascii() or not raw_len.isdigit(): + raise ApiError(400, "bad_content_length", "Content-Length должен быть неотрицательным целым") + length = int(raw_len) + if length > MAX_BODY: + raise ApiError(413, "too_large", f"тело больше {MAX_BODY} байт") raw = self.rfile.read(length) if length else b"" for route_method, rx, fn, scope in _COMPILED: if route_method != method: @@ -267,8 +298,6 @@ class Handler(BaseHTTPRequestHandler): conn = self.hub.conn() try: status, payload = fn(self.hub, conn, match, query, body) - except (ValueError, TypeError) as e: # нечисловой limit, неверные типы полей - raise ApiError(400, "bad_request", str(e)) finally: conn.close() return self._send(status, payload) @@ -299,13 +328,7 @@ def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: def ticker(): while not stop.wait(60): - conn = hub.conn() - try: - queue.maybe_auto_refresh(conn) - except Exception: # noqa: BLE001 - traceback.print_exc() - finally: - conn.close() + hub.tick() threading.Thread(target=ticker, daemon=True).start() print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}") diff --git a/tests/test_api.py b/tests/test_api.py index 0469c86..e404d1d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,3 +1,4 @@ +import json import threading import pytest @@ -151,3 +152,72 @@ def test_bad_requests(hub): urllib.request.urlopen(req) assert e2.value.code == 400 e2.value.close() + + +# --- ревью Task 6 -------------------------------------------------------------- + +def _raw(hub, headers, path="/batches", method="POST"): + import http.client + host, port = hub["base"].removeprefix("http://").split(":") + c = http.client.HTTPConnection(host, int(port), timeout=10) + try: + c.putrequest(method, path) + c.putheader("Authorization", "Bearer " + hub["client"].token) + for k, v in headers.items(): + c.putheader(k, v) + c.endheaders() + r = c.getresponse() + return r.status, json.loads(r.read().decode("utf-8")) + finally: + c.close() + + + +@pytest.mark.parametrize("value,status,error", [("-5", 400, "bad_content_length"), ("abc", 400, "bad_content_length"), + (str(api.MAX_BODY + 1), 413, "too_large")]) +def test_content_length_checked(hub, value, status, error): + got, payload = _raw(hub, {"Content-Length": value}) + assert got == status and payload["error"] == error + + +def test_numeric_param_400_internal_bug_500(hub, monkeypatch): + with pytest.raises(HubError) as e: + hub["client"].get("/batches", limit="x") + assert e.value.status == 400 + + def boom(*a): + raise TypeError("внутренний баг") + monkeypatch.setattr(api, "_COMPILED", [("GET", api.re.compile(r"^/issues$"), boom, "client")]) + with pytest.raises(HubError) as e: + hub["client"].get("/issues") + assert e.value.status == 500 and e.value.error == "internal" + with pytest.raises(HubError) as e: # сервер жив + hub["client"].get("/issues") + assert e.value.status == 500 + + +def test_tick_survives_connection_failure(hub, monkeypatch): + def broken(): + raise OSError("диск недоступен") + monkeypatch.setattr(hub["hub"], "conn", broken) + hub["hub"].tick() # не бросает + monkeypatch.undo() + hub["hub"].tick() + + +def test_claim_long_poll_returns_new_command(hub): + import time + hub["hub"].claim_wait = 4 + threading.Timer(0.5, lambda: hub["client"].post("/batches", {"title": "x", "commands": [ + {"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})).start() + t0 = time.monotonic() + cmd = hub["agent"].post("/agent/claim", {})["command"] + assert cmd and cmd["type"] == "ensure_fields" and time.monotonic() - t0 < 4 + + +def test_claim_long_poll_times_out_with_null(hub): + import time + hub["hub"].claim_wait = 1.5 + t0 = time.monotonic() + assert hub["agent"].post("/agent/claim", {})["command"] is None + assert 1.0 <= time.monotonic() - t0 < 4