diff --git a/src/antigravity_provider/router/grok_oauth.py b/src/antigravity_provider/router/grok_oauth.py index 2f70fa1..0b384c2 100644 --- a/src/antigravity_provider/router/grok_oauth.py +++ b/src/antigravity_provider/router/grok_oauth.py @@ -151,10 +151,39 @@ class GrokOAuthSession: self._finalize_with_tokens(access_token, refresh_token, poll_resp.get("id_token", "")) break except urllib.error.HTTPError as http_err: - if http_err.code in (400, 403, 404): - # Authorization pending + # В device-flow сервер сообщает РАЗНЫЕ вещи одним кодом 400, + # различая их полем error в теле. Раньше 400/403/404 скопом + # считались «ещё не подтверждено», поэтому отказ пользователя и + # просроченный код выглядели как ожидание: мастер крутил + # «Ожидание подтверждения...» до таймаута и не говорил правду. + reason = "" + try: + body = http_err.read().decode("utf-8", errors="replace") + parsed = json.loads(body) if body else {} + if isinstance(parsed, dict): + reason = str(parsed.get("error") or "") + except Exception: + reason = "" + + if reason == "authorization_pending" or (not reason and http_err.code in (400, 403, 404)): continue - logger.warning("xAI device poll HTTP error: %d", http_err.code) + if reason == "slow_down": + self.interval = min(self.interval * 2, 30) + continue + if reason == "access_denied": + self.status = "failed" + self.error_msg = "Доступ отклонён: подтверждение в браузере не выдано" + break + if reason == "expired_token": + self.status = "failed" + self.error_msg = "Код устройства истёк, начните подключение заново" + break + + logger.warning("xAI device poll HTTP error: %d (%s)", http_err.code, reason or "без пояснения") + if reason: + self.status = "failed" + self.error_msg = f"xAI отклонил запрос: {reason}" + break except Exception as ex: logger.debug("xAI device poll error: %s", ex) diff --git a/tests/test_grok_device_flow.py b/tests/test_grok_device_flow.py new file mode 100644 index 0000000..fc1f00b --- /dev/null +++ b/tests/test_grok_device_flow.py @@ -0,0 +1,52 @@ +"""Опрос device-flow обязан различать исходы. + +Владелец: «не даёт зайти в грок через аутх». Backend при этом исправен — +провайдер возвращает настоящие адрес и код. + +Дефект в цикле опроса: коды 400, 403 и 404 скопом считались «ещё не +подтверждено». Но в device-flow сервер сообщает РАЗНЫЕ вещи одним кодом +400, различая их полем error в теле: authorization_pending, slow_down, +access_denied, expired_token. Отказ пользователя и просроченный код +выглядели как ожидание — мастер крутил «Ожидание подтверждения...» до +таймаута и не говорил правду. +""" + +from __future__ import annotations + +import io +import json +import pathlib +import urllib.error + + +def _error(reason: str, code: int = 400) -> urllib.error.HTTPError: + body = json.dumps({"error": reason}).encode("utf-8") + return urllib.error.HTTPError("https://x", code, "Bad Request", {}, io.BytesIO(body)) + + +def test_poll_loop_handles_each_outcome_distinctly(): + src = pathlib.Path("src/antigravity_provider/router/grok_oauth.py").read_text(encoding="utf-8") + + assert "authorization_pending" in src, "ожидание больше не распознаётся явно" + assert "access_denied" in src, "отказ пользователя не отличается от ожидания" + assert "expired_token" in src, "просроченный код не отличается от ожидания" + assert "slow_down" in src, "требование сбавить темп игнорируется" + + # Прежняя конструкция считала все три кода одним исходом. + assert "if http_err.code in (400, 403, 404):\n # Authorization pending" not in src + + +def test_error_body_is_readable(): + """Тело ответа должно разбираться — на нём и держится различение.""" + err = _error("access_denied") + parsed = json.loads(err.read().decode("utf-8")) + assert parsed["error"] == "access_denied" + + +def test_denied_and_expired_are_terminal(): + """Оба исхода обязаны прекращать опрос, а не продолжать его.""" + src = pathlib.Path("src/antigravity_provider/router/grok_oauth.py").read_text(encoding="utf-8") + tail = src.split("if reason == \"access_denied\":", 1)[1][:400] + assert "break" in tail, "отказ не прекращает опрос" + expired = src.split("if reason == \"expired_token\":", 1)[1][:400] + assert "break" in expired, "просрочка не прекращает опрос"