Files
SocialPub/tools/pasture/town/core/http.py
T

219 lines
6.7 KiB
Python
Raw Normal View History

"""HTTP to the pasture: every https://<name>.test site through Caddy on 127.0.0.1:6443, verified against Caddy's own CA
(.ca/root.crt), with one kept-alive connection per site and thread. 429s wait for the server's reset; connection drops
are retried. Every request is timed into `Client.timings` for the load mode."""
import http.client
import json as jsonlib
import os
import socket
import ssl
import threading
import time
import urllib.parse
import uuid
CADDY = ("127.0.0.1", 6443)
class HttpError(Exception):
def __init__(self, method, url, status, body):
super().__init__(f"{method} {url} -> {status}: {body[:300]!r}")
self.status = status
self.body = body
class Response:
def __init__(self, status, headers, body, url):
self.status = status
self.headers = headers
self.body = body
self.url = url
@property
def ok(self):
return 200 <= self.status < 300
@property
def text(self):
return self.body.decode("utf-8", "replace")
def json(self):
if not self.body:
return None
try:
return jsonlib.loads(self.body)
except ValueError:
return None
def header(self, name):
for k, v in self.headers:
if k.lower() == name.lower():
return v
return None
class _CaddyConnection(http.client.HTTPSConnection):
"""Connects to Caddy's port but speaks TLS (SNI and certificate check) as the named site."""
def __init__(self, site, context, timeout):
super().__init__(site, 443, context=context, timeout=timeout)
self._site = site
self._ctx = context
def connect(self):
sock = socket.create_connection(CADDY, self.timeout)
self.sock = self._ctx.wrap_socket(sock, server_hostname=self._site)
class Client:
def __init__(self, ca_file, timeout=60):
self.ctx = ssl.create_default_context(cafile=ca_file)
self.timeout = timeout
self.local = threading.local()
self.timings = [] # (method, site, path template, status, ms)
self.lock = threading.Lock()
self.record = False
def _connection(self, scheme, netloc):
conns = getattr(self.local, "conns", None)
if conns is None:
conns = self.local.conns = {}
key = (scheme, netloc)
conn = conns.get(key)
if conn is None:
if scheme == "https":
conn = _CaddyConnection(netloc.split(":")[0], self.ctx, self.timeout)
else:
host, _, port = netloc.partition(":")
conn = http.client.HTTPConnection(host, int(port or 80), timeout=self.timeout)
conns[key] = conn
return conn
def _drop(self, scheme, netloc):
conn = self.local.conns.pop((scheme, netloc), None)
if conn is not None:
conn.close()
def request(self, method, url, *, headers=None, json=None, form=None, data=None, files=None, params=None,
ok=None, attempts=4, wait_429=True, template=None):
"""Sends one request. `json` is a JSON body, `form` urlencoded pairs (a dict or a list of pairs, lists expand to
key[]=v as Rails expects), `files` a multipart body ({name: (filename, bytes, type)} plus `form` fields).
`ok` is a set of accepted statuses: anything else raises HttpError."""
parts = urllib.parse.urlsplit(url)
path = parts.path or "/"
query = parts.query
if params:
extra = urllib.parse.urlencode(_pairs(params), doseq=True)
query = f"{query}&{extra}" if query else extra
target = path + (f"?{query}" if query else "")
hdrs = {"Accept": "application/json", "User-Agent": "pasture-town/1"}
body = None
if json is not None:
body = jsonlib.dumps(json).encode()
hdrs["Content-Type"] = "application/json"
elif files is not None:
boundary = uuid.uuid4().hex
body = _multipart(boundary, _pairs(form or {}), files)
hdrs["Content-Type"] = f"multipart/form-data; boundary={boundary}"
elif form is not None:
body = urllib.parse.urlencode(_pairs(form), doseq=True).encode()
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
elif data is not None:
body = data
if headers:
hdrs.update(headers)
last = None
for attempt in range(attempts):
conn = self._connection(parts.scheme, parts.netloc)
started = time.monotonic()
try:
conn.request(method, target, body=body, headers=hdrs)
raw = conn.getresponse()
payload = raw.read()
resp = Response(raw.status, raw.getheaders(), payload, url)
except (ConnectionError, http.client.HTTPException, socket.timeout, ssl.SSLError, OSError) as e:
self._drop(parts.scheme, parts.netloc)
last = e
time.sleep(0.5 * (attempt + 1))
continue
ms = (time.monotonic() - started) * 1000
if self.record:
with self.lock:
self.timings.append((method, parts.netloc, template or path, resp.status, ms))
if resp.status == 429 and wait_429 and attempt < attempts - 1:
time.sleep(_retry_after(resp))
continue
if resp.status in (502, 503, 504) and attempt < attempts - 1:
time.sleep(1 + attempt)
continue
if ok is not None and resp.status not in ok:
raise HttpError(method, url, resp.status, resp.text)
return resp
raise HttpError(method, url, 0, repr(last))
def get(self, url, **kw):
return self.request("GET", url, **kw)
def post(self, url, **kw):
return self.request("POST", url, **kw)
def _pairs(values):
"""A dict or list of pairs, with list values as Rails' key[]=v (unless the key already ends in [])."""
items = values.items() if isinstance(values, dict) else values
out = []
for k, v in items:
if v is None:
continue
if isinstance(v, (list, tuple)):
key = k if k.endswith("[]") else f"{k}[]"
out.extend((key, _scalar(x)) for x in v)
else:
out.append((k, _scalar(v)))
return out
def _scalar(v):
if v is True:
return "true"
if v is False:
return "false"
return str(v)
def _multipart(boundary, fields, files):
chunks = []
for k, v in fields:
chunks.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode())
for name, (filename, content, ctype) in files.items():
chunks.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
f"Content-Type: {ctype}\r\n\r\n".encode() + content + b"\r\n")
chunks.append(f"--{boundary}--\r\n".encode())
return b"".join(chunks)
def _retry_after(resp):
value = resp.header("Retry-After")
if value and value.isdigit():
return min(int(value), 60)
reset = resp.header("X-RateLimit-Reset")
if reset:
try:
from email.utils import parsedate_to_datetime
import datetime
when = datetime.datetime.fromisoformat(reset.replace("Z", "+00:00")) if "T" in reset \
else parsedate_to_datetime(reset)
return max(1, min(60, (when - datetime.datetime.now(datetime.timezone.utc)).total_seconds()))
except (ValueError, TypeError):
pass
return 5
_client = None
def client():
global _client
if _client is None:
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_client = Client(os.path.join(here, ".ca", "root.crt"))
return _client