warpedpinball.transports.http

HTTP transport for Vector boards (plain HTTP on port 80).

  1"""HTTP transport for Vector boards (plain HTTP on port 80)."""
  2
  3from __future__ import annotations
  4
  5import time
  6from typing import Any, Iterator, Optional
  7from urllib.parse import urlsplit
  8
  9import requests
 10
 11from .. import auth
 12from ..exceptions import (
 13    AuthenticationError,
 14    AuthenticationRequiredError,
 15    DeviceTimeoutError,
 16    DeviceUnreachableError,
 17    RateLimitedError,
 18    TransportError,
 19)
 20from . import Transport, parse_body, raise_for_status, serialize_body
 21
 22DEFAULT_TIMEOUT = 10.0
 23
 24
 25def _is_lan_address(host: str) -> bool:
 26    """True for private/link-local/loopback IPs (boards always live on these)."""
 27    import ipaddress
 28
 29    try:
 30        ip = ipaddress.ip_address(host)
 31    except ValueError:
 32        return False  # a hostname; leave proxy behavior alone
 33    return ip.is_private or ip.is_link_local or ip.is_loopback
 34#: Retries when the device says "429 Too many challenges" (expired challenges
 35#: are purged on each challenge request, so a short sleep usually clears it).
 36CHALLENGE_RETRIES = 3
 37CHALLENGE_RETRY_SLEEP = 1.0
 38
 39
 40class HttpTransport(Transport):
 41    """Talks to a Vector at ``http://<host>`` and handles HMAC auth signing."""
 42
 43    requires_password = True
 44
 45    def __init__(
 46        self,
 47        host: str,
 48        password: Optional[str] = None,
 49        timeout: float = DEFAULT_TIMEOUT,
 50        session: Optional[requests.Session] = None,
 51    ):
 52        host = host.rstrip("/")
 53        if not host.startswith("http://") and not host.startswith("https://"):
 54            host = "http://" + host
 55        self.base_url = host
 56        #: Bare host[:port] for error messages (no scheme/path noise).
 57        self._host_label = urlsplit(self.base_url).netloc or self.base_url
 58        self.password = password
 59        self.timeout = timeout
 60        if session is None:
 61            session = requests.Session()
 62            if _is_lan_address(urlsplit(self.base_url).hostname or ""):
 63                # Never route LAN traffic through a system/environment proxy.
 64                # Windows machines often have one configured (corporate, VPN,
 65                # WPAD auto-detect), and requests would otherwise send this
 66                # board's traffic to a proxy that cannot reach it.
 67                session.trust_env = False
 68        self._session = session
 69
 70    @property
 71    def description(self) -> str:
 72        return self.base_url
 73
 74    def close(self) -> None:
 75        self._session.close()
 76
 77    # -- internals ---------------------------------------------------------
 78
 79    def _wrap_request_exc(
 80        self, exc: requests.RequestException, path: str
 81    ) -> TransportError:
 82        """Turn a raw requests/urllib3 error into a clean, typed TransportError.
 83
 84        Timeouts and connection failures are the common, user-facing cases and
 85        get their own friendly subclasses; anything else keeps a compact
 86        message. The original exception is preserved on ``.cause`` (and these
 87        are raised ``from None`` so an uncaught error prints one short
 88        traceback, not the full urllib3/requests stack)."""
 89        if isinstance(exc, requests.exceptions.Timeout):
 90            return DeviceTimeoutError(self._host_label, timeout=self.timeout, cause=exc)
 91        if isinstance(exc, requests.exceptions.ConnectionError):
 92            return DeviceUnreachableError(self._host_label, cause=exc)
 93        return TransportError(f"Request to {self._host_label}{path} failed: {exc}")
 94
 95    def _fetch_challenge(self) -> str:
 96        """Fetch a fresh single-use challenge (never cached)."""
 97        for attempt in range(CHALLENGE_RETRIES + 1):
 98            try:
 99                resp = self._session.get(
100                    self.base_url + auth.CHALLENGE_PATH, timeout=self.timeout
101                )
102            except requests.RequestException as exc:
103                raise self._wrap_request_exc(exc, auth.CHALLENGE_PATH) from None
104            if resp.status_code == 429:
105                if attempt < CHALLENGE_RETRIES:
106                    time.sleep(CHALLENGE_RETRY_SLEEP)
107                    continue
108                raise RateLimitedError(
109                    "Device has too many outstanding auth challenges; "
110                    "retry after a short wait"
111                )
112            raise_for_status(resp.status_code, resp.text, auth.CHALLENGE_PATH)
113            data = resp.json()
114            return data["challenge"]
115        # Defensive: the loop above always returns or raises within the retry
116        # budget, so this is unreachable in practice.
117        raise RateLimitedError(  # pragma: no cover
118            "Device has too many outstanding auth challenges"
119        )
120
121    def _send(
122        self,
123        path: str,
124        body_str: Optional[str],
125        authenticated: bool,
126        stream: bool = False,
127    ) -> requests.Response:
128        headers = {}
129        if body_str is not None:
130            headers["Content-Type"] = "application/json"
131        if authenticated:
132            # An empty string is a valid (empty) password and is signed as
133            # such; only ``None`` means no password has been set.
134            if self.password is None:
135                raise AuthenticationRequiredError(
136                    f"Route {path!r} requires authentication but no password is set"
137                )
138            headers.update(
139                auth.auth_headers(self.password, self._fetch_challenge(), path, body_str or "")
140            )
141
142        method = "GET" if body_str is None else "POST"
143        url = self.base_url + path
144        retries = 1 if (method == "GET" and not authenticated) else 0
145        while True:
146            try:
147                return self._session.request(
148                    method,
149                    url,
150                    data=body_str.encode("utf-8") if body_str is not None else None,
151                    headers=headers,
152                    timeout=self.timeout,
153                    stream=stream,
154                )
155            except requests.RequestException as exc:
156                if retries > 0:
157                    retries -= 1
158                    continue
159                raise self._wrap_request_exc(exc, path) from None
160
161    def _request_with_auth_retry(
162        self, path: str, body_str: Optional[str], authenticated: bool, stream: bool
163    ) -> requests.Response:
164        resp = self._send(path, body_str, authenticated, stream=stream)
165        if authenticated and resp.status_code == 401:
166            # Challenges are single-use and expire after 60 s; a stale/consumed
167            # challenge earns exactly one retry. "Bad Credentials" never does.
168            try:
169                raise_for_status(resp.status_code, resp.text, path)
170            except AuthenticationError as exc:
171                if not auth.is_retryable_auth_failure(exc.reason):
172                    raise
173            resp = self._send(path, body_str, authenticated, stream=stream)
174        return resp
175
176    # -- Transport interface -------------------------------------------------
177
178    def request(self, path: str, body: Any = None, authenticated: bool = False) -> Any:
179        body_str = serialize_body(body)
180        resp = self._request_with_auth_retry(path, body_str, authenticated, stream=False)
181        raise_for_status(resp.status_code, resp.text, path)
182        return parse_body(resp.text)
183
184    def stream(
185        self, path: str, body: Any = None, authenticated: bool = False
186    ) -> Iterator[bytes]:
187        body_str = serialize_body(body)
188        resp = self._request_with_auth_retry(path, body_str, authenticated, stream=True)
189        if resp.status_code >= 300:
190            text = resp.text  # small error body; safe to read
191            raise_for_status(resp.status_code, text, path)
192
193        def _iter() -> Iterator[bytes]:
194            try:
195                for chunk in resp.iter_content(chunk_size=4096):
196                    if chunk:
197                        yield chunk
198            except requests.RequestException as exc:
199                raise self._wrap_request_exc(exc, path) from None
200            finally:
201                resp.close()
202
203        return _iter()
DEFAULT_TIMEOUT = 10.0
CHALLENGE_RETRIES = 3
CHALLENGE_RETRY_SLEEP = 1.0
class HttpTransport(warpedpinball.transports.Transport):
 41class HttpTransport(Transport):
 42    """Talks to a Vector at ``http://<host>`` and handles HMAC auth signing."""
 43
 44    requires_password = True
 45
 46    def __init__(
 47        self,
 48        host: str,
 49        password: Optional[str] = None,
 50        timeout: float = DEFAULT_TIMEOUT,
 51        session: Optional[requests.Session] = None,
 52    ):
 53        host = host.rstrip("/")
 54        if not host.startswith("http://") and not host.startswith("https://"):
 55            host = "http://" + host
 56        self.base_url = host
 57        #: Bare host[:port] for error messages (no scheme/path noise).
 58        self._host_label = urlsplit(self.base_url).netloc or self.base_url
 59        self.password = password
 60        self.timeout = timeout
 61        if session is None:
 62            session = requests.Session()
 63            if _is_lan_address(urlsplit(self.base_url).hostname or ""):
 64                # Never route LAN traffic through a system/environment proxy.
 65                # Windows machines often have one configured (corporate, VPN,
 66                # WPAD auto-detect), and requests would otherwise send this
 67                # board's traffic to a proxy that cannot reach it.
 68                session.trust_env = False
 69        self._session = session
 70
 71    @property
 72    def description(self) -> str:
 73        return self.base_url
 74
 75    def close(self) -> None:
 76        self._session.close()
 77
 78    # -- internals ---------------------------------------------------------
 79
 80    def _wrap_request_exc(
 81        self, exc: requests.RequestException, path: str
 82    ) -> TransportError:
 83        """Turn a raw requests/urllib3 error into a clean, typed TransportError.
 84
 85        Timeouts and connection failures are the common, user-facing cases and
 86        get their own friendly subclasses; anything else keeps a compact
 87        message. The original exception is preserved on ``.cause`` (and these
 88        are raised ``from None`` so an uncaught error prints one short
 89        traceback, not the full urllib3/requests stack)."""
 90        if isinstance(exc, requests.exceptions.Timeout):
 91            return DeviceTimeoutError(self._host_label, timeout=self.timeout, cause=exc)
 92        if isinstance(exc, requests.exceptions.ConnectionError):
 93            return DeviceUnreachableError(self._host_label, cause=exc)
 94        return TransportError(f"Request to {self._host_label}{path} failed: {exc}")
 95
 96    def _fetch_challenge(self) -> str:
 97        """Fetch a fresh single-use challenge (never cached)."""
 98        for attempt in range(CHALLENGE_RETRIES + 1):
 99            try:
