fix(downloader): bound requests after a cut and stop retry nesting

Request 4 MiB sub-ranges once a host cuts a body, skip the session's own urllib3 Retry inside download(), fail the ranged path at once on a 4xx, and cap resumes at 50 of 1 MiB each. Adds a deny fault profile.
This commit is contained in:
imSp4rky
2026-08-24 15:10:36 -06:00
parent e5e1ce14c6
commit d4690e5c6b
5 changed files with 316 additions and 21 deletions
@@ -0,0 +1,93 @@
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
import requests as rq
from unshackle.core.constants import DOWNLOAD_CANCELLED
from unshackle.core.downloaders.requests import download
# the package re-exports the `requests` function, shadowing the module of the same name
downloader = sys.modules[download.__module__]
BODY = bytes(range(256)) * 256 # 64 KiB
CUT_AFTER = 10 * 1024
CHUNK = 16 * 1024
class _CutServer(ThreadingHTTPServer):
"""Serves BODY but ends any body longer than CUT_AFTER early, like a per-request-limited CDN."""
allow_reuse_address = True
daemon_threads = True
def __init__(self):
self.ranges: list[str] = []
self.cuts = 0
self.connections = 0
super().__init__(("127.0.0.1", 0), _CutHandler)
self.url = f"http://127.0.0.1:{self.server_address[1]}/seg.mp4"
def handle_error(self, request, client_address): # the client hanging up is expected here
pass
class _CutHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_):
pass
def setup(self):
super().setup()
self.server.connections += 1
def do_GET(self):
header = self.headers.get("Range")
self.server.ranges.append(header)
start, end = 0, len(BODY) - 1
if header:
start_s, _, end_s = header.removeprefix("bytes=").partition("-")
start, end = int(start_s), (int(end_s) if end_s else len(BODY) - 1)
body = BODY[start : end + 1]
self.send_response(206 if header else 200)
if header:
self.send_header("Content-Range", f"bytes {start}-{end}/{len(BODY)}")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if len(body) > CUT_AFTER:
self.server.cuts += 1
self.wfile.write(body[:CUT_AFTER])
self.close_connection = True
return
self.wfile.write(body)
@pytest.fixture
def server(monkeypatch):
DOWNLOAD_CANCELLED.clear()
monkeypatch.setattr(downloader, "RETRY_WAIT", 0)
monkeypatch.setattr(downloader, "REQUEST_CHUNK_SIZE", CHUNK)
monkeypatch.setattr(downloader, "MIN_REQUEST_CHUNK", 4 * 1024)
monkeypatch.setattr(downloader, "MIN_RESUME_PROGRESS", 1024)
server = _CutServer()
threading.Thread(target=server.serve_forever, daemon=True).start()
yield server
server.shutdown()
server.server_close()
DOWNLOAD_CANCELLED.clear()
def test_a_cut_body_bounds_the_following_requests(tmp_path, server):
save_path = tmp_path / "0.mp4"
list(download(url=server.url, save_path=save_path, session=rq.Session()))
assert save_path.read_bytes() == BODY
# the open-ended first request is cut, the chunk-sized one after it is cut too, and the
# halved chunk fits under the host's limit, so every later request completes whole
assert server.cuts == 2
spans = [r.removeprefix("bytes=").split("-") for r in server.ranges if r and r.split("-")[-1]]
assert spans and all(int(end) - int(start) + 1 <= CHUNK for start, end in spans)
# the bounded requests are cheap because they share one pooled connection
assert server.connections < len(server.ranges)
@@ -201,3 +201,27 @@ def test_transient_429_with_retry_after_does_not_abort_track(server, tmp_path, m
assert all(f.read_bytes() == PARENT for f in files)
assert not dl.DOWNLOAD_CANCELLED.is_set()
assert len(server.requests) == 5 # 3 successes + 2 rate-limited attempts
def test_session_retry_does_not_nest_inside_an_attempt(server, tmp_path, monkeypatch):
# a service session mounts urllib3 Retry with a 429 forcelist; download() owns segment
# retries, so the two must not multiply (MAX_ATTEMPTS requests, not MAX_ATTEMPTS x 6)
from requests import Session
from requests.adapters import HTTPAdapter, Retry
dl.DOWNLOAD_CANCELLED.clear()
server.fail_429_first = 10**6
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
session = Session()
adapter = HTTPAdapter(
max_retries=Retry(total=5, backoff_factor=0, status_forcelist=[429, 500, 502, 503, 504]),
)
session.mount("http://", adapter)
session.mount("https://", adapter)
with pytest.raises(Exception):
run(server, tmp_path, [{"url": url(server)}], max_workers=1, session=session)
assert len(server.requests) == dl.MAX_ATTEMPTS
# the caller's session keeps its own retry policy
assert session.get_adapter("http://x/").max_retries.total == 5
+58 -6
View File
@@ -10,12 +10,14 @@ Pins the byte-range fan-out that splits one large file across parts:
- that failure sets only the ranged download's LOCAL abort event, never the
process-global DOWNLOAD_CANCELLED (which would poison sibling tracks);
- ``probe_ranged`` parses the Content-Range total on a 206 and declines a 200
or a content-encoded 206.
or a content-encoded 206;
- the public single-URL path falls back to a sequential download only when the
server does not really support ranges, and re-raises a 4xx instead of paying
MAX_ATTEMPTS a second time from byte 0.
Tests 2/3 exercise ``dispatch_parts`` directly: the public single-URL path in
``requests()`` deliberately swallows a ranged failure and falls back to a plain
sequential download, so the raise the invariant guarantees is only observable at
the dispatch layer.
Tests 2/3 exercise ``dispatch_parts`` directly: the public path swallows a
range-support failure and falls back, so the raise that the invariant guarantees
is only observable at the dispatch layer.
"""
import importlib
@@ -23,13 +25,18 @@ import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from requests import Session
from requests import HTTPError, Session
dl = importlib.import_module("unshackle.core.downloaders.requests")
PAYLOAD = bytes(range(256)) * 128 # 32 KiB, distinctive repeating byte pattern
class _Resp:
def __init__(self, status_code):
self.status_code = status_code
class _RangeHandler(BaseHTTPRequestHandler):
def do_GET(self):
srv = self.server
@@ -48,6 +55,13 @@ class _RangeHandler(BaseHTTPRequestHandler):
self.wfile.write(b"\x00")
return
if mode == "deny_parts" and rng != "bytes=0-0":
# probe passes, every part window is refused for good (expired token, geo block)
self.send_response(403)
self.send_header("Content-Length", "0")
self.end_headers()
return
# always_200 ignores Range entirely; probe_only_206 answers the 0-0 probe
# with a real 206 but hands every wider part window a 200 full body
serve_full = mode == "always_200" or not rng or (mode == "probe_only_206" and rng != "bytes=0-0")
@@ -162,3 +176,41 @@ def test_probe_ranged_parses_content_range(server):
assert dl.probe_ranged(url(server), session) == (0, False)
server.mode = "gzip_probe"
assert dl.probe_ranged(url(server), session) == (0, False)
def test_permanent_part_failure_is_not_retried_sequentially(server, tmp_path, monkeypatch):
server.mode = "deny_parts"
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
monkeypatch.setattr(dl, "RANGE_PARALLEL_MIN_SIZE", 8 * 1024)
monkeypatch.setattr(dl, "RANGE_PARALLEL_PART_SIZE", 8 * 1024)
with pytest.raises(HTTPError):
run(server, tmp_path, [{"url": url(server)}], max_workers=4)
# a sequential fallback would issue a plain unranged GET; only the probe and the
# part windows may appear, each part capped at MAX_ATTEMPTS
assert [r for r in server.requests if r is None] == []
parts = len({r for r in server.requests if r and r != "bytes=0-0"})
assert len(server.requests) <= 1 + parts * dl.MAX_ATTEMPTS
# nothing half-written is left behind for a later run to mistake for a finished file
assert sorted(tmp_path.glob("seg_*")) == []
def test_range_unsupported_falls_back_to_sequential(server, tmp_path, monkeypatch):
server.mode = "probe_only_206"
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
monkeypatch.setattr(dl, "RANGE_PARALLEL_MIN_SIZE", 8 * 1024)
monkeypatch.setattr(dl, "RANGE_PARALLEL_PART_SIZE", 8 * 1024)
files = run(server, tmp_path, [{"url": url(server)}], max_workers=4)
assert len(files) == 1
assert files[0].read_bytes() == PAYLOAD
assert not files[0].with_name(files[0].name + ".!dev").exists()
def test_permanent_status_unwraps_causes():
assert dl.permanent_status(HTTPError(response=_Resp(403))) == 403
assert dl.permanent_status(HTTPError(response=_Resp(429))) is None
assert dl.permanent_status(HTTPError(response=_Resp(503))) is None
assert dl.permanent_status(IOError("short read")) is None
# RnetSession wraps the HTTPError one level down as __cause__
wrapped = RuntimeError("max retries")
wrapped.__cause__ = HTTPError(response=_Resp(404))
assert dl.permanent_status(wrapped) == 404