mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 04:55:07 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user