From 118cf0de211c7e516fc3faee96f3222c95a27a0d Mon Sep 17 00:00:00 2001 From: user Date: Sun, 15 Feb 2026 18:55:21 +0100 Subject: [PATCH] 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 --- plugins/alert.py | 22 +++++------------- src/derp/http.py | 58 +++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/plugins/alert.py b/plugins/alert.py index 13a612f..452f8e6 100644 --- a/plugins/alert.py +++ b/plugins/alert.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import json import re -import ssl import urllib.request 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() for tag, backend in _BACKENDS.items(): - items = None - for attempt in range(3): - try: - items = await loop.run_in_executor(None, backend, keyword) - break - 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: + try: + items = await loop.run_in_executor(None, backend, keyword) + except Exception as exc: + data["last_error"] = f"{tag}: {exc}" + had_error = True continue seen_set = set(data.get("seen", {}).get(tag, [])) diff --git a/src/derp/http.py b/src/derp/http.py index 315e53f..07a4c7d 100644 --- a/src/derp/http.py +++ b/src/derp/http.py @@ -1,8 +1,10 @@ """Proxy-aware HTTP/TCP helpers -- routes outbound traffic through SOCKS5.""" import asyncio +import logging import socket import ssl +import time import urllib.request import socks @@ -11,6 +13,10 @@ from sockshandler import SocksiPyConnectionS, SocksiPyHandler _PROXY_ADDR = "127.0.0.1" _PROXY_PORT = 1080 +_MAX_RETRIES = 3 +_RETRY_ERRORS = (ssl.SSLError, ConnectionError, TimeoutError, OSError) + +_log = logging.getLogger(__name__) class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler): @@ -35,13 +41,25 @@ class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler): 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) opener = urllib.request.build_opener(handler) kwargs = {} if timeout is not None: 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): @@ -54,20 +72,31 @@ def create_connection(address, *, timeout=None): """SOCKS5-proxied drop-in for socket.create_connection. Returns a connected socksocket (usable as context manager). + Retries on transient connection errors with exponential backoff. """ host, port = address - sock = socks.socksocket() - sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True) - if timeout is not None: - sock.settimeout(timeout) - sock.connect((host, port)) - return sock + for attempt in range(_MAX_RETRIES): + try: + sock = socks.socksocket() + sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True) + if timeout is not None: + 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): """SOCKS5-proxied drop-in for asyncio.open_connection. SOCKS5 handshake runs in a thread executor; returns (reader, writer). + Retries on transient connection errors with exponential backoff. """ loop = asyncio.get_running_loop() @@ -80,5 +109,14 @@ async def open_connection(host, port, *, timeout=None): sock.setblocking(False) return sock - sock = await loop.run_in_executor(None, _connect) - return await asyncio.open_connection(sock=sock) + for attempt in range(_MAX_RETRIES): + 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)