Spaces:
Running
Running
SilverElixir commited on
Commit ·
c43d58c
1
Parent(s): 13103aa
Gate webhook updates until startup state loads and shut down gracefully on SIGTERM
Browse files- bot.py +59 -7
- lumen_admin.py +5 -0
- lumen_chat_state.py +5 -0
- tests/test_bot_admin.py +88 -1
bot.py
CHANGED
|
@@ -24,7 +24,9 @@ import os
|
|
| 24 |
import queue
|
| 25 |
import re
|
| 26 |
import secrets
|
|
|
|
| 27 |
import sys
|
|
|
|
| 28 |
import time
|
| 29 |
from pathlib import Path
|
| 30 |
from datetime import date
|
|
@@ -1469,9 +1471,17 @@ from lumen_message_core import (
|
|
| 1469 |
SETWEBHOOK_MAX_ATTEMPTS = 5
|
| 1470 |
SETWEBHOOK_BACKOFF_SEC = 5.0
|
| 1471 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1472 |
async def _webhook_startup() -> None:
|
|
|
|
| 1473 |
# Синхронное дисковое чтение в потоке: иначе старт стопорит приём /webhook.
|
| 1474 |
await asyncio.to_thread(load_state_from_disk)
|
|
|
|
| 1475 |
log.info("Bot startup: webhook mode.")
|
| 1476 |
# На Space кап можно переопределить env: без этой строки в логах не видно,
|
| 1477 |
# какой лимит реально работает (разбор 26.10.2026).
|
|
@@ -1669,8 +1679,32 @@ def _require_bot_token() -> str:
|
|
| 1669 |
return BOT_TOKEN
|
| 1670 |
|
| 1671 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1672 |
async def main() -> None:
|
| 1673 |
-
global bot, client
|
| 1674 |
|
| 1675 |
_require_bot_token()
|
| 1676 |
|
|
@@ -1688,6 +1722,7 @@ async def main() -> None:
|
|
| 1688 |
bot = Bot(token=BOT_TOKEN, session=sess)
|
| 1689 |
client = genai.Client(api_key=GEMINI_API_KEY)
|
| 1690 |
|
|
|
|
| 1691 |
startup_task = asyncio.create_task(_webhook_startup(), name="webhook_startup")
|
| 1692 |
flush_task = asyncio.create_task(_flush_dirty_state(), name="state_flush")
|
| 1693 |
srv_config = uvicorn.Config(
|
|
@@ -1697,6 +1732,9 @@ async def main() -> None:
|
|
| 1697 |
app=app, host="0.0.0.0", port=7860, log_level=None, loop="asyncio", log_config=None,
|
| 1698 |
)
|
| 1699 |
server = uvicorn.Server(srv_config)
|
|
|
|
|
|
|
|
|
|
| 1700 |
try:
|
| 1701 |
serve_task = asyncio.create_task(server.serve(), name="uvicorn_serve")
|
| 1702 |
done, _pending = await asyncio.wait(
|
|
@@ -1720,8 +1758,11 @@ async def main() -> None:
|
|
| 1720 |
if exc is not None:
|
| 1721 |
log.error("[serve] HTTP server failed, stopping: %s", exc)
|
| 1722 |
raise SystemExit(1) from exc
|
| 1723 |
-
|
| 1724 |
-
|
|
|
|
|
|
|
|
|
|
| 1725 |
finally:
|
| 1726 |
startup_task.cancel()
|
| 1727 |
flush_task.cancel()
|
|
@@ -1737,10 +1778,21 @@ async def main() -> None:
|
|
| 1737 |
# Флаги — канонически в lumen_chat_state (P2): множества общие объектом,
|
| 1738 |
# bool-флаги читаем из модуля, т.к. bot-привязки после выноса stale.
|
| 1739 |
# Синхронная запись в потоке: loop уже дренирует задачи, висеть нельзя.
|
| 1740 |
-
if
|
| 1741 |
-
|
| 1742 |
-
|
| 1743 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1744 |
await _close_sessions()
|
| 1745 |
|
| 1746 |
if __name__ == "__main__":
|
|
|
|
| 24 |
import queue
|
| 25 |
import re
|
| 26 |
import secrets
|
| 27 |
+
import signal
|
| 28 |
import sys
|
| 29 |
+
import threading
|
| 30 |
import time
|
| 31 |
from pathlib import Path
|
| 32 |
from datetime import date
|
|
|
|
| 1471 |
SETWEBHOOK_MAX_ATTEMPTS = 5
|
| 1472 |
SETWEBHOOK_BACKOFF_SEC = 5.0
|
| 1473 |
|
| 1474 |
+
# /webhook отвечает 503, пока старт не загрузил состояние: иначе апдейт,
|
| 1475 |
+
# пришедший во время загрузки, создаёт пустой чат и перезаписывает удалённую
|
| 1476 |
+
# историю/индекс (аудит 28.10.2026).
|
| 1477 |
+
_state_ready = True
|
| 1478 |
+
|
| 1479 |
+
|
| 1480 |
async def _webhook_startup() -> None:
|
| 1481 |
+
global _state_ready
|
| 1482 |
# Синхронное дисковое чтение в потоке: иначе старт стопорит приём /webhook.
|
| 1483 |
await asyncio.to_thread(load_state_from_disk)
|
| 1484 |
+
_state_ready = True
|
| 1485 |
log.info("Bot startup: webhook mode.")
|
| 1486 |
# На Space кап можно переопределить env: без этой строки в логах не видно,
|
| 1487 |
# какой лимит реально работает (разбор 26.10.2026).
|
|
|
|
| 1679 |
return BOT_TOKEN
|
| 1680 |
|
| 1681 |
|
| 1682 |
+
@contextlib.contextmanager
|
| 1683 |
+
def _capture_signals_gracefully(server: Any):
|
| 1684 |
+
"""SIGTERM/SIGINT: uvicorn по умолчанию пере-поднимает сигнал после
|
| 1685 |
+
serve(), и finally с финальным сбросом состояния не выполняется
|
| 1686 |
+
(аудит 28.10.2026). Перехватываем сигнал и только просим сервер выйти."""
|
| 1687 |
+
if threading.current_thread() is not threading.main_thread():
|
| 1688 |
+
yield
|
| 1689 |
+
return
|
| 1690 |
+
original = {}
|
| 1691 |
+
|
| 1692 |
+
def _stop(signum, frame):
|
| 1693 |
+
server.should_exit = True
|
| 1694 |
+
|
| 1695 |
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 1696 |
+
with contextlib.suppress(Exception):
|
| 1697 |
+
original[sig] = signal.signal(sig, _stop)
|
| 1698 |
+
try:
|
| 1699 |
+
yield
|
| 1700 |
+
finally:
|
| 1701 |
+
for sig, handler in original.items():
|
| 1702 |
+
with contextlib.suppress(Exception):
|
| 1703 |
+
signal.signal(sig, handler)
|
| 1704 |
+
|
| 1705 |
+
|
| 1706 |
async def main() -> None:
|
| 1707 |
+
global bot, client, _state_ready
|
| 1708 |
|
| 1709 |
_require_bot_token()
|
| 1710 |
|
|
|
|
| 1722 |
bot = Bot(token=BOT_TOKEN, session=sess)
|
| 1723 |
client = genai.Client(api_key=GEMINI_API_KEY)
|
| 1724 |
|
| 1725 |
+
_state_ready = False
|
| 1726 |
startup_task = asyncio.create_task(_webhook_startup(), name="webhook_startup")
|
| 1727 |
flush_task = asyncio.create_task(_flush_dirty_state(), name="state_flush")
|
| 1728 |
srv_config = uvicorn.Config(
|
|
|
|
| 1732 |
app=app, host="0.0.0.0", port=7860, log_level=None, loop="asyncio", log_config=None,
|
| 1733 |
)
|
| 1734 |
server = uvicorn.Server(srv_config)
|
| 1735 |
+
# Не даём uvicorn пере-поднять SIGTERM после serve(): иначе финальный
|
| 1736 |
+
# сброс состояния и закрытие сессий не выполняются.
|
| 1737 |
+
server.capture_signals = lambda: _capture_signals_gracefully(server)
|
| 1738 |
try:
|
| 1739 |
serve_task = asyncio.create_task(server.serve(), name="uvicorn_serve")
|
| 1740 |
done, _pending = await asyncio.wait(
|
|
|
|
| 1758 |
if exc is not None:
|
| 1759 |
log.error("[serve] HTTP server failed, stopping: %s", exc)
|
| 1760 |
raise SystemExit(1) from exc
|
| 1761 |
+
if getattr(server, "should_exit", False):
|
| 1762 |
+
log.info("[serve] HTTP server stopped gracefully")
|
| 1763 |
+
else:
|
| 1764 |
+
log.error("[serve] HTTP server exited unexpectedly, stopping")
|
| 1765 |
+
raise SystemExit(1)
|
| 1766 |
finally:
|
| 1767 |
startup_task.cancel()
|
| 1768 |
flush_task.cancel()
|
|
|
|
| 1778 |
# Флаги — канонически в lumen_chat_state (P2): множества общие объектом,
|
| 1779 |
# bool-флаги читаем из модуля, т.к. bot-привязки после выноса stale.
|
| 1780 |
# Синхронная запись в потоке: loop уже дренирует задачи, висеть нельзя.
|
| 1781 |
+
if lumen_chat_state._state_load_failed:
|
| 1782 |
+
# Стартовая загрузка не удалась: не затираем удалённые данные
|
| 1783 |
+
# (аудит 28.10.2026).
|
| 1784 |
+
log.warning('[state] Startup load failed: skipping the final flush to keep remote data intact.')
|
| 1785 |
+
else:
|
| 1786 |
+
if _dirty_chat_ids or lumen_chat_state._index_dirty or _pending_chat_deletions:
|
| 1787 |
+
try:
|
| 1788 |
+
await asyncio.wait_for(asyncio.to_thread(_flush_state_now), timeout=15.0)
|
| 1789 |
+
except asyncio.TimeoutError:
|
| 1790 |
+
log.warning('[state] Final flush exceeded 15s; abandoning the remainder.')
|
| 1791 |
+
if lumen_chat_state._quota_dirty:
|
| 1792 |
+
try:
|
| 1793 |
+
await asyncio.wait_for(asyncio.to_thread(save_global_quota), timeout=10.0)
|
| 1794 |
+
except asyncio.TimeoutError:
|
| 1795 |
+
log.warning('[state] Final quota save exceeded 10s; abandoning the remainder.')
|
| 1796 |
await _close_sessions()
|
| 1797 |
|
| 1798 |
if __name__ == "__main__":
|
lumen_admin.py
CHANGED
|
@@ -155,6 +155,11 @@ async def webhook_handler(request: Request) -> Any:
|
|
| 155 |
if not bot.WEBHOOK_SECRET or not token or not hmac.compare_digest(token.encode(), bot.WEBHOOK_SECRET.encode()):
|
| 156 |
_log_webhook_denied("invalid secret token")
|
| 157 |
return {"ok": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
# Тело апдейта маленькое: отсекаем мусор по заголовку до чтения.
|
| 159 |
content_length = request.headers.get("Content-Length")
|
| 160 |
if content_length is not None:
|
|
|
|
| 155 |
if not bot.WEBHOOK_SECRET or not token or not hmac.compare_digest(token.encode(), bot.WEBHOOK_SECRET.encode()):
|
| 156 |
_log_webhook_denied("invalid secret token")
|
| 157 |
return {"ok": False}
|
| 158 |
+
# Состояние ещё грузится: 503, чтобы Telegram повторил позже, а не создал
|
| 159 |
+
# пустой чат и не перезаписал удалённую историю (аудит 28.10.2026).
|
| 160 |
+
if not getattr(bot, "_state_ready", True):
|
| 161 |
+
log.info("[webhook] State is still loading, asking Telegram to retry")
|
| 162 |
+
return JSONResponse(status_code=503, content={"ok": False, "retry": True})
|
| 163 |
# Тело апдейта маленькое: отсекаем мусор по заголовку до чтения.
|
| 164 |
content_length = request.headers.get("Content-Length")
|
| 165 |
if content_length is not None:
|
lumen_chat_state.py
CHANGED
|
@@ -675,6 +675,11 @@ async def _flush_dirty_state_once() -> None:
|
|
| 675 |
'[state] %d chat deletion(s) failed, will retry next cycle: %s',
|
| 676 |
len(failed_deletes), ", ".join(str(c) for c in sorted(failed_deletes)),
|
| 677 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
if _dirty_chat_ids:
|
| 679 |
to_save = list(_dirty_chat_ids)
|
| 680 |
_dirty_chat_ids.clear()
|
|
|
|
| 675 |
'[state] %d chat deletion(s) failed, will retry next cycle: %s',
|
| 676 |
len(failed_deletes), ", ".join(str(c) for c in sorted(failed_deletes)),
|
| 677 |
)
|
| 678 |
+
if _state_load_failed and _dirty_chat_ids:
|
| 679 |
+
# Удалённые истории не затёрты: файл в памяти — это либо новый чат,
|
| 680 |
+
# либо всё, что осталось после сбоя загрузки; индекс и квота при
|
| 681 |
+
# этом не пишутся (аудит A5-2, 28.10.2026).
|
| 682 |
+
log.warning('[state] Startup load failed; saving chats from memory, index stays untouched.')
|
| 683 |
if _dirty_chat_ids:
|
| 684 |
to_save = list(_dirty_chat_ids)
|
| 685 |
_dirty_chat_ids.clear()
|
tests/test_bot_admin.py
CHANGED
|
@@ -10,7 +10,7 @@ import os
|
|
| 10 |
import threading
|
| 11 |
import time
|
| 12 |
import pytest
|
| 13 |
-
from unittest.mock import Mock
|
| 14 |
from tests.bot_test_helpers import (
|
| 15 |
_FakeAdminRequest,
|
| 16 |
_FakeWebhookRequest,
|
|
@@ -24,9 +24,11 @@ def _clear_webhook_update_dedup():
|
|
| 24 |
import lumen_admin
|
| 25 |
lumen_admin._seen_update_ids.clear()
|
| 26 |
lumen_admin._seen_update_id_set.clear()
|
|
|
|
| 27 |
yield
|
| 28 |
lumen_admin._seen_update_ids.clear()
|
| 29 |
lumen_admin._seen_update_id_set.clear()
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def test_public_log_formatter_hashes_long_numeric_ids():
|
|
@@ -162,6 +164,33 @@ def test_webhook_handler_tracks_dispatched_task_for_shutdown():
|
|
| 162 |
bot._process_raw_update = original_process
|
| 163 |
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
def test_webhook_handler_ignores_duplicate_update_id():
|
| 166 |
# Telegram повторяет доставку при таймауте/сбое: один update_id не должен
|
| 167 |
# давать второй ответ, скачивание и трату квоты (аудит 28.10.2026).
|
|
@@ -800,6 +829,64 @@ def test_main_exits_nonzero_when_server_dies_first(monkeypatch, caplog):
|
|
| 800 |
lumen_chat_state._quota_dirty = orig_quota_dirty
|
| 801 |
|
| 802 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
def test_probe_url_returns_ok_shape_and_redacts_secret_on_error():
|
| 804 |
# Общий зонд /diag и /selftest: успех — статус, неуспех — текст без секрета.
|
| 805 |
class _FakeResp:
|
|
|
|
| 10 |
import threading
|
| 11 |
import time
|
| 12 |
import pytest
|
| 13 |
+
from unittest.mock import AsyncMock, Mock
|
| 14 |
from tests.bot_test_helpers import (
|
| 15 |
_FakeAdminRequest,
|
| 16 |
_FakeWebhookRequest,
|
|
|
|
| 24 |
import lumen_admin
|
| 25 |
lumen_admin._seen_update_ids.clear()
|
| 26 |
lumen_admin._seen_update_id_set.clear()
|
| 27 |
+
bot._state_ready = True
|
| 28 |
yield
|
| 29 |
lumen_admin._seen_update_ids.clear()
|
| 30 |
lumen_admin._seen_update_id_set.clear()
|
| 31 |
+
bot._state_ready = True
|
| 32 |
|
| 33 |
|
| 34 |
def test_public_log_formatter_hashes_long_numeric_ids():
|
|
|
|
| 164 |
bot._process_raw_update = original_process
|
| 165 |
|
| 166 |
|
| 167 |
+
def test_webhook_returns_503_until_state_is_ready():
|
| 168 |
+
# Апдейт во время загрузки состояния не должен создавать пустой чат и
|
| 169 |
+
# перезаписывать удалённую историю (аудит 28.10.2026).
|
| 170 |
+
original_secret = bot.WEBHOOK_SECRET
|
| 171 |
+
original_bot_obj = bot.bot
|
| 172 |
+
original_process = bot._process_raw_update
|
| 173 |
+
original_ready = bot._state_ready
|
| 174 |
+
bot.WEBHOOK_SECRET = "real-webhook-secret"
|
| 175 |
+
bot.bot = object()
|
| 176 |
+
bot._state_ready = False
|
| 177 |
+
process = AsyncMock()
|
| 178 |
+
bot._process_raw_update = process
|
| 179 |
+
try:
|
| 180 |
+
req = _FakeWebhookRequest(
|
| 181 |
+
headers={"X-Telegram-Bot-Api-Secret-Token": "real-webhook-secret"},
|
| 182 |
+
body={"update_id": 778},
|
| 183 |
+
)
|
| 184 |
+
result = asyncio.run(_run_webhook_handler(req))
|
| 185 |
+
assert getattr(result, "status_code", None) == 503
|
| 186 |
+
process.assert_not_called()
|
| 187 |
+
finally:
|
| 188 |
+
bot.WEBHOOK_SECRET = original_secret
|
| 189 |
+
bot.bot = original_bot_obj
|
| 190 |
+
bot._process_raw_update = original_process
|
| 191 |
+
bot._state_ready = original_ready
|
| 192 |
+
|
| 193 |
+
|
| 194 |
def test_webhook_handler_ignores_duplicate_update_id():
|
| 195 |
# Telegram повторяет доставку при таймауте/сбое: один update_id не должен
|
| 196 |
# давать второй ответ, скачивание и трату квоты (аудит 28.10.2026).
|
|
|
|
| 829 |
lumen_chat_state._quota_dirty = orig_quota_dirty
|
| 830 |
|
| 831 |
|
| 832 |
+
def test_main_flushes_on_graceful_server_exit(monkeypatch, caplog):
|
| 833 |
+
# SIGTERM/SIGINT: сервер завершился штатно — финальный сброс состояния и
|
| 834 |
+
# закрытие сессий обязаны выполниться (аудит 28.10.2026).
|
| 835 |
+
from types import SimpleNamespace
|
| 836 |
+
from unittest.mock import AsyncMock
|
| 837 |
+
import lumen_chat_state
|
| 838 |
+
monkeypatch.setattr(bot, "_require_bot_token", lambda: "123:abc")
|
| 839 |
+
monkeypatch.setattr(bot, "Bot", lambda *a, **k: SimpleNamespace())
|
| 840 |
+
monkeypatch.setattr(bot, "IPv4AiohttpSession", lambda *a, **k: SimpleNamespace())
|
| 841 |
+
monkeypatch.setattr(bot.genai, "Client", lambda *a, **k: SimpleNamespace())
|
| 842 |
+
|
| 843 |
+
async def _never():
|
| 844 |
+
await asyncio.sleep(3600)
|
| 845 |
+
|
| 846 |
+
monkeypatch.setattr(bot, "_webhook_startup", _never)
|
| 847 |
+
monkeypatch.setattr(bot, "_flush_dirty_state", _never)
|
| 848 |
+
monkeypatch.setattr(bot, "_drain_inflight_tasks", AsyncMock())
|
| 849 |
+
monkeypatch.setattr(bot, "_close_sessions", AsyncMock())
|
| 850 |
+
monkeypatch.setattr(bot.uvicorn, "Config", lambda *a, **k: SimpleNamespace())
|
| 851 |
+
|
| 852 |
+
class _StoppedServer:
|
| 853 |
+
should_exit = True
|
| 854 |
+
|
| 855 |
+
async def serve(self):
|
| 856 |
+
return None
|
| 857 |
+
|
| 858 |
+
monkeypatch.setattr(bot.uvicorn, "Server", lambda cfg: _StoppedServer())
|
| 859 |
+
emitted = []
|
| 860 |
+
monkeypatch.setattr(bot, "_flush_state_now", lambda: emitted.append("chats"))
|
| 861 |
+
monkeypatch.setattr(bot, "save_global_quota", lambda: emitted.append("quota"))
|
| 862 |
+
orig_bot, orig_client = bot.bot, bot.client
|
| 863 |
+
orig_ready = bot._state_ready
|
| 864 |
+
orig_dirty, orig_deletions = set(bot._dirty_chat_ids), set(bot._pending_chat_deletions)
|
| 865 |
+
orig_index_dirty, orig_quota_dirty = lumen_chat_state._index_dirty, lumen_chat_state._quota_dirty
|
| 866 |
+
orig_load_failed = lumen_chat_state._state_load_failed
|
| 867 |
+
bot._dirty_chat_ids.clear()
|
| 868 |
+
bot._dirty_chat_ids.add(999001)
|
| 869 |
+
bot._pending_chat_deletions.clear()
|
| 870 |
+
lumen_chat_state._index_dirty = False
|
| 871 |
+
lumen_chat_state._quota_dirty = True
|
| 872 |
+
lumen_chat_state._state_load_failed = False
|
| 873 |
+
try:
|
| 874 |
+
with caplog.at_level(logging.INFO, logger="bot"):
|
| 875 |
+
asyncio.run(bot.main())
|
| 876 |
+
assert "[serve] HTTP server stopped gracefully" in caplog.text
|
| 877 |
+
assert "chats" in emitted and "quota" in emitted
|
| 878 |
+
finally:
|
| 879 |
+
bot.bot, bot.client = orig_bot, orig_client
|
| 880 |
+
bot._state_ready = orig_ready
|
| 881 |
+
bot._dirty_chat_ids.clear()
|
| 882 |
+
bot._dirty_chat_ids.update(orig_dirty)
|
| 883 |
+
bot._pending_chat_deletions.clear()
|
| 884 |
+
bot._pending_chat_deletions.update(orig_deletions)
|
| 885 |
+
lumen_chat_state._index_dirty = orig_index_dirty
|
| 886 |
+
lumen_chat_state._quota_dirty = orig_quota_dirty
|
| 887 |
+
lumen_chat_state._state_load_failed = orig_load_failed
|
| 888 |
+
|
| 889 |
+
|
| 890 |
def test_probe_url_returns_ok_shape_and_redacts_secret_on_error():
|
| 891 |
# Общий зонд /diag и /selftest: успех — статус, неуспех — текст без секрета.
|
| 892 |
class _FakeResp:
|