zapret-kvn/xray_fluent/importer/subscription_http.py
loop-uh 9c631bbd48
Some checks failed
Windows project source guards / test (push) Has been cancelled
Подписка напрямую — стеком Chromium; dialer сокетов в патче для Android
- Прямая попытка загрузки подписки идёт через браузерный транспорт: на
  проводе рукопожатие Chrome, а не TLS Python. Семантика та же, что у
  urllib: ответ сервера (4xx/5xx, отказ HWID, 304, превышение размера)
  окончателен, «нет ответа» — переход на маршрут через туннель, редирект на
  http:// — отказ. UA профиля клиента подписки сохраняется. Через туннель
  рукопожатие провайдеру не видно — там прежний urllib.
- Служба browser-fetch выключена, пока main.py не включит её при старте:
  тесты и утилиты не поднимают ядро и не ходят в сеть сами (на стенде
  выпуска core/sing-box.exe есть — иначе тесты DoH пошли бы в сеть).
- core-patches/browser-fetch.patch: browserfetch.SetSocketControl(fd) —
  на Android сокеты Cronet защищаются от своего VPN (protect / привязка к
  физической сети) до connect; только до запуска движка. Тест в отдельном
  процессе; реальный запрос через dialer — тот же отпечаток Chrome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:02:17 +03:00

