feat: async HTTP client and parallel source fetching

Replace blocking urllib with a minimal async HTTP/1.1 client (http.py)
using asyncio streams. Pool source fetches now run in parallel via
asyncio.gather. Dead proxy reporting uses async POST. Handles
Content-Length, chunked transfer-encoding, and connection-close bodies.
No new dependencies.
This commit is contained in:
user
2026-02-15 17:55:56 +01:00
parent 714e8efb3d
commit 903cb38b9f
5 changed files with 338 additions and 58 deletions
+139
View File
@@ -0,0 +1,139 @@
"""Minimal async HTTP/1.1 client using asyncio streams."""
from __future__ import annotations
import asyncio
import json
import logging
import ssl
from urllib.parse import urlparse
logger = logging.getLogger("s5p")
async def http_get_json(url: str, timeout: float = 10.0) -> dict:
"""GET a URL and return parsed JSON response."""
parsed = urlparse(url)
host = parsed.hostname or ""
use_tls = parsed.scheme == "https"
default_port = 443 if use_tls else 80
port = parsed.port or default_port
path = parsed.path or "/"
if parsed.query:
path = f"{path}?{parsed.query}"
ssl_ctx = ssl.create_default_context() if use_tls else None
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port, ssl=ssl_ctx),
timeout=timeout,
)
try:
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Accept: application/json\r\n"
f"Connection: close\r\n"
f"\r\n"
)
writer.write(request.encode())
await writer.drain()
body = await _read_response(reader, timeout)
return json.loads(body)
finally:
writer.close()
try:
await writer.wait_closed()
except OSError:
pass
async def http_post_json(url: str, payload: dict, timeout: float = 10.0) -> None:
"""POST JSON body to a URL (fire-and-forget)."""
parsed = urlparse(url)
host = parsed.hostname or ""
use_tls = parsed.scheme == "https"
default_port = 443 if use_tls else 80
port = parsed.port or default_port
path = parsed.path or "/"
if parsed.query:
path = f"{path}?{parsed.query}"
ssl_ctx = ssl.create_default_context() if use_tls else None
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port, ssl=ssl_ctx),
timeout=timeout,
)
try:
body = json.dumps(payload).encode()
request = (
f"POST {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n"
f"\r\n"
)
writer.write(request.encode() + body)
await writer.drain()
# Read response status to detect errors, but don't require body.
status_line = await asyncio.wait_for(reader.readline(), timeout=timeout)
parts = status_line.decode("utf-8", errors="replace").split(None, 2)
if len(parts) >= 2 and not parts[1].startswith("2"):
logger.debug("http_post_json: %s returned %s", url, parts[1])
finally:
writer.close()
try:
await writer.wait_closed()
except OSError:
pass
async def _read_response(reader: asyncio.StreamReader, timeout: float) -> bytes:
"""Read HTTP response, return body bytes. Handles Content-Length and chunked."""
# Status line
status_line = await asyncio.wait_for(reader.readline(), timeout=timeout)
parts = status_line.decode("utf-8", errors="replace").split(None, 2)
if len(parts) < 2 or not parts[1].startswith("2"):
status = parts[1] if len(parts) >= 2 else "?"
raise OSError(f"HTTP {status}: {status_line.decode(errors='replace').strip()}")
# Headers
content_length = -1
chunked = False
while True:
line = await asyncio.wait_for(reader.readline(), timeout=timeout)
if line in (b"\r\n", b"\n", b""):
break
header = line.decode("utf-8", errors="replace").lower()
if header.startswith("content-length:"):
content_length = int(header.split(":", 1)[1].strip())
elif header.startswith("transfer-encoding:") and "chunked" in header:
chunked = True
# Body
if chunked:
return await _read_chunked(reader, timeout)
elif content_length >= 0:
return await asyncio.wait_for(reader.readexactly(content_length), timeout=timeout)
else:
# Connection: close -- read until EOF
return await asyncio.wait_for(reader.read(), timeout=timeout)
async def _read_chunked(reader: asyncio.StreamReader, timeout: float) -> bytes:
"""Read chunked transfer-encoding body."""
parts: list[bytes] = []
while True:
size_line = await asyncio.wait_for(reader.readline(), timeout=timeout)
size = int(size_line.strip(), 16)
if size == 0:
await asyncio.wait_for(reader.readline(), timeout=timeout) # trailing CRLF
break
chunk = await asyncio.wait_for(reader.readexactly(size), timeout=timeout)
parts.append(chunk)
await asyncio.wait_for(reader.readline(), timeout=timeout) # trailing CRLF
return b"".join(parts)
+28 -34
View File
@@ -8,12 +8,12 @@ import logging
import os import os
import random import random
import time import time
import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
from .config import ChainHop, PoolSourceConfig, ProxyPoolConfig, parse_proxy_url from .config import ChainHop, PoolSourceConfig, ProxyPoolConfig, parse_proxy_url
from .http import http_get_json, http_post_json
from .proto import ProtoError, build_chain from .proto import ProtoError, build_chain
logger = logging.getLogger("s5p") logger = logging.getLogger("s5p")
@@ -152,26 +152,35 @@ class ProxyPool:
# -- source fetching ----------------------------------------------------- # -- source fetching -----------------------------------------------------
async def _fetch_all_sources(self) -> None: async def _fetch_all_sources(self) -> None:
"""Fetch proxies from all configured sources and merge.""" """Fetch proxies from all configured sources in parallel and merge."""
loop = asyncio.get_running_loop()
proxies: list[ChainHop] = [] async def _fetch_one(src: PoolSourceConfig) -> list[ChainHop]:
for src in self._cfg.sources:
try:
if src.url: if src.url:
batch = await loop.run_in_executor(None, self._fetch_api_sync, src) return await self._fetch_api(src)
logger.info("pool: fetched %d proxies from %s", len(batch), src.url)
proxies.extend(batch)
elif src.file: elif src.file:
batch = await loop.run_in_executor(None, self._fetch_file_sync, src) loop = asyncio.get_running_loop()
logger.info("pool: loaded %d proxies from %s", len(batch), src.file) return await loop.run_in_executor(None, self._fetch_file_sync, src)
proxies.extend(batch) return []
except Exception as e:
results = await asyncio.gather(
*[_fetch_one(s) for s in self._cfg.sources],
return_exceptions=True,
)
proxies: list[ChainHop] = []
for i, result in enumerate(results):
src = self._cfg.sources[i]
label = src.url or src.file or "?" label = src.url or src.file or "?"
logger.warning("pool: source %s failed: %s", label, e) if isinstance(result, Exception):
logger.warning("pool: source %s failed: %s", label, result)
else:
kind = "fetched" if src.url else "loaded"
logger.info("pool: %s %d proxies from %s", kind, len(result), label)
proxies.extend(result)
self._merge(proxies) self._merge(proxies)
def _fetch_api_sync(self, src: PoolSourceConfig) -> list[ChainHop]: async def _fetch_api(self, src: PoolSourceConfig) -> list[ChainHop]:
"""Fetch proxies from an HTTP API (runs in executor).""" """Fetch proxies from an HTTP API (async)."""
params: dict[str, str] = {} params: dict[str, str] = {}
if src.limit: if src.limit:
params["limit"] = str(src.limit) params["limit"] = str(src.limit)
@@ -185,9 +194,7 @@ class ProxyPool:
sep = "&" if "?" in url else "?" sep = "&" if "?" in url else "?"
url = f"{url}{sep}{urlencode(params)}" url = f"{url}{sep}{urlencode(params)}"
req = urllib.request.Request(url, headers={"Accept": "application/json"}) data = await http_get_json(url)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
proxies: list[ChainHop] = [] proxies: list[ChainHop] = []
for entry in data.get("proxies", []): for entry in data.get("proxies", []):
@@ -405,7 +412,7 @@ class ProxyPool:
asyncio.ensure_future(self._report_dead(dead)) asyncio.ensure_future(self._report_dead(dead))
async def _report_dead(self, keys: list[str]) -> None: async def _report_dead(self, keys: list[str]) -> None:
"""POST dead proxy list to report_url (fire-and-forget).""" """POST dead proxy list to report_url (fire-and-forget, async)."""
dead = [] dead = []
for key in keys: for key in keys:
# key format: proto://host:port # key format: proto://host:port
@@ -416,25 +423,12 @@ class ProxyPool:
if not dead: if not dead:
return return
loop = asyncio.get_running_loop()
try: try:
await loop.run_in_executor(None, self._report_sync, dead) await http_post_json(self._cfg.report_url, {"dead": dead})
logger.info("pool: reported %d dead proxies to %s", len(dead), self._cfg.report_url) logger.info("pool: reported %d dead proxies to %s", len(dead), self._cfg.report_url)
except Exception as e: except Exception as e:
logger.debug("pool: report failed: %s", e) logger.debug("pool: report failed: %s", e)
def _report_sync(self, dead: list[dict[str, str]]) -> None:
"""Synchronous POST to report_url (runs in executor)."""
payload = json.dumps({"dead": dead}).encode()
req = urllib.request.Request(
self._cfg.report_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10):
pass
def _rebuild_alive(self) -> None: def _rebuild_alive(self) -> None:
"""Rebuild the alive keys list from current state.""" """Rebuild the alive keys list from current state."""
self._alive_keys = [k for k, e in self._proxies.items() if e.alive] self._alive_keys = [k for k, e in self._proxies.items() if e.alive]
+6 -10
View File
@@ -3,14 +3,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import random import random
import time import time
import urllib.request
from urllib.parse import urlencode from urllib.parse import urlencode
from .config import ChainHop, ProxySourceConfig from .config import ChainHop, ProxySourceConfig
from .http import http_get_json
logger = logging.getLogger("s5p") logger = logging.getLogger("s5p")
@@ -49,11 +48,10 @@ class ProxySource:
return random.choice(self._cache) return random.choice(self._cache)
async def _refresh(self) -> None: async def _refresh(self) -> None:
"""Fetch proxy list from the API.""" """Fetch proxy list from the API (async)."""
async with self._lock: async with self._lock:
loop = asyncio.get_running_loop()
try: try:
proxies = await loop.run_in_executor(None, self._fetch_sync) proxies = await self._fetch()
self._cache = proxies self._cache = proxies
self._last_fetch = time.monotonic() self._last_fetch = time.monotonic()
logger.info("proxy source: loaded %d proxies", len(proxies)) logger.info("proxy source: loaded %d proxies", len(proxies))
@@ -62,8 +60,8 @@ class ProxySource:
if self._cache: if self._cache:
logger.info("proxy source: using stale cache (%d proxies)", len(self._cache)) logger.info("proxy source: using stale cache (%d proxies)", len(self._cache))
def _fetch_sync(self) -> list[ChainHop]: async def _fetch(self) -> list[ChainHop]:
"""Synchronous HTTP fetch (runs in executor).""" """Async HTTP fetch."""
params: dict[str, str] = {} params: dict[str, str] = {}
if self._cfg.limit: if self._cfg.limit:
params["limit"] = str(self._cfg.limit) params["limit"] = str(self._cfg.limit)
@@ -77,9 +75,7 @@ class ProxySource:
sep = "&" if "?" in url else "?" sep = "&" if "?" in url else "?"
url = f"{url}{sep}{urlencode(params)}" url = f"{url}{sep}{urlencode(params)}"
req = urllib.request.Request(url, headers={"Accept": "application/json"}) data = await http_get_json(url)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
proxies: list[ChainHop] = [] proxies: list[ChainHop] = []
for entry in data.get("proxies", []): for entry in data.get("proxies", []):
+153
View File
@@ -0,0 +1,153 @@
"""Tests for the async HTTP client."""
import asyncio
import json
import pytest
from s5p.http import http_get_json, http_post_json
async def _run_mock_server(handler, host="127.0.0.1", port=0):
"""Start a mock TCP server, return (server, port)."""
server = await asyncio.start_server(handler, host, port)
port = server.sockets[0].getsockname()[1]
return server, port
class TestHttpGetJson:
"""Test async HTTP GET."""
def test_basic_get(self):
payload = {"proxies": [{"proto": "socks5", "proxy": "1.2.3.4:1080"}]}
body = json.dumps(payload).encode()
async def handler(reader, writer):
await reader.readline() # request line
while (await reader.readline()) not in (b"\r\n", b"\n", b""):
pass
writer.write(
f"HTTP/1.1 200 OK\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n"
f"\r\n".encode()
+ body
)
await writer.drain()
writer.close()
async def run():
server, port = await _run_mock_server(handler)
async with server:
result = await http_get_json(f"http://127.0.0.1:{port}/test")
assert result == payload
asyncio.run(run())
def test_chunked_response(self):
payload = {"status": "ok"}
body = json.dumps(payload).encode()
async def handler(reader, writer):
await reader.readline()
while (await reader.readline()) not in (b"\r\n", b"\n", b""):
pass
writer.write(
b"HTTP/1.1 200 OK\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
)
# Send body as a single chunk
writer.write(f"{len(body):x}\r\n".encode() + body + b"\r\n")
writer.write(b"0\r\n\r\n")
await writer.drain()
writer.close()
async def run():
server, port = await _run_mock_server(handler)
async with server:
result = await http_get_json(f"http://127.0.0.1:{port}/chunked")
assert result == payload
asyncio.run(run())
def test_error_status(self):
async def handler(reader, writer):
await reader.readline()
while (await reader.readline()) not in (b"\r\n", b"\n", b""):
pass
writer.write(b"HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n")
await writer.drain()
writer.close()
async def run():
server, port = await _run_mock_server(handler)
async with server:
with pytest.raises(OSError, match="HTTP 500"):
await http_get_json(f"http://127.0.0.1:{port}/fail")
asyncio.run(run())
def test_connection_close_body(self):
"""Server sends no Content-Length, just closes connection."""
payload = {"key": "value"}
body = json.dumps(payload).encode()
async def handler(reader, writer):
await reader.readline()
while (await reader.readline()) not in (b"\r\n", b"\n", b""):
pass
writer.write(
b"HTTP/1.1 200 OK\r\n"
b"Connection: close\r\n"
b"\r\n"
+ body
)
await writer.drain()
writer.close()
await writer.wait_closed()
async def run():
server, port = await _run_mock_server(handler)
async with server:
result = await http_get_json(f"http://127.0.0.1:{port}/nosize")
assert result == payload
asyncio.run(run())
class TestHttpPostJson:
"""Test async HTTP POST."""
def test_basic_post(self):
received = {}
async def handler(reader, writer):
request_line = await reader.readline()
received["method"] = request_line.decode().split()[0]
content_length = 0
while True:
line = await reader.readline()
if line in (b"\r\n", b"\n", b""):
break
header = line.decode().lower()
if header.startswith("content-length:"):
content_length = int(header.split(":", 1)[1].strip())
body = await reader.readexactly(content_length)
received["body"] = json.loads(body)
writer.write(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n")
await writer.drain()
writer.close()
async def run():
server, port = await _run_mock_server(handler)
async with server:
await http_post_json(
f"http://127.0.0.1:{port}/report",
{"dead": [{"proto": "socks5", "proxy": "1.2.3.4:1080"}]},
)
assert received["method"] == "POST"
assert received["body"]["dead"][0]["proto"] == "socks5"
asyncio.run(run())
+10 -12
View File
@@ -1,6 +1,5 @@
"""Tests for the managed proxy pool.""" """Tests for the managed proxy pool."""
import json
import time import time
import pytest import pytest
@@ -304,21 +303,20 @@ class TestProxyPoolReport:
asyncio.run(pool._run_health_tests()) asyncio.run(pool._run_health_tests())
mock_report.assert_not_called() mock_report.assert_not_called()
def test_report_sync_payload(self): def test_report_async_payload(self):
from unittest.mock import MagicMock, patch import asyncio
from unittest.mock import AsyncMock, patch
cfg = ProxyPoolConfig(sources=[], report_url="http://api:8081/report") cfg = ProxyPoolConfig(sources=[], report_url="http://api:8081/report")
pool = ProxyPool(cfg, [], timeout=10.0) pool = ProxyPool(cfg, [], timeout=10.0)
dead = [{"proto": "socks5", "proxy": "10.0.0.1:1080"}] with patch("s5p.pool.http_post_json", new_callable=AsyncMock) as mock_post:
with patch("s5p.pool.urllib.request.urlopen", new_callable=MagicMock) as mock_open: asyncio.run(pool._report_dead(["socks5://10.0.0.1:1080"]))
mock_open.return_value.__enter__ = MagicMock() mock_post.assert_called_once()
mock_open.return_value.__exit__ = MagicMock(return_value=False) url = mock_post.call_args[0][0]
pool._report_sync(dead) payload = mock_post.call_args[0][1]
req = mock_open.call_args[0][0] assert url == "http://api:8081/report"
assert req.method == "POST" assert payload == {"dead": [{"proto": "socks5", "proxy": "10.0.0.1:1080"}]}
assert req.full_url == "http://api:8081/report"
assert b'"dead"' in req.data
class TestProxyPoolStaleExpiry: class TestProxyPoolStaleExpiry: