fix(hub): проверка Content-Length и предел тела, тик автообновления, узкая обработка ошибок, тесты долгого claim
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
parent
2558248dc9
commit
34d5714898
@ -2,6 +2,7 @@
|
|||||||
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md)."""
|
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md)."""
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@ -15,6 +16,11 @@ from .commands import CommandError
|
|||||||
from .paths import backups_dir as default_backups_dir
|
from .paths import backups_dir as default_backups_dir
|
||||||
from .queue import ClaimedError
|
from .queue import ClaimedError
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
|
||||||
|
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
|
||||||
|
|
||||||
|
|
||||||
class ApiError(Exception):
|
class ApiError(Exception):
|
||||||
def __init__(self, status, error, detail=None):
|
def __init__(self, status, error, detail=None):
|
||||||
@ -34,6 +40,18 @@ class Hub:
|
|||||||
def conn(self):
|
def conn(self):
|
||||||
return db.connect(self.db_path)
|
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):
|
def make_backup(self, label):
|
||||||
conn = self.conn()
|
conn = self.conn()
|
||||||
try:
|
try:
|
||||||
@ -48,6 +66,13 @@ def _int(m, i=1):
|
|||||||
return int(m.group(i))
|
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):
|
def _not_found(obj, what):
|
||||||
if obj is None:
|
if obj is None:
|
||||||
raise ApiError(404, "not_found", what)
|
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):
|
def h_issues(hub, conn, m, q, body):
|
||||||
filters = {k: q.get(k) for k in ISSUE_FILTERS}
|
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):
|
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):
|
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):
|
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):
|
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]}
|
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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
timeout = SOCKET_TIMEOUT
|
||||||
hub: Hub = None # подставляется в make_server
|
hub: Hub = None # подставляется в make_server
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
@ -249,7 +275,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
url = urlparse(self.path)
|
url = urlparse(self.path)
|
||||||
try:
|
try:
|
||||||
query = {k: v[0] for k, v in parse_qs(url.query).items()}
|
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""
|
raw = self.rfile.read(length) if length else b""
|
||||||
for route_method, rx, fn, scope in _COMPILED:
|
for route_method, rx, fn, scope in _COMPILED:
|
||||||
if route_method != method:
|
if route_method != method:
|
||||||
@ -267,8 +298,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
conn = self.hub.conn()
|
conn = self.hub.conn()
|
||||||
try:
|
try:
|
||||||
status, payload = fn(self.hub, conn, match, query, body)
|
status, payload = fn(self.hub, conn, match, query, body)
|
||||||
except (ValueError, TypeError) as e: # нечисловой limit, неверные типы полей
|
|
||||||
raise ApiError(400, "bad_request", str(e))
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return self._send(status, payload)
|
return self._send(status, payload)
|
||||||
@ -299,13 +328,7 @@ def serve(hub: Hub, host="0.0.0.0", port=8790) -> None:
|
|||||||
|
|
||||||
def ticker():
|
def ticker():
|
||||||
while not stop.wait(60):
|
while not stop.wait(60):
|
||||||
conn = hub.conn()
|
hub.tick()
|
||||||
try:
|
|
||||||
queue.maybe_auto_refresh(conn)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
traceback.print_exc()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
threading.Thread(target=ticker, daemon=True).start()
|
threading.Thread(target=ticker, daemon=True).start()
|
||||||
print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}")
|
print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}")
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -151,3 +152,72 @@ def test_bad_requests(hub):
|
|||||||
urllib.request.urlopen(req)
|
urllib.request.urlopen(req)
|
||||||
assert e2.value.code == 400
|
assert e2.value.code == 400
|
||||||
e2.value.close()
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user