v0.27.3
This commit is contained in:
1 parent
e84df445c6
commit
e87a382a7e
109 files changed
+1092
-2925
No files matched your search
Executable
+191
@@ -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()
|
||||
Reference in new issue
Block a user