mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-09-17 22:52:02 +02:00
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.
448 lines
19 KiB
Python
448 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Localhost fault-injection HTTP server for downloader reliability benchmarking.
|
|
|
|
Serves deterministic pseudo-random segment payloads (seeded per URL path, so the
|
|
bytes are reproducible and any output file can be hashed against them to prove
|
|
integrity) and applies a configurable *fault profile* to those responses. Stall
|
|
and reset faults are decided deterministically from a printed seed keyed on
|
|
(path, attempt), so a retry can recover instead of failing forever; rate-limit
|
|
windows are wall-clock relative to run start, so their timing is not seed-bound.
|
|
|
|
Fault profiles:
|
|
stall sleep N seconds mid-body on X% of (path, attempt)s
|
|
reset RST the connection mid-body on X% of (path, attempt)s
|
|
rate-limit reply 429 + Retry-After during recurring throttle windows
|
|
(window_open seconds serving / window_closed seconds throttled)
|
|
tail-slow heavily throttle the last K segments' bodies
|
|
throttle-cut throttle every body and close it (FIN) after N seconds or N bytes
|
|
flaky-first fail attempt 1 per path (503), succeed on retry
|
|
deny reply 403 to every request (a permanent error no retry can fix)
|
|
loopback no faults; payloads served from memory (overhead ceiling)
|
|
|
|
Range requests are honored (206 + Content-Range) so byte-range / hedge / tail-boost
|
|
download paths exercise correctly, and an unsatisfiable resume gets a 416.
|
|
|
|
Standalone:
|
|
uv run python scripts/fault_server.py --profile rate-limit --segments 32
|
|
uv run python scripts/fault_server.py --profile stall --stall-secs 4 --port 8080
|
|
uv run python scripts/fault_server.py --profile throttle-cut --segments 1 --seg-size 71303168
|
|
uv run python scripts/fault_server.py --profile deny --deny-after 1
|
|
|
|
Importable: build a ``FaultServer``, serve it on a thread, and hash output against
|
|
``segment_payload(path, size)``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import random
|
|
import socket
|
|
import struct
|
|
import threading
|
|
import time
|
|
from dataclasses import asdict, dataclass
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Any, Optional
|
|
|
|
MIB = 1024 * 1024
|
|
DEFAULT_SEED = 1729
|
|
|
|
|
|
def segment_payload(path: str, size: int) -> bytes:
|
|
"""Deterministic pseudo-random body for ``path``.
|
|
|
|
Pure and seed-stable: the bench regenerates this to byte-compare every output
|
|
file, so it must never depend on the fault seed, request order, or attempt.
|
|
"""
|
|
seed = int.from_bytes(hashlib.sha256(path.encode("utf-8")).digest()[:8], "big")
|
|
return random.Random(seed).randbytes(size)
|
|
|
|
|
|
@dataclass
|
|
class FaultProfile:
|
|
"""A named set of deterministic response faults. Presets live in ``PROFILES``."""
|
|
|
|
name: str
|
|
stall_pct: float = 0.0 # % of (path, attempt)s that stall mid-body
|
|
stall_secs: float = 3.0 # seconds to hold when a stall fires
|
|
reset_pct: float = 0.0 # % of (path, attempt)s that RST mid-body
|
|
rate_limit: bool = False # 429 + Retry-After during closed windows
|
|
retry_after: int = 1 # Retry-After seconds advertised on a 429
|
|
window_open: float = 5.0 # seconds serving normally before a throttle window
|
|
window_closed: float = 5.0 # seconds replying 429 during a throttle window
|
|
body_rate_kib: int = 0 # throttle every segment body to this KiB/s (0 = unthrottled)
|
|
tail_slow: int = 0 # throttle the last K segments' bodies
|
|
tail_rate_kib: int = 256 # throttle rate for tail-slow segments (KiB/s)
|
|
flaky_first: bool = False # fail attempt 1 per path, succeed after
|
|
flaky_status: int = 503 # status returned by a flaky-first failure
|
|
cut_secs: float = 0.0 # close the body cleanly (FIN) after this long (0 = never)
|
|
cut_bytes: int = 0 # close the body cleanly (FIN) after this many bytes (0 = never)
|
|
deny: bool = False # reply deny_status to every request past deny_after
|
|
deny_status: int = 403 # status returned by a deny
|
|
deny_after: int = 0 # serve this many attempts per path before denying (0 = deny all)
|
|
|
|
|
|
PROFILES: dict[str, FaultProfile] = {
|
|
"loopback": FaultProfile("loopback"),
|
|
"stall": FaultProfile("stall", stall_pct=25.0, stall_secs=3.0),
|
|
"reset": FaultProfile("reset", reset_pct=25.0),
|
|
# short windows + a body throttle so a fast loopback batch actually spans several throttle
|
|
# windows and trips the 429 + Retry-After retry path (a real 900 MB/s batch would otherwise
|
|
# finish inside the first open window). Standalone use can widen the windows via overrides.
|
|
"rate-limit": FaultProfile(
|
|
"rate-limit", rate_limit=True, retry_after=1, window_open=1.0, window_closed=1.0, body_rate_kib=2048
|
|
),
|
|
"tail-slow": FaultProfile("tail-slow", tail_slow=4, tail_rate_kib=256),
|
|
"flaky-first": FaultProfile("flaky-first", flaky_first=True, flaky_status=503),
|
|
# a CDN that throttles one flow and drops it on a timer: every connection delivers bytes
|
|
# and then ends short, so each retry makes progress but no single attempt finishes. Not
|
|
# seed-gated like stall and reset, because the real host cuts every attempt, not a share.
|
|
"throttle-cut": FaultProfile("throttle-cut", body_rate_kib=480, cut_secs=6.5, cut_bytes=5 * MIB // 2),
|
|
# a permanently refused resource (expired token, geo block): no retry and no fallback
|
|
# recovers, so a client that keeps trying is burning attempts for nothing. --deny-after
|
|
# serves the first N attempts per path, which lets a range probe through first.
|
|
"deny": FaultProfile("deny", deny=True),
|
|
}
|
|
|
|
|
|
class FaultServer(ThreadingHTTPServer):
|
|
"""Threaded loopback server serving seeded payloads under a ``FaultProfile``.
|
|
|
|
Counters (total requests, per-status, per-path attempts) are the request-amplification
|
|
signal the bench reads: a retry/hedge storm against a throttled host shows up as
|
|
requests >> segment count. ``reset_counters`` re-baselines between benchmark runs.
|
|
"""
|
|
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
request_queue_size = 128 # default 5 drops SYNs past 5 concurrent connects -> phantom 1s retransmit tail
|
|
|
|
def __init__(
|
|
self,
|
|
profile: FaultProfile,
|
|
seg_size: int,
|
|
total_segments: int,
|
|
seed: int = DEFAULT_SEED,
|
|
host: str = "127.0.0.1",
|
|
port: int = 0,
|
|
) -> None:
|
|
self.profile = profile
|
|
self.seg_size = seg_size
|
|
self.total_segments = total_segments
|
|
self.seed = seed
|
|
self.stall_event = threading.Event() # set at teardown to release stalled handlers
|
|
self._lock = threading.Lock()
|
|
self._request_count = 0
|
|
self._status_counts: dict[int, int] = {}
|
|
self._attempts: dict[str, int] = {}
|
|
self._window_origin = time.monotonic()
|
|
# precompute the known segment bodies so serving is a dict lookup (no per-request
|
|
# generation), making the loopback profile a true python-overhead ceiling
|
|
self._payloads: dict[str, bytes] = {
|
|
f"/seg/{i}": segment_payload(f"/seg/{i}", seg_size) for i in range(total_segments)
|
|
}
|
|
super().__init__((host, port), FaultHandler)
|
|
|
|
def handle_error(self, request: Any, client_address: Any) -> None: # silence expected fault noise
|
|
pass
|
|
|
|
def payload(self, path: str) -> bytes:
|
|
cached = self._payloads.get(path)
|
|
if cached is not None:
|
|
return cached
|
|
with self._lock: # unknown path (standalone use): generate + cache once
|
|
body = self._payloads.get(path)
|
|
if body is None:
|
|
body = segment_payload(path, self.seg_size)
|
|
self._payloads[path] = body
|
|
return body
|
|
|
|
def note_request(self) -> None:
|
|
with self._lock:
|
|
self._request_count += 1
|
|
|
|
def note_status(self, code: int) -> None:
|
|
with self._lock:
|
|
self._status_counts[code] = self._status_counts.get(code, 0) + 1
|
|
|
|
def note_attempt(self, path: str) -> int:
|
|
with self._lock:
|
|
n = self._attempts.get(path, 0) + 1
|
|
self._attempts[path] = n
|
|
return n
|
|
|
|
def in_throttle_window(self) -> bool:
|
|
p = self.profile
|
|
period = p.window_open + p.window_closed
|
|
if period <= 0:
|
|
return False
|
|
phase = (time.monotonic() - self._window_origin) % period
|
|
return phase >= p.window_open
|
|
|
|
def decides(self, kind: str, path: str, attempt: int, pct: float) -> bool:
|
|
"""Deterministic per-(path, attempt) fault gate: fires for ``pct`` percent of them."""
|
|
if pct <= 0:
|
|
return False
|
|
digest = hashlib.sha256(f"{self.seed}:{kind}:{path}:{attempt}".encode("utf-8")).digest()
|
|
return (int.from_bytes(digest[:4], "big") / 0xFFFFFFFF) * 100.0 < pct
|
|
|
|
def reset_counters(self) -> None:
|
|
with self._lock:
|
|
self._request_count = 0
|
|
self._status_counts.clear()
|
|
self._attempts.clear()
|
|
self._window_origin = time.monotonic() # realign throttle windows to this run's start
|
|
|
|
def stats(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
max_attempts = max(self._attempts.values(), default=0)
|
|
return {
|
|
"requests": self._request_count,
|
|
"status_counts": dict(sorted(self._status_counts.items())),
|
|
"max_attempts": max_attempts,
|
|
}
|
|
|
|
|
|
class FaultHandler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def log_message(self, *_: Any) -> None: # silence per-request logging
|
|
pass
|
|
|
|
def _parse_range(self, total: int) -> Optional[tuple[int, int]]:
|
|
header = self.headers.get("Range")
|
|
if not header or not header.startswith("bytes="):
|
|
return None
|
|
start_s, _, end_s = header[len("bytes=") :].partition("-")
|
|
if not start_s: # RFC 7233 suffix range: bytes=-N means the last N bytes
|
|
return max(0, total - int(end_s)), total - 1
|
|
start = int(start_s)
|
|
end = int(end_s) if end_s else total - 1
|
|
if start > end: # inverted range: report unsatisfiable so _serve answers 416
|
|
return total, total - 1
|
|
return start, min(end, total - 1)
|
|
|
|
def do_GET(self) -> None:
|
|
server: FaultServer = self.server # type: ignore[assignment]
|
|
server.note_request()
|
|
if not self.path.startswith("/seg/"):
|
|
self.send_error(404)
|
|
return
|
|
try:
|
|
idx = int(self.path.rsplit("/", 1)[-1])
|
|
except ValueError:
|
|
self.send_error(404)
|
|
return
|
|
|
|
attempt = server.note_attempt(self.path)
|
|
prof = server.profile
|
|
|
|
if prof.deny and attempt > prof.deny_after:
|
|
self._send_status(prof.deny_status)
|
|
return
|
|
if prof.flaky_first and attempt == 1: # first attempt per path always fails, retry recovers
|
|
self._send_status(prof.flaky_status)
|
|
return
|
|
if prof.rate_limit and server.in_throttle_window(): # recurring throttle window
|
|
self._send_429(prof.retry_after)
|
|
return
|
|
|
|
rate = prof.body_rate_kib
|
|
if prof.tail_slow and idx >= server.total_segments - prof.tail_slow:
|
|
rate = prof.tail_rate_kib
|
|
stall = server.decides("stall", self.path, attempt, prof.stall_pct)
|
|
reset = server.decides("reset", self.path, attempt, prof.reset_pct)
|
|
self._serve(
|
|
server.payload(self.path),
|
|
stall=stall,
|
|
reset=reset,
|
|
rate_kib=rate,
|
|
cut_secs=prof.cut_secs,
|
|
cut_bytes=prof.cut_bytes,
|
|
)
|
|
|
|
def _send_status(self, code: int) -> None:
|
|
server: FaultServer = self.server # type: ignore[assignment]
|
|
server.note_status(code)
|
|
self.send_response(code)
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
def _send_429(self, retry_after: int) -> None:
|
|
server: FaultServer = self.server # type: ignore[assignment]
|
|
server.note_status(429)
|
|
self.send_response(429)
|
|
self.send_header("Retry-After", str(retry_after))
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
def _serve(
|
|
self,
|
|
full: bytes,
|
|
stall: bool = False,
|
|
reset: bool = False,
|
|
rate_kib: int = 0,
|
|
cut_secs: float = 0.0,
|
|
cut_bytes: int = 0,
|
|
) -> None:
|
|
server: FaultServer = self.server # type: ignore[assignment]
|
|
rng = self._parse_range(len(full))
|
|
if rng is not None and rng[0] >= len(full): # unsatisfiable resume: a real CDN answers 416
|
|
server.note_status(416)
|
|
self.send_response(416)
|
|
self.send_header("Content-Range", f"bytes */{len(full)}")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
return
|
|
if rng is not None:
|
|
start, end = rng
|
|
body = full[start : end + 1]
|
|
server.note_status(206)
|
|
self.send_response(206)
|
|
self.send_header("Content-Range", f"bytes {start}-{end}/{len(full)}")
|
|
else:
|
|
body = full
|
|
server.note_status(200)
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
self.send_header("Content-Type", "application/octet-stream")
|
|
self.end_headers()
|
|
|
|
try:
|
|
if reset: # send half, then abort the TCP connection (RST via SO_LINGER=0)
|
|
half = max(1, len(body) // 2)
|
|
self.wfile.write(body[:half])
|
|
self.wfile.flush()
|
|
sock = self.connection
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
|
|
sock.close()
|
|
self.close_connection = True
|
|
elif stall: # send half, hold (a client READ_TIMEOUT shorter than stall_secs will retry), then finish
|
|
half = max(1, len(body) // 2)
|
|
self.wfile.write(body[:half])
|
|
self.wfile.flush()
|
|
server.stall_event.wait(server.profile.stall_secs)
|
|
self.wfile.write(body[half:])
|
|
self.wfile.flush()
|
|
else:
|
|
self._write_body(body, rate_kib, cut_secs, cut_bytes)
|
|
except (BrokenPipeError, ConnectionError, OSError):
|
|
self.close_connection = True
|
|
|
|
def _cut(self) -> None:
|
|
"""End the body early with a clean half-close.
|
|
|
|
FIN rather than RST: the client reads fewer bytes than Content-Length promised and
|
|
raises IncompleteRead, which is the resumable short read a throttling CDN produces.
|
|
An RST (see the reset fault) is a different error class on the client side.
|
|
"""
|
|
try:
|
|
self.wfile.flush()
|
|
self.connection.shutdown(socket.SHUT_WR)
|
|
except OSError:
|
|
pass
|
|
self.close_connection = True
|
|
|
|
def _write_body(self, body: bytes, rate_kib: int, cut_secs: float = 0.0, cut_bytes: int = 0) -> None:
|
|
if rate_kib <= 0 and cut_secs <= 0 and cut_bytes <= 0:
|
|
self.wfile.write(body)
|
|
return
|
|
chunk = 64 * 1024 # throttle: fixed-size chunks paced to rate_kib KiB/s
|
|
per_chunk = chunk / (rate_kib * 1024) if rate_kib > 0 else 0.0
|
|
deadline = time.monotonic() + cut_secs if cut_secs > 0 else None
|
|
sent = 0
|
|
for off in range(0, len(body), chunk):
|
|
size = min(chunk, len(body) - off)
|
|
if cut_bytes > 0:
|
|
size = min(size, cut_bytes - sent)
|
|
self.wfile.write(body[off : off + size])
|
|
self.wfile.flush()
|
|
sent += size
|
|
if cut_bytes > 0 and sent >= cut_bytes:
|
|
self._cut()
|
|
return
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
self._cut()
|
|
return
|
|
if per_chunk:
|
|
time.sleep(per_chunk)
|
|
|
|
|
|
def resolve_profile(name: str, overrides: Optional[dict[str, Any]] = None) -> FaultProfile:
|
|
"""Return a copy of the named preset with any non-None ``overrides`` applied."""
|
|
if name not in PROFILES:
|
|
raise KeyError(f"unknown profile {name!r}; choices: {', '.join(sorted(PROFILES))}")
|
|
base = asdict(PROFILES[name])
|
|
for key, value in (overrides or {}).items():
|
|
if value is not None and key in base:
|
|
base[key] = value
|
|
return FaultProfile(**base)
|
|
|
|
|
|
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("--port", type=int, default=0, help="Listen port (0 = auto).")
|
|
p.add_argument("--profile", choices=sorted(PROFILES), default="loopback", help="Fault profile to apply.")
|
|
p.add_argument("--segments", type=int, default=32, help="Number of /seg/i payloads to precompute.")
|
|
p.add_argument("--seg-size", type=int, default=2 * MIB, help="Bytes per segment payload.")
|
|
p.add_argument("--seed", type=int, default=DEFAULT_SEED, help="Fault RNG seed (printed for reproducibility).")
|
|
# profile knobs (override the chosen preset when given)
|
|
p.add_argument("--stall-pct", type=float, help="Override: %% of (path, attempt)s that stall.")
|
|
p.add_argument("--stall-secs", type=float, help="Override: seconds a stall holds.")
|
|
p.add_argument("--reset-pct", type=float, help="Override: %% of (path, attempt)s that RST.")
|
|
p.add_argument("--retry-after", type=int, help="Override: Retry-After seconds on a 429.")
|
|
p.add_argument("--window-open", type=float, help="Override: seconds serving before a throttle window.")
|
|
p.add_argument("--window-closed", type=float, help="Override: seconds replying 429 during a window.")
|
|
p.add_argument("--body-rate-kib", type=int, help="Override: throttle every body to this KiB/s (0 = off).")
|
|
p.add_argument("--tail-slow", type=int, help="Override: throttle the last K segments.")
|
|
p.add_argument("--tail-rate-kib", type=int, help="Override: tail-slow throttle rate (KiB/s).")
|
|
p.add_argument("--deny-after", type=int, help="Override: serve this many attempts per path before denying.")
|
|
p.add_argument("--cut-secs", type=float, help="Override: close each body (FIN) after this long (0 = never).")
|
|
p.add_argument("--cut-bytes", type=int, help="Override: close each body (FIN) after this many bytes (0 = never).")
|
|
return p
|
|
|
|
|
|
def main() -> None:
|
|
args = _build_arg_parser().parse_args()
|
|
overrides = {
|
|
"stall_pct": args.stall_pct,
|
|
"stall_secs": args.stall_secs,
|
|
"reset_pct": args.reset_pct,
|
|
"retry_after": args.retry_after,
|
|
"window_open": args.window_open,
|
|
"window_closed": args.window_closed,
|
|
"body_rate_kib": args.body_rate_kib,
|
|
"tail_slow": args.tail_slow,
|
|
"tail_rate_kib": args.tail_rate_kib,
|
|
"cut_secs": args.cut_secs,
|
|
"cut_bytes": args.cut_bytes,
|
|
"deny_after": args.deny_after,
|
|
}
|
|
profile = resolve_profile(args.profile, overrides)
|
|
server = FaultServer(profile, args.seg_size, args.segments, args.seed, port=args.port)
|
|
host, port = server.server_address[0], server.server_address[1]
|
|
config = {
|
|
"listen": f"http://{host}:{port}",
|
|
"seed": args.seed,
|
|
"segments": args.segments,
|
|
"seg_size": args.seg_size,
|
|
"base_url": f"http://{host}:{port}/seg/{{i}}",
|
|
"profile": asdict(profile),
|
|
}
|
|
print(json.dumps(config, indent=2), flush=True)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.stall_event.set()
|
|
server.shutdown()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|