import asyncio
import http.server
import socket
import socketserver
import threading
import time
import pytest
import eggfetch
from eggfetch.compat.httpx import Client, AsyncClient, Timeout, MockTransport, Response
from eggfetch.compat.httpx._exceptions import (
ConnectError,
ConnectTimeout,
PoolTimeout,
ReadTimeout,
TimeoutException,
WriteTimeout,
)
import sys
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
from native_fixtures import (
HeadersStallHandler,
local_http_server,
local_proxy_server,
local_tls_server,
local_tls_handshake_stall_server,
)
class _StallHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
content_length = int(self.headers.get("Content-Length", 0))
if content_length:
self.rfile.read(content_length)
time.sleep(300)
def log_message(self, format, *args):
pass
class TestProxyTimeoutClassification:
def test_mock_proxy_connect_timeout(self):
def handler(request):
raise eggfetch.ConnectTimeout("Connect timed out")
with Client(transport=MockTransport(handler)) as client:
with pytest.raises(ConnectTimeout) as exc_info:
client.get("http://testserver/")
assert isinstance(exc_info.value, TimeoutException)
def test_mock_read_timeout_on_slow_server(self):
def handler(request):
raise eggfetch.ReadTimeout("Read timed out")
with Client(transport=MockTransport(handler)) as client:
with pytest.raises(ReadTimeout) as exc_info:
client.get("http://testserver/")
assert isinstance(exc_info.value, TimeoutException)
def test_mock_write_timeout(self):
def handler(request):
raise eggfetch.WriteTimeout("Write timed out")
with Client(transport=MockTransport(handler)) as client:
with pytest.raises(WriteTimeout) as exc_info:
client.post("http://testserver/", content=b"data")
assert isinstance(exc_info.value, TimeoutException)
def test_mock_pool_timeout(self):
def handler(request):
raise eggfetch.PoolTimeout("Pool timed out")
with Client(transport=MockTransport(handler)) as client:
with pytest.raises(PoolTimeout) as exc_info:
client.get("http://testserver/")
assert isinstance(exc_info.value, TimeoutException)
def _test_stall_handler_read_timeout(self):
from native_fixtures import _ThreadedHTTPServer
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(5)
port = srv.getsockname()[1]
httpd = _ThreadedHTTPServer(
("127.0.0.1", port), _StallHandler, bind_and_activate=False
)
httpd.socket = srv
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
try:
with Client(timeout=Timeout(0.5)) as c:
start = time.monotonic()
with pytest.raises(ReadTimeout) as exc_info:
c.get(f"http://127.0.0.1:{port}/")
elapsed = time.monotonic() - start
assert isinstance(exc_info.value, TimeoutException)
assert not isinstance(exc_info.value, ConnectTimeout)
assert elapsed < 5.0, f"Stall timeout took too long: {elapsed:.2f}s"
finally:
httpd.shutdown()
srv.close()
class TestAsyncProxyTimeoutClassification:
@pytest.mark.asyncio
async def test_async_mock_connect_timeout(self):
async def handler(request):
raise eggfetch.ConnectTimeout("Connect timed out")
async with AsyncClient(async_transport=MockTransport(handler)) as client:
with pytest.raises(ConnectTimeout) as exc_info:
await client.get("http://testserver/")
assert isinstance(exc_info.value, TimeoutException)
@pytest.mark.asyncio
async def test_async_mock_read_timeout(self):
async def handler(request):
raise eggfetch.ReadTimeout("Read timed out")
async with AsyncClient(async_transport=MockTransport(handler)) as client:
with pytest.raises(ReadTimeout) as exc_info:
await client.get("http://testserver/")
assert isinstance(exc_info.value, TimeoutException)
class TestTimeoutPassthrough:
def test_scalar_timeout_sets_all_phases(self):
captured = {}
def handler(request):
captured["timeout"] = True
return Response(200)
with Client(transport=MockTransport(handler), timeout=5.0) as client:
client.get("http://testserver/")
assert captured.get("timeout")
def test_none_timeout_disables_all_phases(self):
captured = {}
def handler(request):
captured["timeout"] = True
return Response(200)
with Client(transport=MockTransport(handler), timeout=None) as client:
client.get("http://testserver/")
assert captured.get("timeout")
def test_per_request_timeout_overrides(self):
captured = {}
def handler(request):
captured["timeout"] = True
return Response(200)
with Client(transport=MockTransport(handler), timeout=10.0) as client:
client.get("http://testserver/", timeout=2.0)
assert captured.get("timeout")
def test_per_request_none_disables(self):
captured = {}
def handler(request):
captured["timeout"] = True
return Response(200)
with Client(transport=MockTransport(handler), timeout=10.0) as client:
client.get("http://testserver/", timeout=None)
assert captured.get("timeout")
def test_timeout_object_passthrough(self):
captured = {}
def handler(request):
captured["timeout"] = True
return Response(200)
timeout = Timeout(connect=1.0, read=2.0, write=3.0, pool=4.0)
with Client(transport=MockTransport(handler), timeout=timeout) as client:
client.get("http://testserver/")
assert captured.get("timeout")
class TestRealSocketTimeoutClassification:
def test_real_stall_server_timeout(self):
with local_tls_handshake_stall_server() as (host, port):
with Client(timeout=Timeout(0.5)) as c:
start = time.monotonic()
with pytest.raises((ConnectTimeout, ConnectError)) as exc_info:
c.get(f"https://{host}:{port}/")
elapsed = time.monotonic() - start
assert not isinstance(exc_info.value, ReadTimeout)
assert elapsed < 5.0, f"Timeout took too long: {elapsed:.2f}s"
def test_real_slow_server_timeout(self):
with local_http_server(HeadersStallHandler) as (host, port):
with Client(timeout=Timeout(0.5)) as c:
start = time.monotonic()
with pytest.raises((ReadTimeout, ConnectError)) as exc_info:
c.get(f"http://{host}:{port}/headers-then-stall")
elapsed = time.monotonic() - start
assert not isinstance(exc_info.value, ConnectTimeout)
assert elapsed < 5.0, f"Timeout took too long: {elapsed:.2f}s"
def test_real_connect_timeout_refused(self):
with Client(timeout=Timeout(0.3)) as c:
start = time.monotonic()
with pytest.raises(ConnectError) as exc_info:
c.get("http://127.0.0.1:1/")
elapsed = time.monotonic() - start
assert isinstance(exc_info.value, ConnectError)
assert elapsed < 5.0, f"Timeout took too long: {elapsed:.2f}s"
def test_real_proxy_server_forward(self):
with local_http_server() as (backend_host, backend_port):
with local_proxy_server(backend=(backend_host, backend_port)) as (proxy_host, proxy_port, _handler):
with Client(timeout=Timeout(5)) as c:
resp = c.get(
f"http://{proxy_host}:{proxy_port}/health",
)
assert resp.status_code == 200
def test_real_tls_server_handshake(self):
with local_tls_server() as (tls_host, tls_port, client_ctx, cert_path):
with Client(timeout=Timeout(5), verify=cert_path) as c:
resp = c.get(f"https://{tls_host}:{tls_port}/health")
assert resp.status_code == 200