Ошибки Eva и 413 хаба в тексте отказа, защита list_docs (M-1, M-3, M-4, M-7, #1)
- EvaError (Eva вернула `error`) — failed «Eva: <текст>» без повтора, а не «внутренняя ошибка агента». - 413 хаба на пачку страниц — в ошибке названа страница («страница DOC-x больше 16 МБ») или границы пачки. - list_docs останавливается, если Eva не соблюдает slice и ответ не приносит новых записей. - Удалены неиспользуемые Executor.s и Executor.sleep; Executor(eva, hub). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4a410d4b08
commit
c9fd3d6290
41
src/agent.py
41
src/agent.py
@ -327,15 +327,27 @@ class EvaClient(Http):
|
||||
return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields)
|
||||
|
||||
def list_docs(self, flt: list, fields: List[str]) -> List[dict]:
|
||||
"""Весь список страницами по PAGE_SIZE. Если Eva не соблюдает slice (ответ без новых записей), обход
|
||||
останавливается на собранном, а не крутится бесконечно (M-4)."""
|
||||
out, offset = [], 0 # type: List[dict], int
|
||||
seen = set() # type: set
|
||||
while True:
|
||||
chunk = self.call("CmfDocument.list", filter=flt, fields=fields,
|
||||
slice=[offset, offset + self.PAGE_SIZE]) or []
|
||||
if isinstance(chunk, dict): # обёрнутый результат
|
||||
chunk = chunk.get("items") or chunk.get("result") or []
|
||||
out.extend(chunk)
|
||||
fresh = 0
|
||||
for d in chunk:
|
||||
key = json.dumps(d, sort_keys=True, ensure_ascii=False)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(d)
|
||||
fresh += 1
|
||||
if len(chunk) < self.PAGE_SIZE:
|
||||
return out
|
||||
if not fresh:
|
||||
log(" Eva: CmfDocument.list повторяет записи (slice не соблюдается?) — останов на %d" % len(out))
|
||||
return out
|
||||
offset += self.PAGE_SIZE
|
||||
|
||||
def download(self, url: str, limit: int) -> bytes:
|
||||
@ -808,9 +820,8 @@ class HubError(Exception):
|
||||
|
||||
|
||||
class Executor:
|
||||
def __init__(self, eva: EvaClient, hub: Optional[HubClient], settings: dict):
|
||||
self.eva, self.hub, self.s = eva, hub, settings
|
||||
self.sleep = time.sleep
|
||||
def __init__(self, eva: EvaClient, hub: Optional[HubClient]):
|
||||
self.eva, self.hub = eva, hub
|
||||
|
||||
def run(self, cmd: dict) -> dict:
|
||||
self.eva.reset_counters()
|
||||
@ -824,6 +835,8 @@ class Executor:
|
||||
out["retryable"] = e.retryable
|
||||
out["peer"] = "хабом"
|
||||
return out
|
||||
except EvaError as e: # Eva ответила `error` (нет прав, неверный фильтр): повтор не поможет (M-1)
|
||||
return self._out("failed", error="Eva: %s" % e) # текст уже очищен от токена (решение 23)
|
||||
except HttpError as e:
|
||||
out = self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500]))
|
||||
out["retryable"] = e.code >= 500 # исход неизвестен: Runner повторит
|
||||
@ -844,6 +857,19 @@ class Executor:
|
||||
except (ConnectionError, HttpError) as e:
|
||||
raise HubError(e) from None
|
||||
|
||||
def _send_pages(self, command_id, batch: list) -> None:
|
||||
"""Пачка страниц в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом страницы или границами пачки (M-3)."""
|
||||
try:
|
||||
self.hub.wiki_pages(command_id, batch)
|
||||
except HttpError as e:
|
||||
if e.code == 413:
|
||||
what = ("страница %s" % batch[0]["code"] if len(batch) == 1 else
|
||||
"пачка страниц %s…%s (%d шт.)" % (batch[0]["code"], batch[-1]["code"], len(batch)))
|
||||
e = HttpError(413, "%s больше 16 МБ — хаб не принял: %s" % (what, e.text[:300]), e.headers)
|
||||
raise HubError(e) from None
|
||||
except ConnectionError as e:
|
||||
raise HubError(e) from None
|
||||
|
||||
def do_wiki_sync(self, p: dict, cmd: dict) -> dict:
|
||||
"""Дерево страниц Eva от корня → хаб пачками; отчёт — коды в порядке обхода (корень первым). Eva только читается,
|
||||
поэтому после перезапуска команда выполняется заново целиком (design 9)."""
|
||||
@ -874,13 +900,13 @@ class Executor:
|
||||
"html": html, "attachments": attachments, "links": links}
|
||||
psize = len(json.dumps(page, ensure_ascii=False).encode("utf-8")) + 2
|
||||
if batch and (len(batch) >= MAX_PAGES or size + psize > MAX_BATCH_BYTES):
|
||||
self._hub(self.hub.wiki_pages, cmd["id"], batch)
|
||||
self._send_pages(cmd["id"], batch)
|
||||
batch, size = [], 0
|
||||
batch.append(page)
|
||||
size += psize
|
||||
codes.append(code)
|
||||
if batch:
|
||||
self._hub(self.hub.wiki_pages, cmd["id"], batch)
|
||||
self._send_pages(cmd["id"], batch)
|
||||
title = (root.get("name") or "").strip() or root_code
|
||||
return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls})
|
||||
|
||||
@ -1086,8 +1112,7 @@ class Runner:
|
||||
|
||||
def load_settings(self) -> None:
|
||||
self.settings = self.hub.settings()
|
||||
self.executor = Executor(self.eva, self.hub, self.settings)
|
||||
self.executor.sleep = self.sleep
|
||||
self.executor = Executor(self.eva, self.hub)
|
||||
self.eva.pacing = float(self.settings.get("wiki_pacing", 0.2))
|
||||
log("настройки хаба v%s" % self.settings.get("settings_version"))
|
||||
|
||||
|
||||
@ -74,6 +74,26 @@ class EvaClientTest(unittest.TestCase):
|
||||
self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls],
|
||||
[[0, 200], [200, 400], [400, 600]])
|
||||
|
||||
def test_list_slice_ignored_returns_all(self):
|
||||
"""M-4: Eva не поддерживает slice и отдаёт весь список каждый раз — второй ответ без новых записей, стоп."""
|
||||
orig = self.eva.rpc
|
||||
self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None))
|
||||
for i in range(450):
|
||||
self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P")
|
||||
docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"])
|
||||
self.assertEqual(len(docs), 450)
|
||||
self.assertEqual(len(self.eva.api_calls), 2)
|
||||
|
||||
def test_list_slice_ignored_same_page(self):
|
||||
"""M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен."""
|
||||
orig = self.eva.rpc
|
||||
self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=[0, 200]))
|
||||
for i in range(450):
|
||||
self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P")
|
||||
docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"])
|
||||
self.assertEqual([d["code"] for d in docs], ["DOC-%d" % (i + 1) for i in range(200)])
|
||||
self.assertEqual(len(self.eva.api_calls), 2)
|
||||
|
||||
def test_list_unwraps_dict(self):
|
||||
orig = self.eva.rpc
|
||||
self.eva.rpc = lambda m, kw: {"items": orig(m, kw)}
|
||||
|
||||
@ -19,7 +19,7 @@ class WikiFetchTest(unittest.TestCase):
|
||||
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||
client = agent.EvaClient(cfg)
|
||||
client.sleep = no_sleep
|
||||
self.ex = agent.Executor(client, agent.HubClient(cfg), dict(SETTINGS))
|
||||
self.ex = agent.Executor(client, agent.HubClient(cfg))
|
||||
|
||||
def fetch(self, url):
|
||||
return self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7, "url": url}})
|
||||
|
||||
@ -17,7 +17,7 @@ class SyncBase(unittest.TestCase):
|
||||
self.cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||
self.client = agent.EvaClient(self.cfg)
|
||||
self.client.sleep = no_sleep
|
||||
self.ex = agent.Executor(self.client, agent.HubClient(self.cfg), dict(SETTINGS))
|
||||
self.ex = agent.Executor(self.client, agent.HubClient(self.cfg))
|
||||
|
||||
def sync(self, root="DOC-1", label="tz"):
|
||||
cid = self.hub.enqueue("wiki_sync", {"label": label, "root": root})
|
||||
@ -168,6 +168,42 @@ class WikiSyncTest(SyncBase):
|
||||
self.assertIn("422", out["error"])
|
||||
self.assertIn("DOC-1: html", out["error"])
|
||||
|
||||
def test_eva_rpc_error_reported(self):
|
||||
"""M-1: `error` JSON-RPC Eva — failed «Eva: <текст>», без повтора и без «внутренней ошибки агента»."""
|
||||
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
self.eva.rpc_error_next("нет прав, токен eva-secret")
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertEqual(out["error"], "Eva: CmfDocument.get: нет прав, токен ***")
|
||||
self.assertFalse(out.get("retryable"))
|
||||
self.assertEqual(self.hub.pages, [])
|
||||
|
||||
def test_eva_rpc_error_in_runner(self):
|
||||
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
self.eva.rpc_error_next("нет прав")
|
||||
runner = agent.Runner(self.cfg, self.ex.hub, self.client, agent.Journal(os.devnull))
|
||||
runner.executor = self.ex
|
||||
self.assertEqual(runner.run_once({"id": 1, "type": "wiki_sync", "params": {"root": "DOC-1"}})["error"],
|
||||
"Eva: CmfDocument.get: нет прав")
|
||||
|
||||
def test_hub_413_single_page(self):
|
||||
"""M-3: хаб не принял тело (413) — в ошибке названа страница."""
|
||||
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"})
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("страница DOC-1 больше 16 МБ", out["error"])
|
||||
self.assertFalse(out.get("retryable"))
|
||||
|
||||
def test_hub_413_batch(self):
|
||||
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
for i in (2, 3):
|
||||
self.eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i)
|
||||
self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"})
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("пачка страниц DOC-1…DOC-3 (3 шт.) больше 16 МБ", out["error"])
|
||||
|
||||
def test_empty_name_falls_back_to_code(self):
|
||||
self.eva.add_doc(1, "DOC-1", "", project="P") # хаб отклоняет пустой title (stage._check)
|
||||
out = self.sync()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user