678 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import base64
import binascii
import ipaddress
from collections.abc import Callable
from dataclasses import dataclass, field
import locale
import platform
import queue
import re
import threading
import time
import urllib.error
import urllib.request
from urllib.parse import SplitResult, parse_qs, unquote, urlsplit
from ..constants import APP_VERSION, PROXY_HOST, SUBSCRIPTION_PARSER_REVISION
from .happ_crypt import HappCryptError, decrypt_happ_link, is_happ_crypt_link
from ..network import browser_fetch
from ..network.http_utils import build_opener
from ..profiles.models import Subscription
MAX_SUBSCRIPTION_BYTES = 10 * 1024 * 1024
# Потолок на всё обновление целиком. ``timeout`` у urllib ограничивает одну
# сетевую операцию, а не запрос: подключение к каждому адресу хоста, TLS и
# каждое чтение ждут по отдельности и в сумме дают минуты.
SUBSCRIPTION_FETCH_DEADLINE = 30.0
# Фора прямого маршрута перед параллельным стартом прокси. Живой сервер
# отвечает напрямую быстрее и прокси не нужен; при молчаливой блокировке
# ждать полный таймаут прямого пути незачем.
PROXY_HEAD_START = 1.0
# Сколько ещё ждать прямой маршрут, когда через прокси сервер уже отказал.
_DIRECT_GRACE = 3.0
_READ_CHUNK = 64 * 1024
_LOCALE_CODE = re.compile(r"[A-Za-z]{2,3}(?:[_-][A-Za-z0-9]{2,8})*")
_PANEL_HWID = re.compile(r"[A-Za-z0-9=-]{10,64}")
CLIENT_PROFILES = ("zapret", "happ", "incy", "v2raytun", "custom")
_PROFILE_DEFAULT_USER_AGENTS = {
"happ": "Happ/3.13.0",
"incy": "INCY/1.0/Windows",
"v2raytun": "v2rayTun/2.3.5",
}
_WRAPPED_SOURCE_PREFIXES = {
"happ://add/": "happ",
"incy://add/": "incy",
"incy://import/": "incy",
"v2raytun://import/": "v2raytun",
}
# Веб-страница провайдера, которая лишь открывает клиент диплинком:
# ``https://host/incy/https://sub.host/token`` → ``incy://import/<base64>``.
# Сам адрес подписки лежит в пути открытым текстом.
_LAUNCHER_PAGE_PATH = re.compile(
r"/(happ|incy|v2raytun)(?:\.html)?/+(https?(?::|%3a).+)", re.IGNORECASE
)
_HWID_REFUSAL_HEADERS = frozenset({"x-hwid-not-supported", "x-hwid-max-devices-reached"})
class SubscriptionFetchError(RuntimeError):
pass
class SubscriptionServerResponseError(SubscriptionFetchError):
"""The subscription server answered, but the answer is not a usable body.
A real HTTP response (any status, an oversized body, an unsafe redirect,
a bare 304) is authoritative for this URL and identical on every network
path. It must be terminal: never fall back to the VPN proxy after the
server has already answered directly, otherwise a definitive 404 is masked
by the proxy attempt's transport error. Only a transport failure — no
response reached us — is worth retrying through the proxy.
"""
@dataclass(slots=True)
class SubscriptionFetchResult:
data: bytes = b""
headers: dict[str, str] = field(default_factory=dict)
status: int = 200
not_modified: bool = False
via_proxy: bool = False
elapsed: float = 0.0
CLEARTEXT_DIRECT_ERROR = (
"Подписка по http:// идёт открытым текстом: напрямую провайдер увидел бы весь список "
"серверов. Такая подписка обновляется только через подключённый VPN — подключитесь "
"или попросите у провайдера ссылку https://"
)
def cleartext_over_internet(url: str) -> bool:
"""http:// к адресу в интернете: тело подписки увидит любой на пути."""
parsed = urlsplit(url)
if parsed.scheme.lower() != "http":
return False
host = (parsed.hostname or "").strip().lower()
if host == "localhost" or host.endswith(".localhost"):
return False
try:
address = ipaddress.ip_address(host)
except ValueError:
return True
return not (address.is_private or address.is_loopback or address.is_link_local)
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, *, direct: bool = False) -> None:
super().__init__()
self._direct = direct
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urlsplit(newurl).scheme.lower() not in {"http", "https"}:
raise SubscriptionFetchError("Перенаправление подписки использует небезопасную схему")
if self._direct and cleartext_over_internet(newurl):
raise SubscriptionFetchError(CLEARTEXT_DIRECT_ERROR)
return super().redirect_request(req, fp, code, msg, headers, newurl)
def default_subscription_user_agent() -> str:
return f"ZapretKVN/{APP_VERSION}"
def normalize_client_profile(value: str) -> str:
profile = str(value or "").strip().lower()
return profile if profile in CLIENT_PROFILES else "custom"
def profile_default_user_agent(profile: str) -> str:
return _PROFILE_DEFAULT_USER_AGENTS.get(
normalize_client_profile(profile), default_subscription_user_agent()
)
def resolve_subscription_source(value: str) -> tuple[str, str | None]:
text = str(value or "").strip()
lowered = text.lower()
parsed = urlsplit(text)
if parsed.scheme.lower() in {"http", "https"} and parsed.hostname:
launcher = _resolve_launcher_page(parsed)
return launcher if launcher is not None else (text, None)
if is_happ_crypt_link(text):
return _resolve_happ_crypt_source(text)
if parsed.scheme.lower() in {"happ", "incy", "v2raytun"}:
if "crypt" in parsed.netloc.lower() or parsed.path.lower().lstrip("/").startswith("crypt"):
raise SubscriptionFetchError(
"Зашифрованные proprietary deep links этим клиентом не расшифровываются"
)
query = parse_qs(parsed.query)
query_url = (query.get("url") or query.get("data") or [""])[0]
if query_url:
candidate = unquote(query_url).strip()
hint = parsed.scheme.lower()
else:
candidate = ""
hint = None
for prefix, profile in _WRAPPED_SOURCE_PREFIXES.items():
if lowered.startswith(prefix):
candidate = unquote(text[len(prefix) :]).strip()
hint = profile
break
if candidate and hint:
candidate = _decode_wrapped_http_url(candidate)
inner = urlsplit(candidate)
if inner.scheme.lower() in {"http", "https"} and inner.hostname:
return candidate, hint
raise SubscriptionFetchError(
"URL подписки должен использовать HTTP/HTTPS или открытую add/import-ссылку Happ, INCY, v2RayTun"
)
def _resolve_launcher_page(parsed: SplitResult) -> tuple[str, str] | None:
"""Достать подписку из страницы-запускалки клиента.
Такая страница отвечает HTML с редиректом на диплинк, а не подпиской, и
правила панели настроены под тот клиент, чьё имя стоит в пути, — поэтому
возвращается и подсказка профиля.
"""
match = _LAUNCHER_PAGE_PATH.fullmatch(parsed.path)
if match is None:
return None
tail = match.group(2)
if not tail.lower().startswith(("http:/", "https:/")):
tail = unquote(tail)
# Прокси и мессенджеры склеивают «//» в пути: ``https:/sub.host``.
tail = re.sub(r"^(https?):/+", r"\1://", tail, flags=re.IGNORECASE)
if parsed.query:
tail += f"?{parsed.query}"
if parsed.fragment:
tail += f"#{parsed.fragment}"
inner = urlsplit(tail)
if inner.scheme.lower() in {"http", "https"} and inner.hostname:
return tail, match.group(1).lower()
return None
def _resolve_happ_crypt_source(link: str) -> tuple[str, str]:
"""Развернуть ``happ://crypt*`` в обычный URL подписки."""
try:
_, payload = decrypt_happ_link(link)
except HappCryptError as exc:
raise SubscriptionFetchError(str(exc)) from exc
candidate = _decode_wrapped_http_url(payload.strip())
inner = urlsplit(candidate)
if inner.scheme.lower() in {"http", "https"} and inner.hostname:
return candidate, "happ"
raise SubscriptionFetchError(
"Расшифрованная ссылка Happ не содержит адрес подписки HTTP/HTTPS"
)
def _decode_wrapped_http_url(value: str) -> str:
if value.lower().startswith(("http://", "https://")):
return value
compact = "".join(value.split())
if not compact or not re.fullmatch(r"[A-Za-z0-9_+/=-]+", compact):
return value
padded = compact + "=" * ((4 - len(compact) % 4) % 4)
try:
return base64.b64decode(
padded.replace("-", "+").replace("_", "/"), validate=True
).decode("utf-8").strip()
except (ValueError, binascii.Error, UnicodeDecodeError):
return value
def validate_subscription_url(url: str) -> str:
return resolve_subscription_source(url)[0]
def validate_hwid(value: str) -> str:
text = str(value or "").strip()
if not text:
raise SubscriptionFetchError("HWID не задан")
if len(text) > 128 or any(ord(char) < 32 or ord(char) == 127 for char in text):
raise SubscriptionFetchError("HWID содержит недопустимые символы или слишком длинный")
return text
def is_panel_compatible_hwid(value: str) -> bool:
"""Проверить идентификатор по образцу Happ, который принимают панели.
Панель с лимитом устройств принимает латиницу, цифры, ``-`` и ``=`` длиной
10..64 символа. Идентификатор вне этого образца она молча игнорирует, и
лимит устройств перестаёт работать вместо явной ошибки, поэтому несовпадение
стоит показать пользователю до отправки.
"""
return bool(_PANEL_HWID.fullmatch(str(value or "").strip()))
def subscription_request_headers(subscription: Subscription) -> dict[str, str]:
"""Собрать заголовки запроса подписки.
Имитация клиента должна совпадать с оригиналом, а не быть похожей: панели
сопоставляют запрос правилами по заголовкам, и лишний заголовок выдаёт
подделку не хуже отсутствующего. Набор Happ снят с реального клиента — там
нет ни ``Accept``, ни ``X-App-Version``, а локаль короткая (``ru``).
"""
profile = normalize_client_profile(subscription.client_profile)
user_agent = subscription.user_agent.strip() or profile_default_user_agent(profile)
headers = {
"Accept-Encoding": "identity",
"User-Agent": _safe_header_value(user_agent, "User-Agent"),
}
app_version = {
"incy": "1.0",
"v2raytun": "2.3.5",
}.get(profile, APP_VERSION)
if profile in {"incy", "v2raytun"}:
headers["Accept"] = "*/*"
headers["X-App-Version"] = app_version
elif profile != "happ":
headers["Accept"] = "text/plain, application/json;q=0.9, */*;q=0.5"
if profile == "incy":
headers["X-Client"] = "INCY"
headers["X-Device-Locale"] = _safe_header_value(_device_locale(), "X-Device-Locale")
if subscription.send_hwid:
hwid = validate_hwid(subscription.hwid)
headers.update(
{
"X-HWID": hwid,
"X-Device-OS": "Windows",
"X-Ver-OS": _safe_header_value(platform.release() or "Windows", "X-Ver-OS"),
"X-Device-Model": _safe_header_value(
platform.machine() or "Desktop", "X-Device-Model"
),
}
)
if profile == "incy":
headers["X-Device-ID"] = hwid
if profile == "happ":
headers["X-Device-Locale"] = _safe_header_value(
_short_locale(_device_locale()), "X-Device-Locale"
)
return headers
def _device_locale() -> str:
"""Вернуть локаль в форме ``ru-RU``.
На Windows ``locale.getlocale()`` отдаёт человекочитаемое имя вида
``Russian_Russia``, которое ни один клиент в заголовок не пишет, поэтому
оно переводится в код языка через таблицу ``locale.windows_locale``.
"""
value = locale.getlocale()[0] or "ru_RU"
if not _LOCALE_CODE.fullmatch(value):
value = _windows_locale_code(value) or "ru_RU"
return value.replace("_", "-")
def _windows_locale_code(value: str) -> str | None:
"""Перевести ``Russian_Russia`` в ``ru_RU`` через таблицу псевдонимов locale.
Полное имя в таблице обычно отсутствует, поэтому запасной путь — имя языка:
регион при этом может огрубиться (``English_United States`` -> ``en_EN``),
но Happ всё равно берёт из локали только язык.
"""
text = value.strip()
for candidate in (text, text.split("_")[0]):
if not candidate:
continue
try:
normalized = locale.normalize(candidate).split(".")[0]
except (TypeError, ValueError):
continue
if normalized and _LOCALE_CODE.fullmatch(normalized):
return normalized
return None
def _short_locale(value: str) -> str:
"""Happ передаёт только язык: ``ru``, а не ``ru-RU``."""
return value.split("-")[0].split("_")[0] or "ru"
def _safe_header_value(value: str, name: str) -> str:
text = str(value or "").strip()
if not text or any(char in text for char in "\r\n"):
raise SubscriptionFetchError(f"{name} содержит недопустимое значение")
return text
def mask_subscription_url(url: str) -> str:
"""Hide the complete subscription URL on screenshot-visible surfaces."""
return "********" if str(url or "").strip() else ""
def describe_http_failure(status: int, headers: dict[str, str]) -> str:
"""Объяснить отказ сервера подписки.
Панели с лимитом устройств (Remnawave и совместимые) отвечают обычным 404 и
сообщают причину отдельным заголовком. Без их разбора пользователь видит
«HTTP 404» и не понимает, что упёрся в лимит устройств.
"""
normalized = {str(key).lower(): str(value) for key, value in (headers or {}).items()}
# Remnawave шлёт ``x-hwid-limit`` вместе с обеими причинами, поэтому
# отсутствие HWID проверяется первым: иначе оно выдаётся за лимит.
if "x-hwid-not-supported" in normalized:
return (
"Провайдер требует идентификатор устройства (HWID). "
"Включите отправку HWID в настройках подписки"
)
if "x-hwid-max-devices-reached" in normalized or "x-hwid-limit" in normalized:
return (
"Достигнут лимит устройств подписки. Отвяжите лишнее устройство "
"в личном кабинете или у провайдера"
)
if status == 403:
# Панели сопоставляют запрос правилами по заголовкам и отвечают 403, когда
# ни одно правило не подошло либо сработало правило блокировки клиента.
# Cloudflare перед панелью отдаёт тот же код, но со своей HTML-страницей.
if "cf-ray" in normalized or "cf-mitigated" in normalized:
return (
"Защита сайта провайдера отклонила запрос (HTTP 403). "
"Попробуйте обновить подписку через прокси"
)
return (
"Провайдер отклонил запрос клиента (HTTP 403). "
"Смените профиль клиента или User-Agent в настройках подписки"
)
return f"HTTP {status}"
def sanitize_fetch_error(error: BaseException) -> str:
text = str(error) or error.__class__.__name__
text = re.sub(r"https?://[^\s'\"]+", "<URL скрыт>", text)
return text[:500]
def fetch_subscription(
subscription: Subscription,
*,
mode: str = "auto",
proxy_port: int | None = None,
timeout: float = 15,
max_bytes: int = MAX_SUBSCRIPTION_BYTES,
force_refresh: bool = False,
deadline: float = SUBSCRIPTION_FETCH_DEADLINE,
cancel: threading.Event | None = None,
on_route: Callable[[bool], None] | None = None,
) -> SubscriptionFetchResult:
"""Загрузить подписку по первому ответившему маршруту.
Маршруты не ждут друг друга: прямой стартует сразу, прокси — после
``PROXY_HEAD_START`` или сразу, если прямой путь уже отвалился. Вся
загрузка укладывается в ``deadline`` при любом поведении сети.
"""
url = validate_subscription_url(subscription.url)
if mode not in {"auto", "direct", "proxy"}:
raise SubscriptionFetchError(f"Неизвестный режим загрузки: {mode}")
# Открытый http:// напрямую показал бы провайдеру весь список серверов —
# только через туннель.
cleartext = cleartext_over_internet(url)
attempts: list[tuple[bool, int | None]] = []
if mode in {"auto", "direct"} and not cleartext:
attempts.append((False, None))
if mode in {"auto", "proxy"} and proxy_port and proxy_port > 0:
attempts.append((True, int(proxy_port)))
if not attempts:
if cleartext:
raise SubscriptionFetchError(CLEARTEXT_DIRECT_ERROR)
raise SubscriptionFetchError("Активный HTTP-прокси недоступен")
unconditional = bool(force_refresh) or (
int(subscription.parser_revision) != SUBSCRIPTION_PARSER_REVISION
)
started = time.monotonic()
expires = started + max(0.0, float(deadline))
results: queue.SimpleQueue[tuple[bool, object]] = queue.SimpleQueue()
def run(via_proxy: bool, port: int | None) -> None:
try:
outcome: object = _fetch_once(
subscription,
url=url,
via_proxy=via_proxy,
proxy_port=port,
timeout=timeout,
max_bytes=max_bytes,
force_refresh=unconditional,
expires_at=expires,
)
except Exception as exc:
outcome = exc
results.put((via_proxy, outcome))
pending = list(attempts)
running: set[bool] = set()
def start_next() -> None:
via_proxy, port = pending.pop(0)
running.add(via_proxy)
if on_route is not None:
on_route(via_proxy)
# urllib нельзя прервать снаружи, поэтому попытка живёт в daemon-потоке:
# координатор перестаёт её ждать, а она доживает свой таймаут сама.
threading.Thread(
target=run, args=(via_proxy, port), name="subscription-fetch", daemon=True
).start()
start_next()
next_start = started + PROXY_HEAD_START
errors: list[str] = []
held: SubscriptionServerResponseError | None = None
while running:
if cancel is not None and cancel.is_set():
raise SubscriptionFetchError("Обновление подписки отменено")
now = time.monotonic()
if now >= expires:
break
if pending and now >= next_start:
start_next()
continue
wait = expires - now
if pending:
wait = min(wait, next_start - now)
if cancel is not None:
wait = min(wait, 0.2)
try:
via_proxy, outcome = results.get(timeout=wait)
except queue.Empty:
continue
running.discard(via_proxy)
if isinstance(outcome, SubscriptionFetchResult):
outcome.elapsed = time.monotonic() - started
return outcome
if isinstance(outcome, SubscriptionServerResponseError):
if via_proxy and False in running:
# Прямой маршрут ещё в пути, а его ответ главнее: отказ через
# прокси бывает отказом выходному адресу VPN, а не подписке.
held = outcome
expires = min(expires, time.monotonic() + _DIRECT_GRACE)
continue
# The server answered on this path. Its answer is the same on the
# VPN proxy, so surface it now instead of masking it behind the
# proxy attempt's transport error. This is the direct 404 the user
# must see, not a "loading failed via VPN".
raise SubscriptionFetchError(
f"Не удалось загрузить подписку: {outcome}"
) from outcome
# No response reached us on this path (DNS, connect, TLS handshake,
# timeout). Only this case is worth retrying through the proxy.
errors.append(sanitize_fetch_error(outcome)) # type: ignore[arg-type]
if pending:
start_next()
if held is not None:
raise SubscriptionFetchError(f"Не удалось загрузить подписку: {held}") from held
if running:
errors.append(f"сервер не ответил за {time.monotonic() - started:.0f} с")
detail = "; ".join(dict.fromkeys(errors)) or "неизвестная ошибка"
raise SubscriptionFetchError(f"Не удалось загрузить подписку: {detail}")
def _fetch_once(
subscription: Subscription,
*,
url: str,
via_proxy: bool,
proxy_port: int | None,
timeout: float,
max_bytes: int,
force_refresh: bool = False,
expires_at: float | None = None,
) -> SubscriptionFetchResult:
proxy_handler = (
urllib.request.ProxyHandler(
{
"http": f"http://{PROXY_HOST}:{proxy_port}",
"https": f"http://{PROXY_HOST}:{proxy_port}",
}
)
if via_proxy and proxy_port
else urllib.request.ProxyHandler({})
)
opener = build_opener(proxy_handler, _SafeRedirectHandler(direct=not via_proxy))
headers = subscription_request_headers(subscription)
if not force_refresh and subscription.etag:
headers["If-None-Match"] = subscription.etag
if not force_refresh and subscription.last_modified:
headers["If-Modified-Since"] = subscription.last_modified
if not via_proxy and browser_fetch.SERVICE.available():
# Напрямую провайдер видит рукопожатие: стеком Chromium оно как у
# Chrome. Через туннель его не видно — там прежний urllib.
try:
return _fetch_direct_in_browser(
url, headers, timeout=timeout, max_bytes=max_bytes,
force_refresh=force_refresh, expires_at=expires_at,
)
except browser_fetch.BrowserFetchUnavailable:
pass
request = urllib.request.Request(url, headers=headers)
try:
response = opener.open(request, timeout=timeout)
except urllib.error.HTTPError as exc:
if exc.code == 304:
if force_refresh or not (
headers.get("If-None-Match") or headers.get("If-Modified-Since")
):
raise SubscriptionServerResponseError(
"Сервер вернул 304 без условного запроса; полное тело подписки не получено"
) from exc
return SubscriptionFetchResult(
headers=_response_headers(exc.headers),
status=304,
not_modified=True,
via_proxy=via_proxy,
)
raise SubscriptionServerResponseError(
describe_http_failure(exc.code, _response_headers(exc.headers))
) from exc
with response:
final_url = response.geturl()
if urlsplit(final_url).scheme.lower() not in {"http", "https"}:
raise SubscriptionServerResponseError("Ответ подписки пришёл по небезопасной схеме")
response_headers = _response_headers(response.headers)
if _HWID_REFUSAL_HEADERS.intersection(response_headers):
# Отказ по HWID часто приходит как HTTP 200 с узлами-заглушками
# ``0.0.0.0:1``, где текст ошибки спрятан в именах. Импортировать их
# нельзя: они затрут рабочий список серверов подписки.
raise SubscriptionServerResponseError(
describe_http_failure(int(getattr(response, "status", 200) or 200), response_headers)
)
data = _read_body(response, max_bytes + 1, expires_at)
if len(data) > max_bytes:
raise SubscriptionServerResponseError(
f"Ответ подписки превышает {max_bytes // (1024 * 1024)} МиБ"
)
return SubscriptionFetchResult(
data=data,
headers=response_headers,
status=int(getattr(response, "status", 200) or 200),
via_proxy=via_proxy,
)
def _fetch_direct_in_browser(
url: str,
headers: dict[str, str],
*,
timeout: float,
max_bytes: int,
force_refresh: bool,
expires_at: float | None,
) -> SubscriptionFetchResult:
"""Прямая попытка стеком Chromium — с той же семантикой ответов, что у urllib."""
if expires_at is not None:
timeout = max(0.5, min(timeout, expires_at - time.monotonic()))
try:
response = browser_fetch.SERVICE.fetch(
url, headers=tuple(headers.items()), timeout=timeout, max_bytes=max_bytes + 1,
)
except browser_fetch.BrowserFetchUnavailable:
raise
except browser_fetch.BrowserFetchError as exc:
# Ответа сервера нет — как у urllib: дальше решает маршрут через прокси.
raise urllib.error.URLError(str(exc)) from exc
response_headers = {name.lower(): value for name, value in response.headers}
if response.redirect_refused:
if cleartext_over_internet(response.redirect_refused):
raise SubscriptionFetchError(CLEARTEXT_DIRECT_ERROR)
raise SubscriptionFetchError("Перенаправление подписки использует небезопасную схему")
if response.status == 304:
if force_refresh or not (headers.get("If-None-Match") or headers.get("If-Modified-Since")):
raise SubscriptionServerResponseError(
"Сервер вернул 304 без условного запроса; полное тело подписки не получено"
)
return SubscriptionFetchResult(headers=response_headers, status=304, not_modified=True)
if response.status >= 400 or response.status < 200:
raise SubscriptionServerResponseError(describe_http_failure(response.status, response_headers))
if _HWID_REFUSAL_HEADERS.intersection(response_headers):
raise SubscriptionServerResponseError(describe_http_failure(response.status, response_headers))
if response.truncated or len(response.body) > max_bytes:
raise SubscriptionServerResponseError(f"Ответ подписки превышает {max_bytes // (1024 * 1024)} МиБ")
return SubscriptionFetchResult(data=response.body, headers=response_headers, status=response.status)
def _read_body(response, limit: int, expires_at: float | None) -> bytes:
"""Прочитать тело, не выходя за общий дедлайн.
``read(n)`` ждёт все ``n`` байт, и таймаут сокета действует на каждый recv
отдельно — сервер, цедящий ответ по капле, держал бы поток бесконечно.
``read1`` возвращается после одного recv, между ними проверяется срок.
"""
read1 = getattr(response, "read1", None)
if expires_at is None or read1 is None:
return response.read(limit)
chunks: list[bytes] = []
size = 0
while size < limit:
if time.monotonic() >= expires_at:
raise TimeoutError("сервер слишком медленно отдаёт подписку")
chunk = read1(min(_READ_CHUNK, limit - size))
if not chunk:
break
chunks.append(chunk)
size += len(chunk)
return b"".join(chunks)
def _response_headers(headers) -> dict[str, str]:
return {str(key).lower(): str(value) for key, value in headers.items()}