This commit is contained in:
Mateusz Gruszczyński committed 2026-10-07 22:22:54 +02:00
1 parent e84df445c6
commit e87a382a7e
109 files changed
+1092 -2925

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Local HTTP/1.1 origin for manual cache, health, compression and pool tests.
Only Python's standard library is used. This is not a production application.
"""
from __future__ import annotations
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
import secrets
import threading
import time
from urllib.parse import parse_qs, urlsplit
MAX_BODY = 2 * 1024 * 1024
MAX_WORKERS = 32
class State:
def __init__(self, name="origin-a", ttl=30):
self.name, self.ttl = name, ttl
self.healthy, self.delay_ms, self.sequence = True, 0, 0
self.lock = threading.Lock()
class Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address, state):
self.state = state
self.slots = threading.BoundedSemaphore(MAX_WORKERS)
super().__init__(address, Handler)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except BaseException:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "RustProxy-Mock/0.3.0"
def setup(self):
super().setup()
self.connection.settimeout(15)
def log_message(self, *_):
pass
def send(self, status, body=b"", content_type="application/json; charset=utf-8", control="no-store", extra=None):
if isinstance(body, dict):
body = (json.dumps(body, ensure_ascii=False) + "\n").encode()
if isinstance(body, str):
body = body.encode()
if status in (204, 304):
body = b""
self.send_response(status)
self.send_header("Content-Type", content_type)
if status != 204:
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", control)
self.send_header("X-Mock-Origin", self.server.state.name)
for name, value in (extra or {}).items():
self.send_header(name, value)
self.end_headers()
if self.command != "HEAD":
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError, TimeoutError):
self.close_connection = True
def integer(self, query, key, default, maximum):
value = int(query.get(key, [str(default)])[0])
if not 0 <= value <= maximum:
raise ValueError(key)
return value
def do_HEAD(self):
self.do_GET()
def do_GET(self):
target = urlsplit(self.path)
q = parse_qs(target.query)
state = self.server.state
try:
delay = self.integer(q, "ms", 800 if target.path == "/slow" else 0, 10000)
size = self.integer(q, "size", 65536, MAX_BODY)
ttl = self.integer(q, "ttl", state.ttl, 604800)
except ValueError:
return self.send(400, {"error":"invalid query parameter"})
with state.lock:
state.sequence += 1
sequence, healthy = state.sequence, state.healthy
delay += state.delay_ms
if delay:
time.sleep(min(delay, 10000) / 1000)
payload = {"origin":state.name, "sequence":sequence, "generated_at_ns":time.time_ns(),
"nonce":secrets.token_hex(16), "path":target.path,
"accept_language":self.headers.get("Accept-Language", ""), "healthy":healthy}
if target.path == "/health":
return self.send(200 if healthy else 503, payload)
if target.path == "/state":
return self.send(200, {**payload, "delay_ms":state.delay_ms, "ttl":state.ttl})
if target.path == "/":
return self.send(200, """<!doctype html><meta charset="utf-8"><title>RustProxy mock origin</title>
<h1>RustProxy test origin</h1><p>Each uncached request generates a new sequence and nonce.</p>
<ul><li><a href="/cache">/cache</a> - public JSON, 30-second freshness by default</li>
<li><a href="/nocache">/nocache</a> - no-store JSON</li><li><a href="/private">/private</a> - private JSON</li>
<li><a href="/cookie">/cookie</a> - Set-Cookie, bypass shared cache</li><li><a href="/large">/large?size=65536</a> - compressible bytes</li>
<li><a href="/slow">/slow?ms=800</a> - controlled delay</li><li><a href="/health">/health</a> - active probe target</li>
<li><a href="/status/503">/status/503</a> - custom error page test</li></ul>
<p>POST /control with JSON {"healthy":false,"delay_ms":0} to test failover/recovery.</p>""", "text/html; charset=utf-8")
if target.path == "/large":
prefix = (json.dumps(payload) + "\n").encode()
body = (prefix + b"RustProxy compression and cache fixture.\n" * (size // 30 + 1))[:size]
return self.send(200, body, "text/plain; charset=utf-8", f"public, max-age={ttl}")
if target.path.startswith("/status/"):
try:
status = int(target.path.rsplit("/", 1)[1])
if status not in [200, 204, 400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]:
raise ValueError()
except ValueError:
return self.send(400, {"error":"unsupported status"})
return self.send(status, payload, extra={"WWW-Authenticate":'Basic realm="mock"'} if status == 401 else None)
if target.path == "/cache":
return self.send(200, payload, control=f"public, max-age={ttl}", extra={"Vary":"Accept-Language"})
if target.path == "/private":
return self.send(200, payload, control="private, max-age=60")
if target.path == "/cookie":
return self.send(200, payload, control=f"public, max-age={ttl}", extra={"Set-Cookie":"mock-session=demo; HttpOnly; SameSite=Lax; Path=/"})
self.send(200 if target.path in ("/nocache", "/slow") else 404, payload)
def do_POST(self):
# Always consume or close a body before reusing an HTTP/1.1 connection.
if self.headers.get("Transfer-Encoding") or len(self.headers.get_all("Content-Length", [])) != 1:
self.close_connection = True
return self.send(411, {"error":"one Content-Length is required"})
try:
length = int(self.headers["Content-Length"])
if not 0 <= length <= 4096:
raise ValueError()
raw = self.rfile.read(length)
if len(raw) != length:
raise ValueError()
data = json.loads(raw)
if self.path != "/control" or not isinstance(data, dict) or set(data) - {"healthy", "delay_ms"}:
raise ValueError()
if "healthy" in data and not isinstance(data["healthy"], bool):
raise ValueError()
if "delay_ms" in data and (type(data["delay_ms"]) is not int or not 0 <= data["delay_ms"] <= 10000):
raise ValueError()
except (ValueError, TimeoutError):
self.close_connection = True
return self.send(400, {"error":"expected healthy: boolean and/or delay_ms: 0..10000"})
with self.server.state.lock:
for key, value in data.items():
setattr(self.server.state, key, value)
self.send(200, {"updated":True})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=9001)
parser.add_argument("--name", default="origin-a")
parser.add_argument("--ttl", type=int, default=30)
parser.add_argument("--allow-remote", action="store_true", help="Explicitly expose this development-only server on a non-loopback IPv4 address")
args = parser.parse_args()
try:
ip = ipaddress.ip_address(args.host)
except ValueError:
parser.error("--host must be an IPv4 address")
if ip.version != 4:
parser.error("this development helper accepts IPv4 bindings only")
if not ip.is_loopback and not args.allow_remote:
parser.error("non-loopback binding requires --allow-remote; /control is unauthenticated")
if not 0 <= args.port <= 65535 or not 0 <= args.ttl <= 604800:
parser.error("invalid port or TTL")
if not args.name or len(args.name) > 64 or any(ord(c) < 32 or ord(c) > 126 for c in args.name):
parser.error("--name must contain 1..64 printable ASCII characters")
server = Server((args.host, args.port), State(args.name, args.ttl))
print(f"Mock origin {args.name}: http://{args.host}:{server.server_port} (development only)", flush=True)
print("Endpoints: /cache /nocache /private /cookie /large /slow /health /status/503; POST /control", flush=True)
try:
server.serve_forever(poll_interval=0.2)
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()