Files
hermes-agent/tests/pm/_range_server.py
ethernet 8e4a804f53 fix(pm): retry transient network failures
A transient HTTP 500 during the range probe aborted bundle staging.
Retry PM-owned requests and body transfers with one bounded policy,
instead of retrying complete installs or stacking caller retry loops.

Keep partial ranges across attempts and interrupt backoff on pause.
Keep the CDN serial fallback active until its source finishes. Do not
retry permanent HTTP errors, certificate failures, bad hashes or disk
errors. Use the same policy for metadata reads and artifact hashing.

Verified: 217 focused tests passed, 1 skipped, and Ruff passed on the
changed Python files. The pinned Windows ARM64 uv archive also survived
an injected HTTP 500 through the PM CLI and its installed binary ran.
The full suite and desktop release build were not run.
2026-09-09 12:54:08 -04:00

116 lines
4.3 KiB
Python

"""Shared Range-honoring loopback server for pm downloader tests.
Both test_downloader.py and test_store_resume.py exercise real downloads
through this one server instead of each re-implementing it. Behaviour is
configured with class attributes on :class:`RangeHandler`; the ``dl_server``
fixture starts one and resets its state.
"""
from __future__ import annotations
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
class RangeHandler(BaseHTTPRequestHandler):
payloads: dict = {}
ranges_seen: list = [] # (path, start, end) from real Range requests
abort_after: int | None = None # refuse bytes beyond this payload offset
slow_per_chunk: float = 0.0 # sleep per served piece (pause tests)
no_range: bool = False # ignore Range, serve 200 full body
chunk: int = 1 << 20 # serve piece size
def log_message(self, *args): # noqa: A002 - silence request logging
pass
def do_GET(self): # noqa: N802 - http.server API
payload = self.payloads.get(self.path)
if payload is None:
self.send_error(404)
return
if self.no_range:
# A server that ignores Range: 200 with the full body, even
# when the client asked for a byte range.
self.send_response(200)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
served = 0
while served < len(payload):
if self.abort_after is not None and served >= self.abort_after:
self.connection.close()
return
if self.slow_per_chunk:
time.sleep(self.slow_per_chunk)
piece = payload[served:served + self.chunk]
self.wfile.write(piece)
self.wfile.flush()
served += len(piece)
return
rng = self.headers.get("Range")
if rng:
spec = rng.removeprefix("bytes=")
if spec == "0-0":
# probe: 206 with total from Content-Range, 1 byte body
total = len(payload)
self.send_response(206)
self.send_header("Content-Range", f"bytes 0-{total - 1}/{total}")
self.send_header("Content-Length", "1")
self.end_headers()
self.wfile.write(payload[:1])
self.wfile.flush()
return
start_s, end_s = spec.split("-", 1)
start, end = int(start_s), int(end_s)
self.ranges_seen.append((self.path, start, end))
body = payload[start:end + 1]
self.send_response(206)
self.send_header("Content-Range",
f"bytes {start}-{end}/{len(payload)}")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
served = 0
while served < len(body):
if self.abort_after is not None and start + served >= self.abort_after:
self.connection.close()
return
if self.slow_per_chunk:
time.sleep(self.slow_per_chunk)
end = served + self.chunk
if self.abort_after is not None:
end = min(end, self.abort_after - start)
piece = body[served:end]
self.wfile.write(piece)
self.wfile.flush()
served += len(piece)
return
self.send_response(200)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
self.wfile.flush()
@pytest.fixture
def dl_server():
RangeHandler.payloads = {}
RangeHandler.ranges_seen = []
RangeHandler.abort_after = None
RangeHandler.slow_per_chunk = 0.0
RangeHandler.no_range = False
RangeHandler.chunk = 1 << 20
server = HTTPServer(("127.0.0.1", 0), RangeHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def url(server, path: str) -> str:
return f"http://127.0.0.1:{server.server_port}{path}"