Some checks failed
Windows project source guards / test (push) Failing after 41s
Симптом: при включённом VPN обновление xray-подписки падало с таймаутом TLS-рукопожатия, хотя напрямую сервер отвечает (например, честный 404 при отозванном ключе). Причина: в режиме auto попытки идут direct→proxy, но цикл fetch_subscription проваливался на proxy(VPN) даже когда direct уже получил реальный HTTP-ответ. Наружу вылетала ошибка proxy-попытки (handshake timeout), маскируя настоящий ответ сервера. Инвариант: URL подписки указывает на инфраструктуру провайдера и отвечает одинаково на любом сетевом пути. Любой HTTP-ответ (статус, oversize, небезопасный редирект, голый 304) авторитетен и финален; на VPN-прокси уходим только при transport-провале — когда ответа не пришло вовсе (DNS/connect/TLS-таймаут). - Новый SubscriptionServerResponseError маркирует «сервер ответил». - _fetch_once поднимает его для всех случаев с полученным ответом. - Цикл fetch_subscription терминализует его сразу, а к следующей попытке (proxy) переходит только на прочих (transport) исключениях. Тесты: direct HTTP-ответ терминален без proxy-попытки; transport-провал direct уходит на proxy и выигрывает; без proxy transport-провал даёт ошибку; ответ сервера на proxy после transport-провала direct тоже терминален. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
99 lines
3.8 KiB
Python
99 lines
3.8 KiB
Python
"""Direct-first subscription fetch: fall back to the VPN proxy only on a
|
||
transport failure (no response), never after the server already answered.
|
||
|
||
A subscription URL points at the provider's own infrastructure and answers the
|
||
same on any network path. When a direct attempt reaches the server — even with
|
||
a 404 (revoked link / device limit) — that answer is authoritative and must be
|
||
surfaced, instead of being masked by a proxy attempt whose TLS handshake to the
|
||
same host times out through a degraded tunnel.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
from types import SimpleNamespace
|
||
from unittest.mock import patch
|
||
import urllib.error
|
||
|
||
from xray_fluent.importer import subscription_http as sh
|
||
from xray_fluent.importer.subscription_http import (
|
||
SubscriptionFetchError,
|
||
SubscriptionFetchResult,
|
||
SubscriptionServerResponseError,
|
||
SUBSCRIPTION_PARSER_REVISION,
|
||
fetch_subscription,
|
||
)
|
||
|
||
|
||
def _subscription() -> SimpleNamespace:
|
||
return SimpleNamespace(
|
||
url="https://sub.example/a",
|
||
parser_revision=SUBSCRIPTION_PARSER_REVISION,
|
||
etag="",
|
||
last_modified="",
|
||
)
|
||
|
||
|
||
class SubscriptionFetchFallbackTest(unittest.TestCase):
|
||
def test_direct_server_response_is_terminal_no_proxy_attempt(self) -> None:
|
||
attempts: list[bool] = []
|
||
|
||
def fake(_sub, *, via_proxy, **_kw):
|
||
attempts.append(via_proxy)
|
||
raise SubscriptionServerResponseError("HTTP 404")
|
||
|
||
with patch.object(sh, "_fetch_once", side_effect=fake):
|
||
with self.assertRaises(SubscriptionFetchError) as ctx:
|
||
fetch_subscription(_subscription(), mode="auto", proxy_port=1080)
|
||
|
||
# Only the direct attempt ran; a real 404 is never retried via the VPN.
|
||
self.assertEqual(attempts, [False])
|
||
self.assertIn("HTTP 404", str(ctx.exception))
|
||
|
||
def test_transport_failure_falls_back_to_proxy(self) -> None:
|
||
attempts: list[bool] = []
|
||
proxied = SubscriptionFetchResult(data=b"ok", status=200, via_proxy=True)
|
||
|
||
def fake(_sub, *, via_proxy, **_kw):
|
||
attempts.append(via_proxy)
|
||
if not via_proxy:
|
||
raise urllib.error.URLError("_ssl.c:993 handshake timed out")
|
||
return proxied
|
||
|
||
with patch.object(sh, "_fetch_once", side_effect=fake):
|
||
result = fetch_subscription(_subscription(), mode="auto", proxy_port=1080)
|
||
|
||
# No response on the direct path -> the proxy attempt runs and wins.
|
||
self.assertEqual(attempts, [False, True])
|
||
self.assertIs(result, proxied)
|
||
|
||
def test_transport_failure_without_proxy_reports_error(self) -> None:
|
||
def fake(_sub, *, via_proxy, **_kw):
|
||
raise urllib.error.URLError("handshake timed out")
|
||
|
||
with patch.object(sh, "_fetch_once", side_effect=fake):
|
||
with self.assertRaises(SubscriptionFetchError) as ctx:
|
||
fetch_subscription(_subscription(), mode="auto", proxy_port=None)
|
||
|
||
self.assertIn("Не удалось загрузить подписку", str(ctx.exception))
|
||
|
||
def test_proxy_server_response_after_direct_transport_failure_is_terminal(self) -> None:
|
||
attempts: list[bool] = []
|
||
|
||
def fake(_sub, *, via_proxy, **_kw):
|
||
attempts.append(via_proxy)
|
||
if not via_proxy:
|
||
raise urllib.error.URLError("connection refused")
|
||
raise SubscriptionServerResponseError("HTTP 404")
|
||
|
||
with patch.object(sh, "_fetch_once", side_effect=fake):
|
||
with self.assertRaises(SubscriptionFetchError) as ctx:
|
||
fetch_subscription(_subscription(), mode="auto", proxy_port=1080)
|
||
|
||
# Direct had no response, proxy reached the server: surface that answer.
|
||
self.assertEqual(attempts, [False, True])
|
||
self.assertIn("HTTP 404", str(ctx.exception))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|