"""Small synchronous REST transport. No logging, redirects or automatic retries.""" from dataclasses import dataclass, field from decimal import Decimal import http.client import ipaddress import json import math import secrets import socket import ssl import threading import time from urllib.parse import urlencode, urlsplit from uuid import UUID MAX_BYTES = 32 * 1024 * 1024 class TransportError(Exception): """A sanitized transport failure; a write may already have committed.""" @dataclass(frozen=True) class Response: status: int headers: dict = field(repr=False) body: bytes = field(repr=False) def json(self): """Preserve fractional JSON numbers; retain body for exact wire export.""" return json.loads(self.body, parse_float=Decimal) def uuid7(): """Generate an operation identity. Keep the host clock synchronized.""" value = (time.time_ns() // 1_000_000) << 80 value |= 7 << 76 value |= secrets.randbits(12) << 64 value |= 2 << 62 value |= secrets.randbits(62) return str(UUID(int=value)) def segment(value): """Encode one identifier; reject ambiguous path segments.""" from urllib.parse import quote if not isinstance(value, str) or not value or value in (".", ".."): raise ValueError("invalid path segment") if any(ord(c) < 32 or ord(c) == 127 or c in "/\\" for c in value): raise ValueError("invalid path segment") return quote(value, safe="") class Client: def __init__(self, base_url, credential=None, *, timeout=30, allow_local_http=False): if not isinstance(base_url, str) or any(ord(c) <= 32 or c in "\\" for c in base_url): raise ValueError("invalid service origin") try: u = urlsplit(base_url) local = u.hostname == "localhost" or ipaddress.ip_address(u.hostname).is_loopback except ValueError: local = False try: valid = (u.scheme == "https" or (u.scheme == "http" and allow_local_http and local)) if (not valid or not u.hostname or u.username is not None or u.password is not None or u.path not in ("", "/") or u.query or u.fragment or not u.port and u.netloc.endswith(":")): raise ValueError() port = u.port except (ValueError, UnboundLocalError): raise ValueError("invalid service origin") from None if not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or not 1 <= timeout <= 60: raise ValueError("invalid timeout") if credential is not None and (not isinstance(credential, str) or not credential or any(ord(c) < 33 or ord(c) > 126 for c in credential)): raise ValueError("invalid credential") self._host, self._port, self._scheme = u.hostname, port, u.scheme self._credential, self._timeout = credential, timeout def __repr__(self): return "" @property def origin(self): """Normalized service origin, without credentials or request paths.""" host = self._host.encode("idna").decode("ascii").lower() if ":" in host: host = "[" + host + "]" default = 443 if self._scheme == "https" else 80 port = "" if self._port in (None, default) else ":" + str(self._port) return self._scheme + "://" + host + port def request(self, method, path, *, body=None, query=None, content_type="application/json", idempotency_key=None, if_match=None, create_only=False): if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"): raise ValueError("invalid method") if (not isinstance(path, str) or not path.startswith(("/api/v1/", "/hooks/v1/")) or any(ord(c) < 33 or ord(c) > 126 or c in "?#\\" for c in path) or any(x in (".", "..") for x in path.split("/"))): raise ValueError("invalid relative API path") if body is not None and not isinstance(body, (bytes, str)): raise ValueError("body must be exact UTF-8 text or bytes") payload = body.encode("utf-8") if isinstance(body, str) else body if payload is not None and len(payload) > MAX_BYTES: raise ValueError("request exceeds client limit") if if_match is not None and create_only: raise ValueError("conflicting preconditions") headers = {"Accept": "application/json, application/octet-stream", "Accept-Encoding": "identity"} if payload is not None: headers["Content-Type"] = content_type if self._credential is not None: headers["Authorization"] = "Bearer " + self._credential if idempotency_key is not None: headers["Idempotency-Key"] = idempotency_key if if_match is not None: headers["If-Match"] = if_match if create_only: headers["If-None-Match"] = "*" for v in headers.values(): if not isinstance(v, str) or not v or any(ord(c) < 32 or ord(c) > 126 for c in v): raise ValueError("invalid request header") if query: path += "?" + urlencode(query) deadline = time.monotonic() + self._timeout connection = (http.client.HTTPSConnection(self._host, self._port, timeout=self._timeout, context=ssl.create_default_context()) if self._scheme == "https" else http.client.HTTPConnection(self._host, self._port, timeout=self._timeout)) active_socket = [None] def interrupt_io(): sock = active_socket[0] or connection.sock if sock is not None: try: sock.shutdown(socket.SHUT_RDWR) except OSError: pass timer = threading.Timer(self._timeout, interrupt_io) timer.daemon = True timer.start() try: connection.connect() active_socket[0] = connection.sock if time.monotonic() >= deadline: raise TransportError("request deadline exceeded") connection.request(method, path, body=payload, headers=headers) remaining = deadline - time.monotonic() if remaining <= 0: raise TransportError("request deadline exceeded") connection.sock.settimeout(remaining) sock = connection.sock response = connection.getresponse() if 300 <= response.status < 400: raise TransportError("redirect rejected; inspect the configured service origin") if response.getheader("Content-Encoding", "identity") not in ("", "identity"): raise TransportError("encoded response rejected") length = response.getheader("Content-Length") if length is not None and (not length.isascii() or not length.isdecimal() or int(length) > MAX_BYTES): raise TransportError("response exceeds client limit") parts, total = [], 0 while not response.isclosed(): remaining = deadline - time.monotonic() if remaining <= 0: raise TransportError("request deadline exceeded") # response may own the socket after Connection: close detaches it. sock.settimeout(remaining) part = response.read1(min(65536, MAX_BYTES + 1 - total)) total += len(part) if total > MAX_BYTES: raise TransportError("response exceeds client limit") if not part: break parts.append(part) if length is not None and total != int(length): raise TransportError("incomplete response; mutation outcome may be unknown") if time.monotonic() >= deadline: raise TransportError("request deadline exceeded") return Response(response.status, dict(response.getheaders()), b"".join(parts)) except TransportError: raise except (OSError, http.client.HTTPException, ValueError, AttributeError): raise TransportError("request failed; mutation outcome may be unknown") from None finally: timer.cancel() connection.close()