100                resp = self._session.get(
101                    self.base_url + auth.CHALLENGE_PATH, timeout=self.timeout
102                )
103            except requests.RequestException as exc:
104                raise self._wrap_request_exc(exc, auth.CHALLENGE_PATH) from None
105            if resp.status_code == 429:
106                if attempt < CHALLENGE_RETRIES:
107                    time.sleep(CHALLENGE_RETRY_SLEEP)
108                    continue
109                raise RateLimitedError(
110                    "Device has too many outstanding auth challenges; "
111                    "retry after a short wait"
112                )
113            raise_for_status(resp.status_code, resp.text, auth.CHALLENGE_PATH)
114            data = resp.json()
115            return data["challenge"]
116        # Defensive: the loop above always returns or raises within the retry
117        # budget, so this is unreachable in practice.
118        raise RateLimitedError(  # pragma: no cover
119            "Device has too many outstanding auth challenges"
120        )
121
122    def _send(
123        self,
124        path: str,
125        body_str: Optional[str],
126        authenticated: bool,
127        stream: bool = False,
128    ) -> requests.Response:
129        headers = {}
130        if body_str is not None:
131            headers["Content-Type"] = "application/json"
132        if authenticated:
133            # An empty string is a valid (empty) password and is signed as
134            # such; only ``None`` means no password has been set.
135            if self.password is None:
136                raise AuthenticationRequiredError(
137                    f"Route {path!r} requires authentication but no password is set"
138                )
139            headers.update(
140                auth.auth_headers(self.password, self._fetch_challenge(), path, body_str or "")
141            )
142
143        method = "GET" if body_str is None else "POST"
144        url = self.base_url + path
145        retries = 1 if (method == "GET" and not authenticated) else 0
146        while True:
147            try:
148                return self._session.request(
149                    method,
150                    url,
151                    data=body_str.encode("utf-8") if body_str is not None else None,
152                    headers=headers,
153                    timeout=self.timeout,
154                    stream=stream,
155                )
156            except requests.RequestException as exc:
157                if retries > 0:
158                    retries -= 1
159                    continue
160                raise self._wrap_request_exc(exc, path) from None
161
162    def _request_with_auth_retry(
163        self, path: str, body_str: Optional[str], authenticated: bool, stream: bool
164    ) -> requests.Response:
165        resp = self._send(path, body_str, authenticated, stream=stream)
166        if authenticated and resp.status_code == 401:
167            # Challenges are single-use and expire after 60 s; a stale/consumed
168            # challenge earns exactly one retry. "Bad Credentials" never does.
169            try:
170                raise_for_status(resp.status_code, resp.text, path)
171            except AuthenticationError as exc:
172                if not auth.is_retryable_auth_failure(exc.reason):
173                    raise
174            resp = self._send(path, body_str, authenticated, stream=stream)
175        return resp
176
177    # -- Transport interface -------------------------------------------------
178
179    def request(self, path: str, body: Any = None, authenticated: bool = False) -> Any:
180        body_str = serialize_body(body)
181        resp = self._request_with_auth_retry(path, body_str, authenticated, stream=False)
182        raise_for_status(resp.status_code, resp.text, path)
183        return parse_body(resp.text)
184
185    def stream(
186        self, path: str, body: Any = None, authenticated: bool = False
187    ) -> Iterator[bytes]:
188        body_str = serialize_body(body)
189        resp = self._request_with_auth_retry(path, body_str, authenticated, stream=True)
190        if resp.status_code >= 300:
191            text = resp.text  # small error body; safe to read
192            raise_for_status(resp.status_code, text, path)
193
194        def _iter() -> Iterator[bytes]:
195            try:
196                for chunk in resp.iter_content(chunk_size=4096):
197                    if chunk:
198                        yield chunk
199            except requests.RequestException as exc:
200                raise self._wrap_request_exc(exc, path) from None
201            finally:
202                resp.close()
203
204        return _iter()

