diff --git a/src/agent.py b/src/agent.py index 76a29e1..2bf1d02 100644 --- a/src/agent.py +++ b/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) diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 3075a42..7477583 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -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) diff --git a/tests/test_clients.py b/tests/test_clients.py index f315163..70963a7 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -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"\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