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:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
|
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw.decode("utf-8")) if raw else {}
|
status = resp.status
|
||||||
except urllib.error.HTTPError as e:
|
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:
|
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e:
|
||||||
raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None
|
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):
|
class JiraClient(Http):
|
||||||
@ -120,20 +134,24 @@ class JiraClient(Http):
|
|||||||
self.last_call_iso = None # type: Optional[str]
|
self.last_call_iso = None # type: Optional[str]
|
||||||
self.sleep = time.sleep
|
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:
|
def reset_counters(self) -> None:
|
||||||
self.calls, self.rate_limited = 0, 0
|
self.calls, self.rate_limited = 0, 0
|
||||||
|
|
||||||
def call(self, method, path, body=None, params=None):
|
def call(self, method, path, body=None, params=None):
|
||||||
for attempt in range(1, 6):
|
for attempt in range(1, 6):
|
||||||
try:
|
try:
|
||||||
out = super().call(method, path, body, params)
|
return super().call(method, path, body, params)
|
||||||
self.calls += 1
|
|
||||||
self.last_call_at, self.last_call_iso = time.monotonic(), now_iso()
|
|
||||||
return out
|
|
||||||
except HttpError as e:
|
except HttpError as e:
|
||||||
if e.code in (429, 503) and attempt < 5:
|
if e.code in (429, 503) and attempt < 5:
|
||||||
retry = e.headers.get("Retry-After")
|
wait = 60 * attempt
|
||||||
wait = int(retry) if retry and retry.isdigit() else 60 * attempt
|
try:
|
||||||
|
wait = int(e.headers.get("retry-after"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
self.rate_limited += 1
|
self.rate_limited += 1
|
||||||
log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt))
|
log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt))
|
||||||
self.sleep(wait)
|
self.sleep(wait)
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class FakeJira:
|
|||||||
self.links = []
|
self.links = []
|
||||||
self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}]
|
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.seq, self.link_seq, self.comment_seq = 1000, 100, 10
|
||||||
|
self.id_seq = 10000
|
||||||
self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой
|
self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой
|
||||||
self.requests = [] # [(метод, путь)]
|
self.requests = [] # [(метод, путь)]
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
@ -58,12 +59,15 @@ class FakeJira:
|
|||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.server.shutdown()
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
def add(self, key, in_base=True, **fields):
|
def add(self, key, in_base=True, **fields):
|
||||||
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
|
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
|
||||||
"assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [],
|
"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",
|
"issuetype": "Задача", "created": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300",
|
||||||
"sprint": None}
|
"sprint": None}
|
||||||
|
self.id_seq += 1
|
||||||
|
base["id"] = str(self.id_seq)
|
||||||
base.update({c: None for c in CUSTOM})
|
base.update({c: None for c in CUSTOM})
|
||||||
base.update(fields)
|
base.update(fields)
|
||||||
self.issues[key] = base
|
self.issues[key] = base
|
||||||
@ -83,7 +87,7 @@ class FakeJira:
|
|||||||
"duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]},
|
"duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]},
|
||||||
"created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)}
|
"created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)}
|
||||||
fields.update({c: f.get(c) for c in CUSTOM})
|
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):
|
def links_of(self, key):
|
||||||
out = []
|
out = []
|
||||||
@ -118,6 +122,8 @@ class FakeJira:
|
|||||||
code, payload = self.route(method, url.path, q, body)
|
code, payload = self.route(method, url.path, q, body)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % 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)
|
self._send(h, code, payload)
|
||||||
|
|
||||||
def _send(self, h, code, payload, headers=None):
|
def _send(self, h, code, payload, headers=None):
|
||||||
@ -158,6 +164,8 @@ class FakeJira:
|
|||||||
return 201, {}
|
return 201, {}
|
||||||
m = re.match(r"^/rest/api/2/issueLink/(\d+)$", p)
|
m = re.match(r"^/rest/api/2/issueLink/(\d+)$", p)
|
||||||
if m and method == "DELETE":
|
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)]
|
self.links = [l for l in self.links if str(l["id"]) != m.group(1)]
|
||||||
return 204, None
|
return 204, None
|
||||||
m = re.match(r"^/rest/api/2/issue/([^/]+)$", p)
|
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")
|
data = self.jira.get("/rest/api/2/issue/AURORA-1")
|
||||||
self.assertEqual(data["key"], "AURORA-1")
|
self.assertEqual(data["key"], "AURORA-1")
|
||||||
self.assertEqual(self.jira.rate_limited, 2)
|
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):
|
def test_http_error_not_retried(self):
|
||||||
with self.assertRaises(agent.HttpError) as cm:
|
with self.assertRaises(agent.HttpError) as cm:
|
||||||
self.jira.get("/rest/api/2/issue/NOPE-1")
|
self.jira.get("/rest/api/2/issue/NOPE-1")
|
||||||
self.assertEqual(cm.exception.code, 404)
|
self.assertEqual(cm.exception.code, 404)
|
||||||
self.assertIn("Does Not Exist", cm.exception.text)
|
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):
|
def test_gives_up_after_five(self):
|
||||||
self.fake.add("AURORA-1")
|
self.fake.add("AURORA-1")
|
||||||
@ -42,11 +44,81 @@ class ClientsTest(unittest.TestCase):
|
|||||||
with self.assertRaises(agent.HttpError):
|
with self.assertRaises(agent.HttpError):
|
||||||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||||||
self.assertEqual(self.jira.rate_limited, 5)
|
self.assertEqual(self.jira.rate_limited, 5)
|
||||||
|
self.assertEqual(self.jira.calls, 5)
|
||||||
|
|
||||||
def test_basic_auth_header_and_no_secret_in_repr(self):
|
def test_basic_auth_header_and_no_secret_in_repr(self):
|
||||||
self.assertTrue(self.jira.headers["Authorization"].startswith("Basic "))
|
self.assertTrue(self.jira.headers["Authorization"].startswith("Basic "))
|
||||||
self.assertNotIn("secret", repr(self.jira))
|
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):
|
def test_load_config_requires_fields(self):
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user