From ad08c17d83bd78b3fe85e8ce9b0cd074dc9df28e Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Mon, 24 Aug 2026 14:54:39 +0700 Subject: [PATCH] =?UTF-8?q?fix(adapters):=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D0=B8=D0=BA=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=B0=D0=B4=D0=B0=D0=BB=20=D1=81=D0=B0=D0=BC,?= =?UTF-8?q?=20=D1=81=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=8F=D1=89=D1=83=D1=8E=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено при проверке Grok на живых данных владельца. Вызов падал с AttributeError: 'str' object has no attribute 'get' — grok_adapter.py:103. Поле error провайдеры отдают то объектом {"message": ...}, то строкой. Код безусловно звал .get у результата, и на строковой форме ОБРАБОТЧИК ОШИБОК ПАДАЛ САМ: сбой происходил ровно там, где обрабатывался другой сбой, маршрутизация обрывалась вместо перехода к резерву, а настоящая причина терялась. После правки причина видна: Grok API Error (403): The OAuth2 access token could not be validated. То есть у Grok просто протух токен, а выглядело как поломка кода. Та же конструкция стояла ещё в пяти адаптерах: claude, codex, opencode, deepseek, local. Разбор вынесен в base_adapter.extract_api_error_message, все шесть переведены на него. Тест проверяет обе формы ответа и отдельно следит, чтобы копии хрупкой конструкции не вернулись. Тесты: 411 passed, ruff чисто. Co-Authored-By: Claude Opus 5 --- .../router/adapters/base_adapter.py | 26 +++++++++ .../router/adapters/claude_adapter.py | 4 +- .../router/adapters/codex_adapter.py | 4 +- .../router/adapters/deepseek_adapter.py | 4 +- .../router/adapters/grok_adapter.py | 10 +++- .../router/adapters/local_adapter.py | 4 +- .../router/adapters/opencode_adapter.py | 4 +- tests/test_adapter_error_parsing.py | 54 +++++++++++++++++++ 8 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 tests/test_adapter_error_parsing.py diff --git a/src/antigravity_provider/router/adapters/base_adapter.py b/src/antigravity_provider/router/adapters/base_adapter.py index 7eea45b..9ba6111 100644 --- a/src/antigravity_provider/router/adapters/base_adapter.py +++ b/src/antigravity_provider/router/adapters/base_adapter.py @@ -1,6 +1,8 @@ """Base provider adapter interface for multi-provider router.""" from __future__ import annotations +import json + from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any, Dict, List, Optional @@ -49,3 +51,27 @@ class BaseProviderAdapter(ABC): def release(self, profile: RouterProfileConfig) -> None: """Clean up any ephemeral resources for this profile.""" pass + + +def extract_api_error_message(raw_err: str) -> str: + """Достать текст ошибки из ответа провайдера любой формы. + + Провайдеры отдают поле ``error`` то объектом ``{"message": ...}``, то + просто строкой. Прежний код безусловно звал ``.get`` у результата, и на + строковой форме ОБРАБОТЧИК ОШИБОК ПАДАЛ САМ с AttributeError. Настоящая + причина отказа терялась, а маршрутизация обрывалась вместо перехода к + резервному профилю — то есть сбой случался ровно там, где обрабатывался + другой сбой. Найдено на живом Grok владельца. + """ + try: + parsed = json.loads(raw_err) + except Exception: + return raw_err + + if not isinstance(parsed, dict): + return raw_err + + field = parsed.get("error", raw_err) + if isinstance(field, dict): + return str(field.get("message") or raw_err) + return str(field) or raw_err diff --git a/src/antigravity_provider/router/adapters/claude_adapter.py b/src/antigravity_provider/router/adapters/claude_adapter.py index 4825fad..8681ea6 100644 --- a/src/antigravity_provider/router/adapters/claude_adapter.py +++ b/src/antigravity_provider/router/adapters/claude_adapter.py @@ -10,7 +10,7 @@ import urllib.request from typing import Any, Dict, List, Optional from ..router_config import RouterProfileConfig -from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification +from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message logger = logging.getLogger(__name__) @@ -110,7 +110,7 @@ class ClaudeAdapter(BaseProviderAdapter): err_json = json.loads(raw_err) except Exception: err_json = {"error": {"message": raw_err}} - err_msg = err_json.get("error", {}).get("message", raw_err) + err_msg = extract_api_error_message(raw_err) raise RuntimeError(f"Claude API Error ({http_err.code}): {err_msg}") from http_err except Exception as e: raise RuntimeError(f"Claude Transport Error: {e}") from e diff --git a/src/antigravity_provider/router/adapters/codex_adapter.py b/src/antigravity_provider/router/adapters/codex_adapter.py index 165672c..f00fa79 100644 --- a/src/antigravity_provider/router/adapters/codex_adapter.py +++ b/src/antigravity_provider/router/adapters/codex_adapter.py @@ -10,7 +10,7 @@ import urllib.error from typing import Any, Dict, List, Optional from ..router_config import RouterProfileConfig -from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification +from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message logger = logging.getLogger(__name__) @@ -112,7 +112,7 @@ class CodexAdapter(BaseProviderAdapter): err_json = json.loads(raw_err) except Exception: err_json = {"error": {"message": raw_err}} - err_msg = err_json.get("error", {}).get("message", raw_err) + err_msg = extract_api_error_message(raw_err) raise RuntimeError(f"Codex API Error ({http_err.code}): {err_msg}") from http_err except Exception as e: raise RuntimeError(f"Codex Transport Error: {e}") from e diff --git a/src/antigravity_provider/router/adapters/deepseek_adapter.py b/src/antigravity_provider/router/adapters/deepseek_adapter.py index dcacf6a..7e0abbb 100644 --- a/src/antigravity_provider/router/adapters/deepseek_adapter.py +++ b/src/antigravity_provider/router/adapters/deepseek_adapter.py @@ -10,7 +10,7 @@ import urllib.request from typing import Any from ..router_config import RouterProfileConfig -from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification +from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message logger = logging.getLogger("hermes.router.adapter.deepseek") @@ -76,7 +76,7 @@ class DeepSeekResponsesAdapter(BaseProviderAdapter): except urllib.error.HTTPError as http_err: raw_err = http_err.read().decode("utf-8", errors="replace") try: - err_msg = json.loads(raw_err).get("error", {}).get("message", raw_err) + err_msg = extract_api_error_message(raw_err) except Exception: err_msg = raw_err raise RuntimeError(f"DeepSeek API Error ({http_err.code}): {err_msg}") from http_err diff --git a/src/antigravity_provider/router/adapters/grok_adapter.py b/src/antigravity_provider/router/adapters/grok_adapter.py index 61c68ad..46e8f9e 100644 --- a/src/antigravity_provider/router/adapters/grok_adapter.py +++ b/src/antigravity_provider/router/adapters/grok_adapter.py @@ -100,7 +100,15 @@ class GrokAdapter(BaseProviderAdapter): err_json = json.loads(raw_err) except Exception: err_json = {"error": {"message": raw_err}} - err_msg = err_json.get("error", {}).get("message", raw_err) + # Поле error бывает и строкой, и объектом. Раньше здесь безусловно + # звался .get у результата, и на строковой форме обработчик ошибок + # падал сам с AttributeError — настоящая причина отказа терялась, + # а маршрутизация обрывалась вместо перехода к резерву. + err_field = err_json.get("error", raw_err) if isinstance(err_json, dict) else raw_err + if isinstance(err_field, dict): + err_msg = err_field.get("message", raw_err) + else: + err_msg = str(err_field) or raw_err raise RuntimeError(f"Grok API Error ({http_err.code}): {err_msg}") from http_err except Exception as e: raise RuntimeError(f"Grok Transport Error: {e}") from e diff --git a/src/antigravity_provider/router/adapters/local_adapter.py b/src/antigravity_provider/router/adapters/local_adapter.py index 36ac9f3..ad61621 100644 --- a/src/antigravity_provider/router/adapters/local_adapter.py +++ b/src/antigravity_provider/router/adapters/local_adapter.py @@ -9,7 +9,7 @@ import urllib.request from typing import Any, Dict, List, Optional from ..router_config import RouterProfileConfig -from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification +from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message logger = logging.getLogger("hermes.router.adapter.local") @@ -92,7 +92,7 @@ class LocalLLMAdapter(BaseProviderAdapter): except urllib.error.HTTPError as http_err: raw_err = http_err.read().decode("utf-8", errors="replace") try: - err_msg = json.loads(raw_err).get("error", {}).get("message", raw_err) + err_msg = extract_api_error_message(raw_err) except Exception: err_msg = raw_err raise RuntimeError(f"Local LLM API Error ({http_err.code}): {err_msg}") from http_err diff --git a/src/antigravity_provider/router/adapters/opencode_adapter.py b/src/antigravity_provider/router/adapters/opencode_adapter.py index 628de7c..4629e3b 100644 --- a/src/antigravity_provider/router/adapters/opencode_adapter.py +++ b/src/antigravity_provider/router/adapters/opencode_adapter.py @@ -10,7 +10,7 @@ import urllib.error from typing import Any, Dict, List, Optional from ..router_config import RouterProfileConfig -from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification +from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message logger = logging.getLogger(__name__) @@ -161,7 +161,7 @@ class OpenCodeGoAdapter(BaseProviderAdapter): err_json = json.loads(raw_err) except Exception: err_json = {"error": {"message": raw_err}} - err_msg = err_json.get("error", {}).get("message", raw_err) + err_msg = extract_api_error_message(raw_err) raise RuntimeError(f"OpenCode Go Error ({http_err.code}): {err_msg}") from http_err except Exception as e: raise RuntimeError(f"OpenCode Go Transport Error: {e}") from e diff --git a/tests/test_adapter_error_parsing.py b/tests/test_adapter_error_parsing.py new file mode 100644 index 0000000..247a1e4 --- /dev/null +++ b/tests/test_adapter_error_parsing.py @@ -0,0 +1,54 @@ +"""Обработчик ошибок не должен падать сам. + +Найдено на живом Grok владельца: поле ``error`` пришло строкой, а код +безусловно звал у результата ``.get``. Обработчик падал с AttributeError, +настоящая причина (403, токен не проходит проверку) терялась, и +маршрутизация обрывалась вместо перехода к резерву — сбой ровно там, где +обрабатывался другой сбой. + +Та же конструкция стояла ещё в пяти адаптерах. +""" + +from __future__ import annotations + +import json + +import pytest + +from antigravity_provider.router.adapters.base_adapter import extract_api_error_message + + +@pytest.mark.parametrize( + "raw,expected_fragment", + [ + (json.dumps({"error": {"message": "quota exceeded"}}), "quota exceeded"), + (json.dumps({"error": "The OAuth2 access token could not be validated."}), "OAuth2"), + # Без поля error возвращается сырой ответ целиком — это правильно: + # лучше отдать всё, что прислал провайдер, чем потерять причину. + (json.dumps({"detail": "boom"}), "boom"), + ("502 Bad Gateway", "502"), + ("", ""), + ], +) +def test_error_message_extracted_from_any_shape(raw, expected_fragment): + """Строковая и объектная формы разбираются одинаково спокойно.""" + assert expected_fragment in extract_api_error_message(raw) + + +def test_string_error_does_not_raise(): + """Именно эта форма роняла обработчик у Grok.""" + result = extract_api_error_message(json.dumps({"error": "plain string"})) + assert result == "plain string" + + +def test_all_adapters_use_shared_parser(): + """Копии хрупкой конструкции не должны вернуться.""" + import pathlib + + adapters = pathlib.Path("src/antigravity_provider/router/adapters") + offenders = [ + f.name + for f in adapters.glob("*_adapter.py") + if '.get("error", {}).get(' in f.read_text(encoding="utf-8") + ] + assert not offenders, f"хрупкий разбор ошибки вернулся в: {offenders}"