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."""
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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}"