Talks to a Vector at http://<host> and handles HMAC auth signing.

HttpTransport( host: str, password: Optional[str] = None, timeout: float = 10.0, session: Optional[requests.sessions.Session] = None)
46    def __init__(
47        self,
48        host: str,
49        password: Optional[str] = None,
50        timeout: float = DEFAULT_TIMEOUT,
51        session: Optional[requests.Session] = None,
52    ):
53        host = host.rstrip("/")
54        if not host.startswith("http://") and not host.startswith("https://"):
55            host = "http://" + host
56        self.base_url = host
57        #: Bare host[:port] for error messages (no scheme/path noise).
58        self._host_label = urlsplit(self.base_url).netloc or self.base_url
59        self.password = password
60        self.timeout = timeout
61        if session is None:
62            session = requests.Session()
63            if _is_lan_address(urlsplit(self.base_url).hostname or ""):
64                # Never route LAN traffic through a system/environment proxy.
65                # Windows machines often have one configured (corporate, VPN,
66                # WPAD auto-detect), and requests would otherwise send this
67                # board's traffic to a proxy that cannot reach it.
68                session.trust_env = False
69        self._session = session
requires_password = True
base_url
password = None
timeout
description: str
71    @property
72    def description(self) -> str:
73        return self.base_url

Human-readable target, e.g. http://192.168.1.42 or /dev/ttyACM0.

