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:
parent
9a2c341f15
commit
ad08c17d83
8 changed files with 99 additions and 11 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
54
tests/test_adapter_error_parsing.py
Normal file
54
tests/test_adapter_error_parsing.py
Normal 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}"
|
||||||
Loading…
Reference in a new issue