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:
Andrey Epifancev 2026-10-11 23:30:30 +04:00
parent 78b350b033
commit 1d5e128bd0
3 changed files with 108 additions and 10 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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