fix(adapters): обработчик ошибок падал сам, скрывая настоящую причину

Найдено при проверке 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 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-24 14:54:39 +07:00
parent 9a2c341f15
commit ad08c17d83
8 changed files with 99 additions and 11 deletions

View file

@ -1,6 +1,8 @@
"""Base provider adapter interface for multi-provider router.""" """Base provider adapter interface for multi-provider router."""
from __future__ import annotations from __future__ import annotations
import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -49,3 +51,27 @@ class BaseProviderAdapter(ABC):
def release(self, profile: RouterProfileConfig) -> None: def release(self, profile: RouterProfileConfig) -> None:
"""Clean up any ephemeral resources for this profile.""" """Clean up any ephemeral resources for this profile."""
pass 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

View file

@ -10,7 +10,7 @@ import urllib.request
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig 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__) logger = logging.getLogger(__name__)
@ -110,7 +110,7 @@ class ClaudeAdapter(BaseProviderAdapter):
err_json = json.loads(raw_err) err_json = json.loads(raw_err)
except Exception: except Exception:
err_json = {"error": {"message": raw_err}} 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 raise RuntimeError(f"Claude API Error ({http_err.code}): {err_msg}") from http_err
except Exception as e: except Exception as e:
raise RuntimeError(f"Claude Transport Error: {e}") from e raise RuntimeError(f"Claude Transport Error: {e}") from e

View file

@ -10,7 +10,7 @@ import urllib.error
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig 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__) logger = logging.getLogger(__name__)
@ -112,7 +112,7 @@ class CodexAdapter(BaseProviderAdapter):
err_json = json.loads(raw_err) err_json = json.loads(raw_err)
except Exception: except Exception:
err_json = {"error": {"message": raw_err}} 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 raise RuntimeError(f"Codex API Error ({http_err.code}): {err_msg}") from http_err
except Exception as e: except Exception as e:
raise RuntimeError(f"Codex Transport Error: {e}") from e raise RuntimeError(f"Codex Transport Error: {e}") from e

View file

@ -10,7 +10,7 @@ import urllib.request
from typing import Any from typing import Any
from ..router_config import RouterProfileConfig 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") logger = logging.getLogger("hermes.router.adapter.deepseek")
@ -76,7 +76,7 @@ class DeepSeekResponsesAdapter(BaseProviderAdapter):
except urllib.error.HTTPError as http_err: except urllib.error.HTTPError as http_err:
raw_err = http_err.read().decode("utf-8", errors="replace") raw_err = http_err.read().decode("utf-8", errors="replace")
try: try:
err_msg = json.loads(raw_err).get("error", {}).get("message", raw_err) err_msg = extract_api_error_message(raw_err)
except Exception: except Exception:
err_msg = raw_err err_msg = raw_err
raise RuntimeError(f"DeepSeek API Error ({http_err.code}): {err_msg}") from http_err raise RuntimeError(f"DeepSeek API Error ({http_err.code}): {err_msg}") from http_err

View file

@ -100,7 +100,15 @@ class GrokAdapter(BaseProviderAdapter):
err_json = json.loads(raw_err) err_json = json.loads(raw_err)
except Exception: except Exception:
err_json = {"error": {"message": raw_err}} 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 raise RuntimeError(f"Grok API Error ({http_err.code}): {err_msg}") from http_err
except Exception as e: except Exception as e:
raise RuntimeError(f"Grok Transport Error: {e}") from e raise RuntimeError(f"Grok Transport Error: {e}") from e

View file

@ -9,7 +9,7 @@ import urllib.request
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig 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") logger = logging.getLogger("hermes.router.adapter.local")
@ -92,7 +92,7 @@ class LocalLLMAdapter(BaseProviderAdapter):
except urllib.error.HTTPError as http_err: except urllib.error.HTTPError as http_err:
raw_err = http_err.read().decode("utf-8", errors="replace") raw_err = http_err.read().decode("utf-8", errors="replace")
try: try:
err_msg = json.loads(raw_err).get("error", {}).get("message", raw_err) err_msg = extract_api_error_message(raw_err)
except Exception: except Exception:
err_msg = raw_err err_msg = raw_err
raise RuntimeError(f"Local LLM API Error ({http_err.code}): {err_msg}") from http_err raise RuntimeError(f"Local LLM API Error ({http_err.code}): {err_msg}") from http_err

View file

@ -10,7 +10,7 @@ import urllib.error
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig 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__) logger = logging.getLogger(__name__)
@ -161,7 +161,7 @@ class OpenCodeGoAdapter(BaseProviderAdapter):
err_json = json.loads(raw_err) err_json = json.loads(raw_err)
except Exception: except Exception:
err_json = {"error": {"message": raw_err}} 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 raise RuntimeError(f"OpenCode Go Error ({http_err.code}): {err_msg}") from http_err
except Exception as e: except Exception as e:
raise RuntimeError(f"OpenCode Go Transport Error: {e}") from e raise RuntimeError(f"OpenCode Go Transport Error: {e}") from e

View file

@ -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"),
("<html>502 Bad Gateway</html>", "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}"