fix(grok): опрос device-flow не различал отказ, просрочку и ожидание

Владелец: «не даёт зайти в грок через аутх». Backend при этом исправен —
проверено: провайдер возвращает настоящую сессию, адрес
accounts.x.ai/oauth2/device и код.

Дефект в цикле опроса: коды 400, 403 и 404 скопом считались
«авторизация ещё не подтверждена» и опрос молча продолжался. Но в
device-flow сервер сообщает РАЗНЫЕ вещи одним кодом 400, различая их
полем error в теле: authorization_pending, slow_down, access_denied,
expired_token.

Следствие: отказ пользователя и просроченный код выглядели как ожидание.
Мастер показывал «Ожидание подтверждения...» до самого таймаута и не
говорил, что подтверждение уже отклонено или код давно истёк.

Теперь каждый исход обрабатывается по существу: ожидание продолжает
опрос, slow_down увеличивает интервал, отказ и просрочка прекращают его
с внятным сообщением.

Тесты: 414 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-24 15:11:54 +07:00
parent ad08c17d83
commit 50e4de5230
2 changed files with 84 additions and 3 deletions

View file

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

View file

@ -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, "просрочка не прекращает опрос"