fix: centralize retry logic in proxy transport layer

Add exponential-backoff retry (3 attempts) for transient SSL,
connection, timeout, and OS errors to all three proxy functions:
urlopen, create_connection, open_connection. Remove per-plugin
retry from alert.py since transport layer now handles it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 18:55:21 +01:00
parent 6d86e8d7f8
commit 118cf0de21
2 changed files with 53 additions and 27 deletions

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re import re
import ssl
import urllib.request import urllib.request
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -272,22 +271,11 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
for tag, backend in _BACKENDS.items(): for tag, backend in _BACKENDS.items():
items = None try:
for attempt in range(3): items = await loop.run_in_executor(None, backend, keyword)
try: except Exception as exc:
items = await loop.run_in_executor(None, backend, keyword) data["last_error"] = f"{tag}: {exc}"
break had_error = True
except (ssl.SSLError, ConnectionError, TimeoutError, OSError) as exc:
if attempt < 2:
await asyncio.sleep(2 ** attempt)
continue
data["last_error"] = f"{tag}: {exc}"
had_error = True
except Exception as exc:
data["last_error"] = f"{tag}: {exc}"
had_error = True
break
if items is None:
continue continue
seen_set = set(data.get("seen", {}).get(tag, [])) seen_set = set(data.get("seen", {}).get(tag, []))

View File

@@ -1,8 +1,10 @@
"""Proxy-aware HTTP/TCP helpers -- routes outbound traffic through SOCKS5.""" """Proxy-aware HTTP/TCP helpers -- routes outbound traffic through SOCKS5."""
import asyncio import asyncio
import logging
import socket import socket
import ssl import ssl
import time
import urllib.request import urllib.request
import socks import socks
@@ -11,6 +13,10 @@ from sockshandler import SocksiPyConnectionS, SocksiPyHandler
_PROXY_ADDR = "127.0.0.1" _PROXY_ADDR = "127.0.0.1"
_PROXY_PORT = 1080 _PROXY_PORT = 1080
_MAX_RETRIES = 3
_RETRY_ERRORS = (ssl.SSLError, ConnectionError, TimeoutError, OSError)
_log = logging.getLogger(__name__)
class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler): class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler):
@@ -35,13 +41,25 @@ class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler):
def urlopen(req, *, timeout=None, context=None): def urlopen(req, *, timeout=None, context=None):
"""Proxy-aware drop-in for urllib.request.urlopen.""" """Proxy-aware drop-in for urllib.request.urlopen.
Retries on transient SSL/connection errors with exponential backoff.
"""
handler = _ProxyHandler(context=context) handler = _ProxyHandler(context=context)
opener = urllib.request.build_opener(handler) opener = urllib.request.build_opener(handler)
kwargs = {} kwargs = {}
if timeout is not None: if timeout is not None:
kwargs["timeout"] = timeout kwargs["timeout"] = timeout
return opener.open(req, **kwargs) for attempt in range(_MAX_RETRIES):
try:
return opener.open(req, **kwargs)
except _RETRY_ERRORS as exc:
if attempt + 1 >= _MAX_RETRIES:
raise
delay = 2 ** attempt
_log.debug("urlopen retry %d/%d after %s: %s",
attempt + 1, _MAX_RETRIES, type(exc).__name__, exc)
time.sleep(delay)
def build_opener(*handlers, context=None): def build_opener(*handlers, context=None):
@@ -54,20 +72,31 @@ def create_connection(address, *, timeout=None):
"""SOCKS5-proxied drop-in for socket.create_connection. """SOCKS5-proxied drop-in for socket.create_connection.
Returns a connected socksocket (usable as context manager). Returns a connected socksocket (usable as context manager).
Retries on transient connection errors with exponential backoff.
""" """
host, port = address host, port = address
sock = socks.socksocket() for attempt in range(_MAX_RETRIES):
sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True) try:
if timeout is not None: sock = socks.socksocket()
sock.settimeout(timeout) sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True)
sock.connect((host, port)) if timeout is not None:
return sock sock.settimeout(timeout)
sock.connect((host, port))
return sock
except _RETRY_ERRORS as exc:
if attempt + 1 >= _MAX_RETRIES:
raise
delay = 2 ** attempt
_log.debug("create_connection retry %d/%d after %s: %s",
attempt + 1, _MAX_RETRIES, type(exc).__name__, exc)
time.sleep(delay)
async def open_connection(host, port, *, timeout=None): async def open_connection(host, port, *, timeout=None):
"""SOCKS5-proxied drop-in for asyncio.open_connection. """SOCKS5-proxied drop-in for asyncio.open_connection.
SOCKS5 handshake runs in a thread executor; returns (reader, writer). SOCKS5 handshake runs in a thread executor; returns (reader, writer).
Retries on transient connection errors with exponential backoff.
""" """
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -80,5 +109,14 @@ async def open_connection(host, port, *, timeout=None):
sock.setblocking(False) sock.setblocking(False)
return sock return sock
sock = await loop.run_in_executor(None, _connect) for attempt in range(_MAX_RETRIES):
return await asyncio.open_connection(sock=sock) try:
sock = await loop.run_in_executor(None, _connect)
return await asyncio.open_connection(sock=sock)
except _RETRY_ERRORS as exc:
if attempt + 1 >= _MAX_RETRIES:
raise
delay = 2 ** attempt
_log.debug("open_connection retry %d/%d after %s: %s",
attempt + 1, _MAX_RETRIES, type(exc).__name__, exc)
await asyncio.sleep(delay)