perf(proxies): cache expressvpn and proton catalogues across requests

A config reload clears the cache; a failed answer is never kept.
This commit is contained in:
imSp4rky
2026-11-02 17:11:58 -07:00
parent 777bb5c8df
commit c744e9a87b
5 changed files with 63 additions and 19 deletions
+30
View File
@@ -35,3 +35,33 @@ def test_empty_catalogue_is_not_kept() -> None:
fetch()
fetch()
assert len(calls) == 2
def test_login_bound_providers_fetch_once_across_builds(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
from types import SimpleNamespace
from unshackle.core.proxies.expressvpn import ExpressVPN
from unshackle.core.proxies.proton import ProtonVPN
requests_made: list[str] = []
def express_request(self: ExpressVPN, method: str, url: str, **_: object) -> SimpleNamespace:
requests_made.append("express")
return SimpleNamespace(json=lambda: {"locations": [{"country_code": "ca"}]})
def proton_api(self: ProtonVPN, method: str, path: str) -> SimpleNamespace:
requests_made.append(path)
body = {"LogicalServers": [{"ExitCountry": "CA"}]} if path.endswith("logicals") else {"VPN": {"MaxTier": 0}}
return SimpleNamespace(json=lambda: body)
monkeypatch.setattr(ExpressVPN, "get_srt", lambda self: "srt")
monkeypatch.setattr(ExpressVPN, "request", express_request)
monkeypatch.setattr(ProtonVPN, "api", proton_api)
for _ in range(2):
assert ExpressVPN(cache_path=str(tmp_path / "e.json")).get_locations() == [{"country_code": "ca"}]
proton = ProtonVPN(cookie_path=str(tmp_path / "c.txt"), cache_path=str(tmp_path / "p.json"))
assert proton.get_logicals() == [{"ExitCountry": "CA"}]
assert proton.get_max_tier() == 0
assert sorted(requests_made) == ["/vpn", "/vpn/v1/logicals", "express"]
+2
View File
@@ -272,8 +272,10 @@ def reload_config(app: web.Application, *, no_key: bool, serve_widevine: bool, s
old_devices, new_devices = _device_stems(config.serve), _device_stems(serve)
vars(config).update(vars(fresh))
from unshackle.core.api.handlers import reset_remote_pool
from unshackle.core.proxies.proxy import CATALOGUES
reset_remote_pool()
CATALOGUES.clear()
app["config"]["users"] = serve_config["users"]
if serve_widevine:
app["config"]["devices"] = _device_map(serve_config["devices"])
+12 -8
View File
@@ -14,7 +14,7 @@ from typing import Any, Optional
import requests
from unshackle.core.config import config
from unshackle.core.proxies.proxy import Proxy
from unshackle.core.proxies.proxy import Proxy, cached_catalogue
log = logging.getLogger("proxies.expressvpn")
@@ -248,15 +248,19 @@ class ExpressVPN(Proxy):
def get_locations(self) -> list[dict]:
if self.locations is None:
srt = self.get_srt()
if not srt:
return []
response = self.request(
"GET", f"{self.API_BASE}/ids2/locations", headers=self.api_headers(srt), params={"protocols": "proxy"}
)
self.locations = response.json().get("locations", [])
self.locations = self.fetch_locations()
return self.locations
@cached_catalogue
def fetch_locations(self) -> list[dict]:
srt = self.get_srt()
if not srt:
return []
response = self.request(
"GET", f"{self.API_BASE}/ids2/locations", headers=self.api_headers(srt), params={"protocols": "proxy"}
)
return response.json().get("locations", [])
def get_endpoints(self, location: dict) -> list[dict]:
srt = self.get_srt()
if not srt:
+17 -10
View File
@@ -15,7 +15,7 @@ import requests
from unshackle.core.config import config
from unshackle.core.console import prompt_user
from unshackle.core.proxies.proxy import Proxy
from unshackle.core.proxies.proxy import Proxy, cached_catalogue
log = logging.getLogger("proxies.proton")
@@ -195,22 +195,29 @@ class ProtonVPN(Proxy):
return chosen
def get_logicals(self) -> list[dict]:
"""Get Proton's server list. Only a list that loaded is cached, so a later session can still fetch it."""
if self.logicals is None:
response = self.api("GET", "/vpn/v1/logicals")
if response is None:
return []
self.logicals = response.json().get("LogicalServers") or []
"""Get Proton's server list. Only a list that loaded is kept, so a later call can still fetch it."""
if not self.logicals:
self.logicals = self.fetch_logicals()
return self.logicals
@cached_catalogue
def fetch_logicals(self) -> list[dict]:
response = self.api("GET", "/vpn/v1/logicals")
return (response.json().get("LogicalServers") or []) if response is not None else []
def get_max_tier(self) -> int:
"""The account's plan tier (0=free, 2=plus). Caps which servers it can connect to."""
if self.max_tier is None:
response = self.api("GET", "/vpn")
vpn = (response.json().get("VPN") or {}) if response is not None else {}
self.max_tier = int(vpn.get("MaxTier") or 0)
self.max_tier = int(self.fetch_vpn().get("MaxTier") or 0)
return self.max_tier
@cached_catalogue
def fetch_vpn(self) -> dict:
"""The account's VPN section, kept only when it names the plan tier."""
response = self.api("GET", "/vpn")
vpn = (response.json().get("VPN") or {}) if response is not None else {}
return vpn if "MaxTier" in vpn else {}
def get_browser_token(self) -> Optional[tuple[str, str]]:
response = self.api("GET", f"/vpn/v1/browser/token?Duration={self.TOKEN_DURATION}")
if response is None:
+2 -1
View File
@@ -10,9 +10,10 @@ F = TypeVar("F", bound=Callable[..., Any])
def cached_catalogue(fetch: F) -> F:
"""Keep a proxy provider's server catalogue for ``CATALOGUE_TTL`` seconds; the next call fetches an empty answer again.
"""Keep a proxy provider's catalogue for ``CATALOGUE_TTL`` seconds; a failed (empty) answer is fetched again.
A server builds its proxy providers on every request, and each build fetches the catalogue.
One config builds every provider, so a config reload clears the cache.
"""
@functools.wraps(fetch)