Enhance PIA rate limit handling in gluetun WireGuard rotator. Introduce persistent cooldown management and improve retry logic for token requests. Update README to reflect new RATE_LIMIT_PATH variable and clarify cooldown behavior.
Build and Push Docker Images / build-and-push (push) Successful in 20s

This commit is contained in:
2026-08-14 22:25:12 +02:00
parent 9700944599
commit f1e4a91bd8
3 changed files with 121 additions and 22 deletions
+103 -15
View File
@@ -226,6 +226,79 @@ def ensure_pia_ca() -> Path:
return path
def pia_ssl_context() -> ssl.SSLContext:
"""SSL context trusting PIA's CA.
OpenSSL 3.2+ / Python 3.13+ enable X509_STRICT by default, which rejects
PIA's ca.rsa.4096.crt because basicConstraints is not marked critical.
"""
context = ssl.create_default_context(cafile=str(ensure_pia_ca()))
if hasattr(ssl, "VERIFY_X509_STRICT"):
context.verify_flags &= ~ssl.VERIFY_X509_STRICT
return context
def rate_limit_path() -> Path:
return Path(os.environ.get("RATE_LIMIT_PATH", "/config/cache/pia-rate-limit.json"))
def rate_limit_wait_seconds() -> int:
return int(os.environ.get("RATE_LIMIT_WAIT_SECONDS", "3600"))
def rate_limit_remaining_seconds() -> int:
path = rate_limit_path()
if not path.is_file():
return 0
try:
data = json.loads(path.read_text(encoding="utf-8"))
until = float(data.get("until", 0))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return 0
return max(0, int(until - time.time()))
def mark_rate_limited(wait_s: int | None = None, reason: str = "") -> int:
wait = rate_limit_wait_seconds() if wait_s is None else wait_s
until = time.time() + wait
path = rate_limit_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"until": until,
"wait_seconds": wait,
"reason": reason,
"marked_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log(f"Marked PIA rate-limit cooldown for {wait}s ({reason or 'rate limited'})")
return wait
def clear_rate_limit() -> None:
path = rate_limit_path()
try:
path.unlink(missing_ok=True)
except OSError:
pass
def raise_if_rate_limit_cooldown() -> None:
remaining = rate_limit_remaining_seconds()
if remaining > 0:
log(f"PIA rate-limit cooldown active; {remaining}s remaining (no API calls)")
raise SystemExit(EXIT_RATE_LIMITED)
class RateLimited(RuntimeError):
"""One auth endpoint reported rate limiting; other methods may still work."""
def _rate_limited(body: str, status_code: int) -> bool:
return status_code == 429 or "too_many_attempts" in body
@@ -279,15 +352,15 @@ def _token_via_central(username: str, password: str) -> str:
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
if _rate_limited(body, exc.code):
raise SystemExit(EXIT_RATE_LIMITED) from exc
raise RateLimited(f"central token API: {body[:200]}") from exc
if _cloudflare_blocked(exc.code, body):
raise RuntimeError(f"central token API blocked by Cloudflare (1010)") from exc
raise RuntimeError("central token API blocked by Cloudflare (1010)") from exc
raise RuntimeError(f"central token API status {exc.code}: {body[:200]}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"central token API network error: {exc}") from exc
if _rate_limited(body, status):
raise SystemExit(EXIT_RATE_LIMITED)
raise RateLimited(f"central token API: {body[:200]}")
return _parse_token_body(body, "central token API")
@@ -307,7 +380,7 @@ def _token_via_gtoken(username: str, password: str) -> str:
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
if _rate_limited(body, exc.code):
raise SystemExit(EXIT_RATE_LIMITED) from exc
raise RateLimited(f"gtoken API: {body[:200]}") from exc
if _cloudflare_blocked(exc.code, body):
raise RuntimeError("gtoken API blocked by Cloudflare (1010)") from exc
raise RuntimeError(f"gtoken API status {exc.code}: {body[:200]}") from exc
@@ -315,13 +388,12 @@ def _token_via_gtoken(username: str, password: str) -> str:
raise RuntimeError(f"gtoken API network error: {exc}") from exc
if _rate_limited(body, status):
raise SystemExit(EXIT_RATE_LIMITED)
raise RateLimited(f"gtoken API: {body[:200]}")
return _parse_token_body(body, "gtoken API")
def _token_via_meta(username: str, password: str, meta: WgServer) -> str:
ca_path = ensure_pia_ca()
context = ssl.create_default_context(cafile=str(ca_path))
context = pia_ssl_context()
conn = _HTTPSConnectionToIP(meta.ip, 443, meta.cn, context, timeout=10)
try:
conn.request(
@@ -339,13 +411,15 @@ def _token_via_meta(username: str, password: str, meta: WgServer) -> str:
conn.close()
if _rate_limited(body, status):
raise SystemExit(EXIT_RATE_LIMITED)
raise RateLimited(f"meta {meta.cn}: {body[:200]}")
if status != 200:
raise RuntimeError(f"meta {meta.cn}/{meta.ip} status {status}: {body[:200]}")
return _parse_token_body(body, f"meta {meta.cn}")
def get_token(username: str, password: str, preferred_region: str | None = None) -> str:
raise_if_rate_limit_cooldown()
cache_path = token_cache_path()
ttl = parse_duration_seconds(os.environ.get("TOKEN_CACHE_TTL", "20h"), 20 * 3600)
force = env_bool("FORCE_TOKEN_REFRESH", False)
@@ -362,6 +436,7 @@ def get_token(username: str, password: str, preferred_region: str | None = None)
pass
errors: list[str] = []
saw_rate_limit = False
for label, getter in (
("central v2 token API", lambda: _token_via_central(username, password)),
@@ -370,10 +445,13 @@ def get_token(username: str, password: str, preferred_region: str | None = None)
try:
log(f"Requesting PIA token via {label}")
token = getter()
clear_rate_limit()
log(f"Fetched and cached new PIA token via {label}")
return _store_token(token)
except SystemExit:
raise
except RateLimited as exc:
saw_rate_limit = True
errors.append(f"{label}: {exc}")
log(f"Token via {label} rate-limited: {exc}")
except Exception as exc: # noqa: BLE001 - try next auth method
errors.append(f"{label}: {exc}")
log(f"Token via {label} failed: {exc}")
@@ -405,14 +483,20 @@ def get_token(username: str, password: str, preferred_region: str | None = None)
try:
log(f"Requesting PIA token via {label}")
token = _token_via_meta(username, password, meta)
clear_rate_limit()
log(f"Fetched and cached new PIA token via {label}")
return _store_token(token)
except SystemExit:
raise
except RateLimited as exc:
saw_rate_limit = True
errors.append(f"{label}: {exc}")
log(f"Token via {label} rate-limited: {exc}")
except Exception as exc: # noqa: BLE001
errors.append(f"{label}: {exc}")
log(f"Token via {label} failed: {exc}")
if saw_rate_limit:
mark_rate_limited(reason="token endpoints rate-limited")
raise SystemExit(EXIT_RATE_LIMITED)
raise SystemExit("PIA token request failed:\n- " + "\n- ".join(errors))
@@ -437,21 +521,25 @@ class _HTTPSConnectionToIP(HTTPSConnection):
def add_key(server: WgServer, token: str, public_key: str) -> dict[str, Any]:
ca_path = ensure_pia_ca()
context = ssl.create_default_context(cafile=str(ca_path))
raise_if_rate_limit_cooldown()
context = pia_ssl_context()
query = urllib.parse.urlencode({"pt": token, "pubkey": public_key})
path = f"/addKey?{query}"
conn = _HTTPSConnectionToIP(server.ip, 1337, server.cn, context)
try:
conn.request("GET", path, headers={"Content-Type": "application/json"})
conn.request("GET", path, headers={**HTTP_HEADERS, "Content-Type": "application/json"})
resp = conn.getresponse()
body = resp.read().decode("utf-8", errors="replace")
status = resp.status
except ssl.SSLError as exc:
raise SystemExit(f"addKey TLS failed for {server.cn}/{server.ip}: {exc}") from exc
finally:
conn.close()
if _rate_limited(body, status):
mark_rate_limited(reason=f"addKey {server.cn}")
raise SystemExit(EXIT_RATE_LIMITED)
if status != 200:
raise SystemExit(f"addKey failed for {server.cn}/{server.ip}: status {status}: {body}")