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:
parent
ad08c17d83
commit
50e4de5230
2 changed files with 84 additions and 3 deletions
|
|
@ -151,10 +151,39 @@ class GrokOAuthSession:
|
||||||
self._finalize_with_tokens(access_token, refresh_token, poll_resp.get("id_token", ""))
|
self._finalize_with_tokens(access_token, refresh_token, poll_resp.get("id_token", ""))
|
||||||
break
|
break
|
||||||
except urllib.error.HTTPError as http_err:
|
except urllib.error.HTTPError as http_err:
|
||||||
if http_err.code in (400, 403, 404):
|
# В device-flow сервер сообщает РАЗНЫЕ вещи одним кодом 400,
|
||||||
# Authorization pending
|
# различая их полем 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
|
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:
|
except Exception as ex:
|
||||||
logger.debug("xAI device poll error: %s", ex)
|
logger.debug("xAI device poll error: %s", ex)
|
||||||
|
|
||||||
|
|
|
||||||
52
tests/test_grok_device_flow.py
Normal file
52
tests/test_grok_device_flow.py
Normal 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, "просрочка не прекращает опрос"
|
||||||
Loading…
Reference in a new issue