fix(proxies): skip a proxy provider that fails to load

A bare --proxy query moves past it; naming it (--proxy nordvpn:us) fails with the load error. dl, search and the REST path share one loader.
This commit is contained in:
imSp4rky
2026-09-25 17:28:29 -06:00
parent 34c04e0587
commit 40281660f1
9 changed files with 190 additions and 153 deletions
+5
View File
@@ -128,6 +128,11 @@ So if you have both Basic and NordVPN configured and use `--proxy us`, a `us` en
your Basic config wins, because unshackle tries Basic first. To skip ahead to a specific your Basic config wins, because unshackle tries Basic first. To skip ahead to a specific
proxy provider, prefix the query (`--proxy nordvpn:us`). proxy provider, prefix the query (`--proxy nordvpn:us`).
If a proxy provider cannot load, for example because its server list cannot be fetched
or its config is not valid, unshackle logs a warning and continues with the other proxy
providers. A bare query skips the proxy provider that did not load. A query that names it,
such as `--proxy nordvpn:us`, fails and shows the reason it did not load.
!!! note "A bare region reaches Control D last" !!! note "A bare region reaches Control D last"
unshackle asks Control D for a bare region such as `--proxy us` only when no other unshackle asks Control D for a bare region such as `--proxy us` only when no other
loaded proxy provider has a proxy for it. Hola loads automatically when the loaded proxy provider has a proxy for it. Hola loads automatically when the
+63
View File
@@ -0,0 +1,63 @@
"""Unit tests for the shared proxy provider loader: a proxy provider that fails to load is skipped
by a bare query and reported by a query that names it."""
from __future__ import annotations
import logging
import pytest
import requests
from unshackle.core.config import config
from unshackle.core.proxies import NordVPN, SurfsharkVPN
from unshackle.core.proxies.resolve import Unavailable, find_provider, load_proxy_providers, resolve_proxy
pytestmark = pytest.mark.unit
CREDENTIALS = {"username": "a" * 24, "password": "b" * 24}
OTHERS = ("basic", "expressvpn", "protonvpn", "windscribevpn", "gluetun", "hola", "controld")
@pytest.fixture(autouse=True)
def nordvpn_down_surfshark_up(monkeypatch: pytest.MonkeyPatch) -> None:
def refuse() -> list[dict]:
raise requests.ConnectionError("no route to api.nordvpn.com")
clusters = [{"countryCode": "US", "connectionName": "us-dal.prod.surfshark.com", "location": "Dallas"}]
monkeypatch.setattr(config, "proxy_providers", {"nordvpn": CREDENTIALS, "surfsharkvpn": CREDENTIALS})
monkeypatch.setattr(NordVPN, "get_countries", staticmethod(refuse))
monkeypatch.setattr(SurfsharkVPN, "get_countries", staticmethod(lambda: clusters))
def test_failed_provider_is_kept_as_unavailable(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
nordvpn, surfshark = load_proxy_providers(OTHERS)
assert isinstance(nordvpn, Unavailable) and nordvpn.name == "NordVPN"
assert isinstance(surfshark, SurfsharkVPN)
assert "NordVPN proxy provider could not load" in caplog.text
def test_bare_query_skips_the_failed_provider() -> None:
uri = resolve_proxy("us", load_proxy_providers(OTHERS))
assert uri.endswith("@us-dal.prod.surfshark.com:443")
def test_query_naming_another_provider_still_works() -> None:
uri = resolve_proxy("surfsharkvpn:us", load_proxy_providers(OTHERS))
assert uri.endswith("@us-dal.prod.surfshark.com:443")
@pytest.mark.parametrize("name", ["nordvpn", "NordVPN"])
def test_query_naming_the_failed_provider_reports_why(name: str) -> None:
with pytest.raises(ValueError, match="NordVPN could not load: no route to api.nordvpn.com"):
resolve_proxy(f"{name}:us", load_proxy_providers(OTHERS))
def test_query_naming_an_unknown_provider_lists_the_loaded_ones() -> None:
with pytest.raises(ValueError, match=r"'mullvad' was not found. Available: \['SurfsharkVPN'\]"):
find_provider(load_proxy_providers(OTHERS), "mullvad")
def test_raise_errors_raises_the_load_failure() -> None:
with pytest.raises(requests.ConnectionError):
load_proxy_providers(OTHERS, raise_errors=True)
+7 -45
View File
@@ -53,18 +53,8 @@ from unshackle.core.events import events
from unshackle.core.export_name import ExportNamer, move_export, plural, season_episodes, title_label from unshackle.core.export_name import ExportNamer, move_export, plural, season_episodes, title_label
from unshackle.core.providers.anilist import parse_anilist_ref from unshackle.core.providers.anilist import parse_anilist_ref
from unshackle.core.providers.tvdb import SEASON_TYPES, parse_int from unshackle.core.providers.tvdb import SEASON_TYPES, parse_int
from unshackle.core.proxies import ( from unshackle.core.proxies import Basic
Basic, from unshackle.core.proxies.resolve import REGION, find_provider, is_loopback, load_proxy_providers, resolve_proxy
ControlD,
ExpressVPN,
Gluetun,
Hola,
NordVPN,
ProtonVPN,
SurfsharkVPN,
WindscribeVPN,
)
from unshackle.core.proxies.resolve import REGION, is_loopback, resolve_proxy
from unshackle.core.service import Service, grow_session_pool from unshackle.core.service import Service, grow_session_pool
from unshackle.core.services import Services from unshackle.core.services import Services
from unshackle.core.temp import with_task_temp from unshackle.core.temp import with_task_temp
@@ -1363,29 +1353,7 @@ class dl:
self.proxy_providers = list(proxy_providers) self.proxy_providers = list(proxy_providers)
else: else:
with console.status("Loading Proxy Providers...", spinner="dots"): with console.status("Loading Proxy Providers...", spinner="dots"):
if config.proxy_providers.get("basic"): self.proxy_providers = load_proxy_providers()
self.proxy_providers.append(Basic(**config.proxy_providers["basic"]))
# ExpressVPN/ProtonVPN auto-load when their default cookie file exists (no yaml needed)
expressvpn = ExpressVPN(**(config.proxy_providers.get("expressvpn") or {}))
if config.proxy_providers.get("expressvpn") or expressvpn.cache_path.is_file():
self.proxy_providers.append(expressvpn)
if config.proxy_providers.get("nordvpn"):
self.proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))
proton = ProtonVPN(**(config.proxy_providers.get("protonvpn") or {}))
if config.proxy_providers.get("protonvpn") or proton.cookie_path.is_file():
self.proxy_providers.append(proton)
if config.proxy_providers.get("surfsharkvpn"):
self.proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"]))
if config.proxy_providers.get("windscribevpn"):
self.proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"]))
if config.proxy_providers.get("gluetun"):
self.proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"]))
if binaries.HolaProxy:
self.proxy_providers.append(Hola())
if config.proxy_providers.get("controld"):
self.proxy_providers.append(ControlD(**config.proxy_providers["controld"]))
for proxy_provider in self.proxy_providers:
self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
if proxy and proxy.startswith("controld://"): if proxy and proxy.startswith("controld://"):
proxy = ctx.params["proxy"] = resolve_proxy(proxy, []) proxy = ctx.params["proxy"] = resolve_proxy(proxy, [])
@@ -1413,16 +1381,10 @@ class dl:
) )
with console.status(status_msg, spinner="dots"): with console.status(status_msg, spinner="dots"):
if requested_provider: if requested_provider:
proxy_provider = next( try:
( proxy_provider = find_provider(self.proxy_providers, requested_provider)
x except ValueError as e:
for x in self.proxy_providers self.log.error(str(e))
if x.__class__.__name__.lower() == requested_provider.lower()
),
None,
)
if not proxy_provider:
self.log.error(f"The proxy provider '{requested_provider}' was not recognised.")
sys.exit(1) sys.exit(1)
proxy_uri = proxy_provider.get_proxy(proxy) proxy_uri = proxy_provider.get_proxy(proxy)
if not proxy_uri: if not proxy_uri:
+7 -40
View File
@@ -12,23 +12,12 @@ from rich.rule import Rule
from rich.tree import Tree from rich.tree import Tree
from unshackle.commands.dl import dl from unshackle.commands.dl import dl
from unshackle.core import binaries
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.console import console from unshackle.core.console import console
from unshackle.core.constants import context_settings from unshackle.core.constants import context_settings
from unshackle.core.proxies import ( from unshackle.core.proxies import Basic
Basic,
ControlD,
ExpressVPN,
Gluetun,
Hola,
NordVPN,
ProtonVPN,
SurfsharkVPN,
WindscribeVPN,
)
from unshackle.core.proxies.proxy import Proxy from unshackle.core.proxies.proxy import Proxy
from unshackle.core.proxies.resolve import REGION from unshackle.core.proxies.resolve import REGION, find_provider, load_proxy_providers
from unshackle.core.service import Service from unshackle.core.service import Service
from unshackle.core.services import Services from unshackle.core.services import Services
from unshackle.core.utils.click_types import ContextData from unshackle.core.utils.click_types import ContextData
@@ -78,28 +67,7 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
ctx.params["proxy"] = None ctx.params["proxy"] = None
else: else:
with console.status("Loading Proxy Providers...", spinner="dots"): with console.status("Loading Proxy Providers...", spinner="dots"):
if config.proxy_providers.get("basic"): proxy_providers = load_proxy_providers()
proxy_providers.append(Basic(**config.proxy_providers["basic"]))
expressvpn = ExpressVPN(**(config.proxy_providers.get("expressvpn") or {}))
if config.proxy_providers.get("expressvpn") or expressvpn.cache_path.is_file():
proxy_providers.append(expressvpn)
if config.proxy_providers.get("nordvpn"):
proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))
proton = ProtonVPN(**(config.proxy_providers.get("protonvpn") or {}))
if config.proxy_providers.get("protonvpn") or proton.cookie_path.is_file():
proxy_providers.append(proton)
if config.proxy_providers.get("surfsharkvpn"):
proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"]))
if config.proxy_providers.get("windscribevpn"):
proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"]))
if config.proxy_providers.get("gluetun"):
proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"]))
if binaries.HolaProxy:
proxy_providers.append(Hola())
if config.proxy_providers.get("controld"):
proxy_providers.append(ControlD(**config.proxy_providers["controld"]))
for proxy_provider in proxy_providers:
log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
if proxy: if proxy:
requested_provider = None requested_provider = None
@@ -109,11 +77,10 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
proxy = proxy.lower() proxy = proxy.lower()
with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"): with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"):
if requested_provider: if requested_provider:
proxy_provider = next( try:
(x for x in proxy_providers if x.__class__.__name__.lower() == requested_provider), None proxy_provider = find_provider(proxy_providers, requested_provider)
) except ValueError as e:
if not proxy_provider: log.error(str(e))
log.error(f"The proxy provider '{requested_provider}' was not recognised.")
sys.exit(1) sys.exit(1)
proxy_uri = proxy_provider.get_proxy(proxy) proxy_uri = proxy_provider.get_proxy(proxy)
if not proxy_uri: if not proxy_uri:
+4 -4
View File
@@ -163,7 +163,9 @@ class NordVPN(Proxy):
Note: There may not always be more than one recommended server. Note: There may not always be more than one recommended server.
""" """
res = requests.get( res = requests.get(
url="https://api.nordvpn.com/v1/servers/recommendations", params={"filters[country_id]": country_id} url="https://api.nordvpn.com/v1/servers/recommendations",
params={"filters[country_id]": country_id},
timeout=10,
) )
if not res.ok: if not res.ok:
raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]") raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]")
@@ -176,9 +178,7 @@ class NordVPN(Proxy):
@staticmethod @staticmethod
def get_countries() -> list[dict]: def get_countries() -> list[dict]:
"""Get a list of available Countries and their metadata.""" """Get a list of available Countries and their metadata."""
res = requests.get( res = requests.get(url="https://api.nordvpn.com/v1/servers/countries", timeout=10)
url="https://api.nordvpn.com/v1/servers/countries",
)
if not res.ok: if not res.ok:
raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]") raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]")
+96 -56
View File
@@ -1,7 +1,4 @@
"""Shared proxy provider initialization and resolution. """Shared proxy provider loading, lookup and resolution for dl, search, the REST API and the remote client."""
Used by both the REST API handlers and the remote service client.
"""
from __future__ import annotations from __future__ import annotations
@@ -11,6 +8,7 @@ import re
from typing import Any, List, Optional from typing import Any, List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from unshackle.core.proxies.proxy import Proxy
from unshackle.core.utils.redact import mask_proxy from unshackle.core.utils.redact import mask_proxy
log = logging.getLogger("proxies") log = logging.getLogger("proxies")
@@ -18,58 +16,106 @@ log = logging.getLogger("proxies")
REGION = r"[a-z]{2,4}(?:_[a-z0-9]{2,4})?(?:-[a-z0-9]+|:[a-z][a-z0-9]*)*\d*" REGION = r"[a-z]{2,4}(?:_[a-z0-9]{2,4})?(?:-[a-z0-9]+|:[a-z][a-z0-9]*)*\d*"
def initialize_proxy_providers(raise_errors: bool = False, quiet: bool = False) -> List[Any]: class Unavailable(Proxy):
"""Initialise and return available proxy providers from config. """Stands in for a proxy provider that failed to load: a bare query skips it, a query that names it fails."""
A proxy provider that fails to build is logged and skipped, unless *raise_errors* is set. def __init__(self, name: str, error: Exception) -> None:
*quiet* drops the per-provider summary lines: rendering a proxy provider asks some of them self.name = name
for their server catalogue over the network, which a repeated caller must not pay for. # The message only: the exception's traceback would keep the failed build's frames alive.
self.error = str(error)
def __repr__(self) -> str:
return f"failed to load ({self.error})"
def get_proxy(self, query: str) -> Optional[str]:
return None
def find_provider(proxy_providers: List[Any], name: str) -> Any:
""" """
Find a proxy provider by its class name, case-insensitive.
Raises ValueError when no proxy provider has that name, or when it failed to load.
"""
for provider in proxy_providers:
if isinstance(provider, Unavailable):
if provider.name.lower() == name.lower():
raise ValueError(f"The proxy provider {provider.name} could not load: {provider.error}")
elif provider.__class__.__name__.lower() == name.lower():
return provider
available = [x.__class__.__name__ for x in proxy_providers if not isinstance(x, Unavailable)]
raise ValueError(f"The proxy provider '{name}' was not found. Available: {available}")
def load_proxy_providers(exclude: tuple[str, ...] = (), raise_errors: bool = False, quiet: bool = False) -> List[Any]:
"""
Build the proxy providers set in config, in the order a bare query tries them.
Each proxy provider loads on its own. One that fails is logged and kept as Unavailable, so a bare
query skips it and a query that names it reports why. *raise_errors* raises the failure instead.
*exclude* holds lowercase class names to leave out.
"""
from unshackle.core import binaries
from unshackle.core.config import config
from unshackle.core.proxies import (
Basic,
ControlD,
ExpressVPN,
Gluetun,
Hola,
NordVPN,
ProtonVPN,
SurfsharkVPN,
WindscribeVPN,
)
settings = config.proxy_providers
# Control D is last: a bare region reaches it only when nothing else answers, since it changes the account.
classes = (Basic, ExpressVPN, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN, Gluetun, Hola, ControlD)
def build(cls: type) -> Any:
key = cls.__name__.lower()
if cls is Hola:
return Hola() if binaries.HolaProxy else None
if cls is ExpressVPN or cls is ProtonVPN:
# These also load with no config when their default cookie file exists.
provider = cls(**(settings.get(key) or {}))
cookie = provider.cache_path if cls is ExpressVPN else provider.cookie_path
return provider if settings.get(key) or cookie.is_file() else None
return cls(**settings[key]) if settings.get(key) else None
proxy_providers: list = [] proxy_providers: list = []
try: for cls in classes:
from unshackle.core import binaries name = cls.__name__
from unshackle.core.config import config as main_config if name.lower() in exclude:
from unshackle.core.proxies.basic import Basic continue
from unshackle.core.proxies.controld import ControlD try:
from unshackle.core.proxies.expressvpn import ExpressVPN provider = build(cls)
from unshackle.core.proxies.hola import Hola if provider is None:
from unshackle.core.proxies.nordvpn import NordVPN continue
from unshackle.core.proxies.proton import ProtonVPN if not quiet:
from unshackle.core.proxies.surfsharkvpn import SurfsharkVPN log.info(f"Loaded {name}: {provider}")
except Exception as e:
if raise_errors:
raise
log.warning(f"The {name} proxy provider could not load, so a bare --proxy query skips it: {e}")
provider = Unavailable(name, e)
proxy_providers.append(provider)
proxy_config = getattr(main_config, "proxy_providers", {}) return proxy_providers
if proxy_config.get("basic"):
proxy_providers.append(Basic(**proxy_config["basic"]))
# ExpressVPN/ProtonVPN auto-load when their default cookie file exists (no yaml needed)
expressvpn = ExpressVPN(**(proxy_config.get("expressvpn") or {}))
if proxy_config.get("expressvpn") or expressvpn.cache_path.is_file():
proxy_providers.append(expressvpn)
if proxy_config.get("nordvpn"):
proxy_providers.append(NordVPN(**proxy_config["nordvpn"]))
proton = ProtonVPN(**(proxy_config.get("protonvpn") or {}))
if proxy_config.get("protonvpn") or proton.cookie_path.is_file():
proxy_providers.append(proton)
if proxy_config.get("surfsharkvpn"):
proxy_providers.append(SurfsharkVPN(**proxy_config["surfsharkvpn"]))
if hasattr(binaries, "HolaProxy") and binaries.HolaProxy:
proxy_providers.append(Hola())
# last: a bare region reaches it only when nothing else answers, since it changes the account
if proxy_config.get("controld"):
proxy_providers.append(ControlD(**proxy_config["controld"]))
if not quiet: def initialize_proxy_providers(raise_errors: bool = False, quiet: bool = False) -> List[Any]:
for provider in proxy_providers: """
log.info(f"Loaded {provider.__class__.__name__}: {provider}") Load the proxy providers for the REST API and the remote service client.
if not proxy_providers:
log.warning("No proxy providers were loaded. Check your proxy provider configuration in unshackle.yaml")
except Exception as e:
if raise_errors:
raise
log.warning(f"Failed to initialize some proxy providers: {e}")
WindscribeVPN and Gluetun are not offered on this path. *quiet* drops the per-provider summary
lines: rendering a proxy provider asks some of them for their server catalogue over the network,
which a repeated caller must not pay for.
"""
proxy_providers = load_proxy_providers(("windscribevpn", "gluetun"), raise_errors, quiet)
if not quiet and not proxy_providers:
log.warning("No proxy providers were loaded. Check your proxy provider configuration in unshackle.yaml")
return proxy_providers return proxy_providers
@@ -99,13 +145,7 @@ def resolve_proxy(proxy: str, proxy_providers: List[Any]) -> Optional[str]:
requested_provider, query = proxy.split(":", maxsplit=1) requested_provider, query = proxy.split(":", maxsplit=1)
if requested_provider: if requested_provider:
provider = next( provider = find_provider(proxy_providers, requested_provider)
(x for x in proxy_providers if x.__class__.__name__.lower() == requested_provider.lower()),
None,
)
if not provider:
available = [x.__class__.__name__ for x in proxy_providers]
raise ValueError(f"Proxy provider '{requested_provider}' not found. Available: {available}")
proxy_uri = provider.get_proxy(query) proxy_uri = provider.get_proxy(query)
if not proxy_uri: if not proxy_uri:
raise ValueError(f"Proxy provider {requested_provider} had no proxy for {query}") raise ValueError(f"Proxy provider {requested_provider} had no proxy for {query}")
+1
View File
@@ -160,6 +160,7 @@ class WindscribeVPN(Proxy):
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
timeout=10,
) )
if not res.ok: if not res.ok:
raise ValueError(f"Failed to get a list of WindscribeVPN locations [{res.status_code}]") raise ValueError(f"Failed to get a list of WindscribeVPN locations [{res.status_code}]")
+2 -4
View File
@@ -729,15 +729,13 @@ def resolve_remote_proxy_arg(proxy_arg: str) -> Optional[str]:
profile's resolver instead (`controld://`), and a bare region query skips Control D. profile's resolver instead (`controld://`), and a bare region query skips Control D.
""" """
from unshackle.core.proxies.controld import ControlD from unshackle.core.proxies.controld import ControlD
from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy from unshackle.core.proxies.resolve import find_provider, initialize_proxy_providers, resolve_proxy
try: try:
providers = initialize_proxy_providers() providers = initialize_proxy_providers()
provider, _, query = proxy_arg.partition(":") provider, _, query = proxy_arg.partition(":")
if provider.lower() == "controld" and query: if provider.lower() == "controld" and query:
controld = next((x for x in providers if isinstance(x, ControlD)), None) controld = find_provider(providers, "controld")
if not controld:
raise ValueError("Proxy provider 'controld' is not configured")
uri = controld.get_remote_proxy(query) uri = controld.get_remote_proxy(query)
if not uri: if not uri:
raise ValueError(f"Control D has no location for {query}") raise ValueError(f"Control D has no location for {query}")
+5 -4
View File
@@ -23,6 +23,7 @@ from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential from unshackle.core.credential import Credential
from unshackle.core.drm import DRM_T from unshackle.core.drm import DRM_T
from unshackle.core.proxies.basic import Basic from unshackle.core.proxies.basic import Basic
from unshackle.core.proxies.resolve import find_provider
from unshackle.core.search_result import SearchResult from unshackle.core.search_result import SearchResult
from unshackle.core.session import ( from unshackle.core.session import (
BACKOFF_FACTOR, BACKOFF_FACTOR,
@@ -217,10 +218,10 @@ class Service(metaclass=ABCMeta):
) )
if proxy_provider_name: if proxy_provider_name:
# Specific provider requested # Specific provider requested
proxy_provider = next( try:
(x for x in ctx.obj.proxy_providers if x.__class__.__name__.lower() == proxy_provider_name), proxy_provider = find_provider(ctx.obj.proxy_providers, proxy_provider_name)
None, except ValueError:
) proxy_provider = None
if proxy_provider: if proxy_provider:
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value) mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
if mapped_proxy_uri: if mapped_proxy_uri: