Files
imSp4rky d4690e5c6b 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.
2026-08-24 15:10:36 -06:00

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()