feat(proxy): try bare --proxy providers in proxy_providers key order

Basic stays first and Control D last; the rest follow their config key order. Geofence and proxy_map lookups go through pick_proxy and skip a provider that fails.
This commit is contained in:
imSp4rky
2026-10-23 08:20:39 -06:00
parent 71f293161e
commit af69e8aad4
5 changed files with 59 additions and 67 deletions
+18 -21
View File
@@ -125,14 +125,18 @@ forwarder for it. See [Control D](#control-d).
### Provider load order
When you do a download without `--no-proxy`, unshackle instantiates every configured
proxy provider once, in this fixed order. It uses that same order for bare-query
resolution:
proxy provider once. It uses that same order for bare-query resolution:
**Basic → ExpressVPN → NordVPN → Proton VPN → Surfshark → Windscribe → Gluetun → Hola → Control D**
1. Basic is always first, so your own proxies win.
2. The other proxy providers follow in the order of their keys under `proxy_providers` in
`unshackle.yaml`. Proxy providers that load without a config key, such as Hola, come
after the listed ones in this default order:
**ExpressVPN → NordVPN → Proton VPN → Surfshark → Windscribe → Gluetun → Hola**
3. Control D is always last.
So if you have both Basic and NordVPN configured and use `--proxy us`, a `us` entry in
your Basic config wins, because unshackle tries Basic first. To skip ahead to a specific
proxy provider, prefix the query (`--proxy nordvpn:us`).
So if you list `windscribevpn` above `nordvpn` and use `--proxy us`, a `us` entry in your
Basic config answers first, then Windscribe. To skip ahead to a specific 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
@@ -1025,21 +1029,14 @@ The returned proxy is HTTPS on **port 443**.
## Developer notes
!!! note "Two resolution paths (developers)"
unshackle has **two** proxy-resolution code paths that are intentionally not
identical:
- `unshackle/commands/dl.py` inlines its own logic and loads **all nine** providers,
including Windscribe and Gluetun.
- `unshackle/core/proxies/resolve.py::initialize_proxy_providers()` is the shared
resolver used by the REST API handlers and the remote-service client. It **omits**
Windscribe and Gluetun.
Both paths accept the same query grammar (a direct URI, a `provider:query`, a bare
country query tried against providers in order, or a `controld://` resolver), but the
set of available providers differs. Keep this in mind when you add a proxy provider:
an entry in `dl.py` does not make it available over the API, and the opposite is also
true.
!!! note "One resolution path (developers)"
`dl`, `search`, the REST API handlers and the remote-service client all load proxy
providers through `load_proxy_providers()` in `unshackle/core/proxies/resolve.py`.
The REST API and the remote-service client call it through
`initialize_proxy_providers()`, which leaves out Gluetun because Gluetun starts a
local Docker container. All paths accept the same query grammar: a direct URI, a
`provider:query`, a bare query tried against providers in order, or a `controld://`
resolver.
The remote-service client resolves `--proxy` with its own
`resolve_remote_proxy_arg()`. It changes a `controld:` query to a `controld://`
+5 -2
View File
@@ -61,8 +61,11 @@ headers:
- **Type:** `dict`  ·  **Default:** `{}`
Proxy/VPN provider configuration. Each sub-key names a proxy provider, and unshackle passes its
block straight to that provider's constructor. See [Proxies & VPN](../../guide/proxies-and-vpn.md)
for the full proxy provider guide. Recognised proxy providers and their exit ports:
block straight to that provider's constructor. A bare `--proxy` query tries Basic first, then
the other proxy providers in the order of their sub-keys, and Control D last (see
[Provider load order](../../guide/proxies-and-vpn.md#provider-load-order)). See
[Proxies & VPN](../../guide/proxies-and-vpn.md) for the full proxy provider guide. Recognised proxy
providers and their exit ports:
| Provider | Config key | Credentials | Proxy scheme/port |
|----------|-----------|-------------|-------------------|
+11
View File
@@ -63,6 +63,17 @@ def test_raise_errors_raises_the_load_failure() -> None:
load_proxy_providers(OTHERS, raise_errors=True)
def test_bare_query_order_follows_config_key_order_with_controld_last(monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.core.proxies import ControlD
order = {"controld": {"token": "t"}, "surfsharkvpn": CREDENTIALS, "nordvpn": CREDENTIALS, "basic": {"us": "x"}}
monkeypatch.setattr(config, "proxy_providers", order)
monkeypatch.setattr(ControlD, "get_locations", lambda self: [])
loaded = load_proxy_providers(("expressvpn", "protonvpn", "hola"), quiet=True)
names = [getattr(p, "name", p.__class__.__name__) for p in loaded]
assert names == ["Basic", "SurfsharkVPN", "NordVPN", "ControlD"]
def test_rest_path_loads_windscribe_but_not_gluetun(monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.core.proxies import Gluetun, WindscribeVPN
from unshackle.core.proxies.resolve import initialize_proxy_providers
+10 -3
View File
@@ -54,7 +54,7 @@ def find_provider(proxy_providers: List[Any], name: str, hint: str = "") -> Any:
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.
Build the proxy providers set in config: Basic first, then config key order, Control D last.
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.
@@ -75,8 +75,15 @@ def load_proxy_providers(exclude: tuple[str, ...] = (), raise_errors: bool = Fal
)
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)
rank = {key: i for i, key in enumerate(settings)}
classes = [
Basic,
*sorted(
(ExpressVPN, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN, Gluetun, Hola),
key=lambda c: rank.get(c.__name__.lower(), len(rank)),
),
ControlD,
]
def build(cls: type) -> Any:
key = cls.__name__.lower()
+15 -41
View File
@@ -25,7 +25,7 @@ from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.drm import DRM_T
from unshackle.core.proxies.basic import Basic
from unshackle.core.proxies.resolve import find_provider
from unshackle.core.proxies.resolve import pick_proxy
from unshackle.core.search_result import SearchResult
from unshackle.core.session import (
BACKOFF_FACTOR,
@@ -229,40 +229,16 @@ class Service(metaclass=ABCMeta):
self.log.info(
f"Found service-specific proxy mapping: {full_proxy_key} -> {sanitize_proxy_for_log(mapped_value)}"
)
if proxy_provider_name:
# Specific provider requested
try:
proxy_provider = find_provider(ctx.obj.proxy_providers, proxy_provider_name)
except ValueError:
proxy_provider = None
if proxy_provider:
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
if mapped_proxy_uri:
proxy = mapped_proxy_uri
self.log.info(
f"Using mapped proxy from {proxy_provider.__class__.__name__}: "
f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}"
)
else:
self.log.warning(
f"Failed to get proxy for mapped value '{sanitize_proxy_for_log(mapped_value)}', using default"
)
else:
self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy")
else:
for proxy_provider in ctx.obj.proxy_providers:
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
if mapped_proxy_uri:
proxy = mapped_proxy_uri
self.log.info(
f"Using mapped proxy from {proxy_provider.__class__.__name__}: "
f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}"
)
break
else:
self.log.warning(
f"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default"
)
try:
proxy_provider, proxy = pick_proxy(ctx.obj.proxy_providers, mapped_value, proxy_provider_name)
self.log.info(
f"Using mapped proxy from {proxy_provider.__class__.__name__}: "
f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}"
)
except ValueError:
self.log.warning(
f"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default"
)
if not proxy:
# don't override the explicit proxy set by the user, even if they may be geoblocked
@@ -284,12 +260,10 @@ class Service(metaclass=ABCMeta):
self.log.info(
f"Service is Geoblocked in your region, getting a Proxy to {requested_proxy}"
)
for proxy_provider in ctx.obj.proxy_providers:
proxy = proxy_provider.get_proxy(requested_proxy)
if proxy:
self.log.info(f"Got Proxy from {proxy_provider.__class__.__name__}")
break
if not proxy:
try:
proxy_provider, proxy = pick_proxy(ctx.obj.proxy_providers, requested_proxy)
self.log.info(f"Got Proxy from {proxy_provider.__class__.__name__}")
except ValueError:
self.log.warning(
f"No proxy available for {requested_proxy}. "
f"Pass --proxy with a proxy in {requested_proxy}, or the request can fail."