fix(agent): счётчики на любой ответ, Retry-After без учёта регистра, не-JSON 200, закрытие ответов, правки фейка
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
parent
78b350b033
commit
1d5e128bd0
34
src/agent.py
34
src/agent.py
@ -84,11 +84,25 @@ class Http:
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raise HttpError(e.code, e.read().decode("utf-8", "replace"), dict(e.headers)) from None
|
||||
try:
|
||||
text = e.read().decode("utf-8", "replace")
|
||||
headers = {k.lower(): v for k, v in e.headers.items()}
|
||||
finally:
|
||||
e.close()
|
||||
self._touch()
|
||||
raise HttpError(e.code, text, headers) from None
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e:
|
||||
raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None
|
||||
self._touch()
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||||
except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError
|
||||
raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None
|
||||
|
||||
def _touch(self) -> None:
|
||||
"""Вызывается на каждый полученный HTTP-ответ (любой код)."""
|
||||
|
||||
|
||||
class JiraClient(Http):
|
||||
@ -120,20 +134,24 @@ class JiraClient(Http):
|
||||
self.last_call_iso = None # type: Optional[str]
|
||||
self.sleep = time.sleep
|
||||
|
||||
def _touch(self) -> None:
|
||||
self.calls += 1
|
||||
self.last_call_at, self.last_call_iso = time.monotonic(), now_iso()
|
||||
|
||||
def reset_counters(self) -> None:
|
||||
self.calls, self.rate_limited = 0, 0
|
||||
|
||||
def call(self, method, path, body=None, params=None):
|
||||
for attempt in range(1, 6):
|
||||
try:
|
||||
out = super().call(method, path, body, params)
|
||||
self.calls += 1
|
||||
self.last_call_at, self.last_call_iso = time.monotonic(), now_iso()
|
||||
return out
|
||||
return super().call(method, path, body, params)
|
||||
except HttpError as e:
|
||||
if e.code in (429, 503) and attempt < 5:
|
||||
retry = e.headers.get("Retry-After")
|
||||
wait = int(retry) if retry and retry.isdigit() else 60 * attempt
|
||||
wait = 60 * attempt
|
||||
try:
|
||||
wait = int(e.headers.get("retry-after"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
self.rate_limited += 1
|
||||
log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt))
|
||||
self.sleep(wait)
|
||||
|
||||
@ -28,6 +28,7 @@ class FakeJira:
|
||||
self.links = []
|
||||
self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}]
|
||||
self.seq, self.link_seq, self.comment_seq = 1000, 100, 10
|
||||
self.id_seq = 10000
|
||||
self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой
|
||||
self.requests = [] # [(метод, путь)]
|
||||
self.lock = threading.Lock()
|
||||
@ -58,12 +59,15 @@ class FakeJira:
|
||||
|
||||
def stop(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
def add(self, key, in_base=True, **fields):
|
||||
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
|
||||
"assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [],
|
||||
"issuetype": "Задача", "created": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300",
|
||||
"sprint": None}
|
||||
self.id_seq += 1
|
||||
base["id"] = str(self.id_seq)
|
||||
base.update({c: None for c in CUSTOM})
|
||||
base.update(fields)
|
||||
self.issues[key] = base
|
||||
@ -83,7 +87,7 @@ class FakeJira:
|
||||
"duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]},
|
||||
"created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)}
|
||||
fields.update({c: f.get(c) for c in CUSTOM})
|
||||
return {"key": key, "id": str(10000 + sorted(self.issues).index(key)), "fields": fields}
|
||||
return {"key": key, "id": f["id"], "fields": fields}
|
||||
|
||||
def links_of(self, key):
|
||||
out = []
|
||||
@ -118,6 +122,8 @@ class FakeJira:
|
||||
code, payload = self.route(method, url.path, q, body)
|
||||
except KeyError as e:
|
||||
code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]}
|
||||
except Exception as e: # некорректный запрос к фейку
|
||||
code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]}
|
||||
self._send(h, code, payload)
|
||||
|
||||
def _send(self, h, code, payload, headers=None):
|
||||
@ -158,6 +164,8 @@ class FakeJira:
|
||||
return 201, {}
|
||||
m = re.match(r"^/rest/api/2/issueLink/(\d+)$", p)
|
||||
if m and method == "DELETE":
|
||||
if not any(str(l["id"]) == m.group(1) for l in self.links):
|
||||
return 404, {"errorMessages": ["Issue link does not exist: %s" % m.group(1)]}
|
||||
self.links = [l for l in self.links if str(l["id"]) != m.group(1)]
|
||||
return 204, None
|
||||
m = re.match(r"^/rest/api/2/issue/([^/]+)$", p)
|
||||
|
||||
@ -28,13 +28,15 @@ class ClientsTest(unittest.TestCase):
|
||||
data = self.jira.get("/rest/api/2/issue/AURORA-1")
|
||||
self.assertEqual(data["key"], "AURORA-1")
|
||||
self.assertEqual(self.jira.rate_limited, 2)
|
||||
self.assertEqual(self.jira.calls, 1)
|
||||
self.assertEqual(self.jira.calls, 3) # считаются все полученные ответы, включая 429/503
|
||||
|
||||
def test_http_error_not_retried(self):
|
||||
with self.assertRaises(agent.HttpError) as cm:
|
||||
self.jira.get("/rest/api/2/issue/NOPE-1")
|
||||
self.assertEqual(cm.exception.code, 404)
|
||||
self.assertIn("Does Not Exist", cm.exception.text)
|
||||
self.assertEqual(self.jira.calls, 1)
|
||||
self.assertIsNotNone(self.jira.last_call_at)
|
||||
|
||||
def test_gives_up_after_five(self):
|
||||
self.fake.add("AURORA-1")
|
||||
@ -42,11 +44,81 @@ class ClientsTest(unittest.TestCase):
|
||||
with self.assertRaises(agent.HttpError):
|
||||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||||
self.assertEqual(self.jira.rate_limited, 5)
|
||||
self.assertEqual(self.jira.calls, 5)
|
||||
|
||||
def test_basic_auth_header_and_no_secret_in_repr(self):
|
||||
self.assertTrue(self.jira.headers["Authorization"].startswith("Basic "))
|
||||
self.assertNotIn("secret", repr(self.jira))
|
||||
|
||||
def test_retry_after_lowercase_header(self):
|
||||
waits = []
|
||||
self.jira.sleep = waits.append
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((429, {"retry-after": "7"}))
|
||||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||||
self.assertEqual(waits, [7])
|
||||
|
||||
def test_retry_after_not_a_number(self):
|
||||
waits = []
|
||||
self.jira.sleep = waits.append
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((503, {"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}))
|
||||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||||
self.assertEqual(waits, [60])
|
||||
|
||||
def test_bearer_header(self):
|
||||
cfg = jira_cfg(self.fake)
|
||||
cfg["jira"]["auth"] = {"type": "bearer", "token": "tok123"}
|
||||
self.assertEqual(agent.JiraClient(cfg).headers["Authorization"], "Bearer tok123")
|
||||
|
||||
def test_unreachable_jira_raises_connection_error(self):
|
||||
cfg = jira_cfg(self.fake)
|
||||
cfg["jira"]["base_url"] = "http://127.0.0.1:1"
|
||||
with self.assertRaises(ConnectionError):
|
||||
agent.JiraClient(cfg).get("/rest/api/2/myself")
|
||||
|
||||
def test_non_json_200_becomes_http_error(self):
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
body = b"<html>\xff\xfe not json"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
srv = HTTPServer(("127.0.0.1", 0), H)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
try:
|
||||
cfg = jira_cfg(self.fake)
|
||||
cfg["jira"]["base_url"] = "http://127.0.0.1:%d" % srv.server_port
|
||||
jira = agent.JiraClient(cfg)
|
||||
with self.assertRaises(agent.HttpError) as cm:
|
||||
jira.get("/x")
|
||||
self.assertEqual(cm.exception.code, 200)
|
||||
self.assertEqual(jira.calls, 1)
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
def test_secret_not_in_log_output(self):
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
buf = io.StringIO()
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||||
with redirect_stdout(buf):
|
||||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||||
agent.log("проверка")
|
||||
out = buf.getvalue()
|
||||
self.assertNotIn("secret", out)
|
||||
self.assertNotIn(self.jira.headers["Authorization"], out)
|
||||
|
||||
def test_load_config_requires_fields(self):
|
||||
import json
|
||||
import os
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user