def close(self) -> None:
75    def close(self) -> None:
76        self._session.close()

Release sockets / serial ports.

def request(self, path: str, body: Any = None, authenticated: bool = False) -> Any:
179    def request(self, path: str, body: Any = None, authenticated: bool = False) -> Any:
180        body_str = serialize_body(body)
181        resp = self._request_with_auth_retry(path, body_str, authenticated, stream=False)
182        raise_for_status(resp.status_code, resp.text, path)
183        return parse_body(resp.text)

Perform one request; return the parsed response body.

Raises a typed exception from warpedpinball.exceptions on error.

def stream( self, path: str, body: Any = None, authenticated: bool = False) -> Iterator[bytes]:
185    def stream(
186        self, path: str, body: Any = None, authenticated: bool = False
187    ) -> Iterator[bytes]:
188        body_str = serialize_body(body)
189        resp = self._request_with_auth_retry(path, body_str, authenticated, stream=True)
190        if resp.status_code >= 300:
191            text = resp.text  # small error body; safe to read
192            raise_for_status(resp.status_code, text, path)
193
194        def _iter() -> Iterator[bytes]:
195            try:
196                for chunk in resp.iter_content(chunk_size=4096):
197                    if chunk:
198                        yield chunk
199            except requests.RequestException as exc:
200                raise self._wrap_request_exc(exc, path) from None
201            finally:
202                resp.close()
203
204        return _iter()

Perform a streaming request; yield raw body chunks as bytes.