warpedpinball.exceptions
Typed exceptions for the Warped Pinball Vector client.
1"""Typed exceptions for the Warped Pinball Vector client.""" 2 3from __future__ import annotations 4 5from typing import List, Optional 6 7 8class VectorError(Exception): 9 """Base class for all errors raised by this library.""" 10 11 12class TransportError(VectorError): 13 """A connection, timeout, or protocol-level failure talking to the device.""" 14 15 16class DeviceUnreachableError(TransportError): 17 """Could not open a connection to the device. 18 19 The address refused the connection, could not be resolved, or is not on 20 the network at all (device powered off, wrong IP, different subnet). A 21 subclass of :class:`TransportError`, so ``except TransportError`` still 22 catches it. ``target`` is the host that was tried; ``cause`` is the 23 underlying networking exception, if you want the gory details. 24 """ 25 26 def __init__( 27 self, 28 target: str, 29 detail: Optional[str] = None, 30 cause: Optional[BaseException] = None, 31 ): 32 self.target = target 33 self.cause = cause 34 message = ( 35 f"Could not reach the machine at {target} " 36 f"(is it powered on and connected to the network?)" 37 ) 38 if detail: 39 message = f"{message}: {detail}" 40 super().__init__(message) 41 42 43class DeviceTimeoutError(TransportError): 44 """The device accepted the connection but did not answer in time. 45 46 Usually the board is busy (mid-game, applying an update) or the link is 47 flaky. A subclass of :class:`TransportError`. ``target`` is the host, 48 ``timeout`` the seconds waited, and ``cause`` the underlying exception. 49 """ 50 51 def __init__( 52 self, 53 target: str, 54 timeout: Optional[float] = None, 55 cause: Optional[BaseException] = None, 56 ): 57 self.target = target 58 self.timeout = timeout 59 self.cause = cause 60 if timeout is not None: 61 message = ( 62 f"The machine at {target} did not respond within {timeout:g}s " 63 f"(it may be busy or the connection is unstable)" 64 ) 65 else: 66 message = f"The machine at {target} did not respond in time" 67 super().__init__(message) 68 69 70class MachineNotFoundError(VectorError): 71 """No machine matching the requested name was found during discovery. 72 73 ``seen_names`` lists the machine names that *were* discovered, so callers 74 (and error messages) can show what is actually on the network. 75 """ 76 77 def __init__(self, name: str, seen_names: Optional[List[str]] = None): 78 self.name = name 79 self.seen_names = seen_names or [] 80 seen = ", ".join(self.seen_names) if self.seen_names else "none" 81 super().__init__( 82 f"No machine named {name!r} found on the network (machines seen: {seen})" 83 ) 84 85 86class AmbiguousMachineError(VectorError): 87 """More than one discovered machine matched the requested name.""" 88 89 def __init__(self, name: str, candidates: List[str]): 90 self.name = name 91 self.candidates = candidates 92 super().__init__( 93 f"Machine name {name!r} is ambiguous; candidates: {', '.join(candidates)}" 94 ) 95 96 97class AuthenticationRequiredError(VectorError): 98 """An authenticated route was called with no password configured. 99 100 Raised before any network traffic. Set ``machine.password``, pass 101 ``password=`` to ``connect()``, or set the ``VECTOR_PASSWORD`` env var. 102 """ 103 104 105class AuthenticationError(VectorError): 106 """The device rejected the request's credentials (HTTP 401). 107 108 ``reason`` carries the device's ``{"error": ...}`` detail, e.g. 109 ``"Bad Credentials"`` or ``"Challenge expired"``. 110 """ 111 112 def __init__(self, reason: str = "authentication failed"): 113 self.reason = reason 114 super().__init__(reason) 115 116 117class RateLimitedError(VectorError): 118 """The device returned 429 while fetching an auth challenge. 119 120 The device holds at most ~10 outstanding challenges; expired ones are 121 purged on each challenge request, so retrying after a short sleep is safe. 122 """ 123 124 125class CooldownError(VectorError): 126 """The route is locked or in cooldown (HTTP 409 "Already running" / 429). 127 128 ``retry_after`` is a best-effort hint in seconds based on the route's 129 documented server-side cooldown (``/api/logs`` 10 s, ``/api/update/check`` 130 10 s, ``/api/adjustments/restore`` 5 s), or ``None`` when unknown. 131 """ 132 133 def __init__(self, message: str, retry_after: Optional[float] = None): 134 self.retry_after = retry_after 135 if retry_after is not None: 136 message = f"{message} (retry after ~{retry_after:g}s)" 137 super().__init__(message) 138 139 140class VectorServerError(VectorError): 141 """The device handler raised an error (HTTP 5xx). Body carries the detail.""" 142 143 def __init__(self, message: str, status: int = 500): 144 self.status = status 145 super().__init__(message) 146 147 148class UnsupportedFirmwareError(VectorError): 149 """The route does not exist on this firmware version (HTTP 404).""" 150 151 def __init__(self, path: str, firmware_version: Optional[str] = None): 152 self.path = path 153 self.firmware_version = firmware_version 154 detail = f" (device firmware: {firmware_version})" if firmware_version else "" 155 super().__init__( 156 f"Route {path!r} is not supported by this firmware{detail}; " 157 "a firmware update may be required" 158 )
Base class for all errors raised by this library.
13class TransportError(VectorError): 14 """A connection, timeout, or protocol-level failure talking to the device."""
A connection, timeout, or protocol-level failure talking to the device.
17class DeviceUnreachableError(TransportError): 18 """Could not open a connection to the device. 19 20 The address refused the connection, could not be resolved, or is not on 21 the network at all (device powered off, wrong IP, different subnet). A 22 subclass of :class:`TransportError`, so ``except TransportError`` still 23 catches it. ``target`` is the host that was tried; ``cause`` is the 24 underlying networking exception, if you want the gory details. 25 """ 26 27 def __init__( 28 self, 29 target: str, 30 detail: Optional[str] = None, 31 cause: Optional[BaseException] = None, 32 ): 33 self.target = target 34 self.cause = cause 35 message = ( 36 f"Could not reach the machine at {target} " 37 f"(is it powered on and connected to the network?)" 38 ) 39 if detail: 40 message = f"{message}: {detail}" 41 super().__init__(message)
Could not open a connection to the device.
The address refused the connection, could not be resolved, or is not on
the network at all (device powered off, wrong IP, different subnet). A
subclass of TransportError, so except TransportError still
catches it. target is the host that was tried; cause is the
underlying networking exception, if you want the gory details.
27 def __init__( 28 self, 29 target: str, 30 detail: Optional[str] = None, 31 cause: Optional[BaseException] = None, 32 ): 33 self.target = target 34 self.cause = cause 35 message = ( 36 f"Could not reach the machine at {target} " 37 f"(is it powered on and connected to the network?)" 38 ) 39 if detail: 40 message = f"{message}: {detail}" 41 super().__init__(message)
44class DeviceTimeoutError(TransportError): 45 """The device accepted the connection but did not answer in time. 46 47 Usually the board is busy (mid-game, applying an update) or the link is 48 flaky. A subclass of :class:`TransportError`. ``target`` is the host, 49 ``timeout`` the seconds waited, and ``cause`` the underlying exception. 50 """ 51 52 def __init__( 53 self, 54 target: str, 55 timeout: Optional[float] = None, 56 cause: Optional[BaseException] = None, 57 ): 58 self.target = target 59 self.timeout = timeout 60 self.cause = cause 61 if timeout is not None: 62 message = ( 63 f"The machine at {target} did not respond within {timeout:g}s " 64 f"(it may be busy or the connection is unstable)" 65 ) 66 else: 67 message = f"The machine at {target} did not respond in time" 68 super().__init__(message)
The device accepted the connection but did not answer in time.
Usually the board is busy (mid-game, applying an update) or the link is
flaky. A subclass of TransportError. target is the host,
timeout the seconds waited, and cause the underlying exception.
52 def __init__( 53 self, 54 target: str, 55 timeout: Optional[float] = None, 56 cause: Optional[BaseException] = None, 57 ): 58 self.target = target 59 self.timeout = timeout 60 self.cause = cause 61 if timeout is not None: 62 message = ( 63 f"The machine at {target} did not respond within {timeout:g}s " 64 f"(it may be busy or the connection is unstable)" 65 ) 66 else: 67 message = f"The machine at {target} did not respond in time" 68 super().__init__(message)
71class MachineNotFoundError(VectorError): 72 """No machine matching the requested name was found during discovery. 73 74 ``seen_names`` lists the machine names that *were* discovered, so callers 75 (and error messages) can show what is actually on the network. 76 """ 77 78 def __init__(self, name: str, seen_names: Optional[List[str]] = None): 79 self.name = name 80 self.seen_names = seen_names or [] 81 seen = ", ".join(self.seen_names) if self.seen_names else "none" 82 super().__init__( 83 f"No machine named {name!r} found on the network (machines seen: {seen})" 84 )
No machine matching the requested name was found during discovery.
seen_names lists the machine names that were discovered, so callers
(and error messages) can show what is actually on the network.
78 def __init__(self, name: str, seen_names: Optional[List[str]] = None): 79 self.name = name 80 self.seen_names = seen_names or [] 81 seen = ", ".join(self.seen_names) if self.seen_names else "none" 82 super().__init__( 83 f"No machine named {name!r} found on the network (machines seen: {seen})" 84 )
87class AmbiguousMachineError(VectorError): 88 """More than one discovered machine matched the requested name.""" 89 90 def __init__(self, name: str, candidates: List[str]): 91 self.name = name 92 self.candidates = candidates 93 super().__init__( 94 f"Machine name {name!r} is ambiguous; candidates: {', '.join(candidates)}" 95 )
More than one discovered machine matched the requested name.
98class AuthenticationRequiredError(VectorError): 99 """An authenticated route was called with no password configured. 100 101 Raised before any network traffic. Set ``machine.password``, pass 102 ``password=`` to ``connect()``, or set the ``VECTOR_PASSWORD`` env var. 103 """
An authenticated route was called with no password configured.
Raised before any network traffic. Set machine.password, pass
password= to connect(), or set the VECTOR_PASSWORD env var.
106class AuthenticationError(VectorError): 107 """The device rejected the request's credentials (HTTP 401). 108 109 ``reason`` carries the device's ``{"error": ...}`` detail, e.g. 110 ``"Bad Credentials"`` or ``"Challenge expired"``. 111 """ 112 113 def __init__(self, reason: str = "authentication failed"): 114 self.reason = reason 115 super().__init__(reason)
The device rejected the request's credentials (HTTP 401).
reason carries the device's {"error": ...} detail, e.g.
"Bad Credentials" or "Challenge expired".
118class RateLimitedError(VectorError): 119 """The device returned 429 while fetching an auth challenge. 120 121 The device holds at most ~10 outstanding challenges; expired ones are 122 purged on each challenge request, so retrying after a short sleep is safe. 123 """
The device returned 429 while fetching an auth challenge.
The device holds at most ~10 outstanding challenges; expired ones are purged on each challenge request, so retrying after a short sleep is safe.
126class CooldownError(VectorError): 127 """The route is locked or in cooldown (HTTP 409 "Already running" / 429). 128 129 ``retry_after`` is a best-effort hint in seconds based on the route's 130 documented server-side cooldown (``/api/logs`` 10 s, ``/api/update/check`` 131 10 s, ``/api/adjustments/restore`` 5 s), or ``None`` when unknown. 132 """ 133 134 def __init__(self, message: str, retry_after: Optional[float] = None): 135 self.retry_after = retry_after 136 if retry_after is not None: 137 message = f"{message} (retry after ~{retry_after:g}s)" 138 super().__init__(message)
The route is locked or in cooldown (HTTP 409 "Already running" / 429).
retry_after is a best-effort hint in seconds based on the route's
documented server-side cooldown (/api/logs 10 s, /api/update/check
10 s, /api/adjustments/restore 5 s), or None when unknown.
141class VectorServerError(VectorError): 142 """The device handler raised an error (HTTP 5xx). Body carries the detail.""" 143 144 def __init__(self, message: str, status: int = 500): 145 self.status = status 146 super().__init__(message)
The device handler raised an error (HTTP 5xx). Body carries the detail.
149class UnsupportedFirmwareError(VectorError): 150 """The route does not exist on this firmware version (HTTP 404).""" 151 152 def __init__(self, path: str, firmware_version: Optional[str] = None): 153 self.path = path 154 self.firmware_version = firmware_version 155 detail = f" (device firmware: {firmware_version})" if firmware_version else "" 156 super().__init__( 157 f"Route {path!r} is not supported by this firmware{detail}; " 158 "a firmware update may be required" 159 )
The route does not exist on this firmware version (HTTP 404).
152 def __init__(self, path: str, firmware_version: Optional[str] = None): 153 self.path = path 154 self.firmware_version = firmware_version 155 detail = f" (device firmware: {firmware_version})" if firmware_version else "" 156 super().__init__( 157 f"Route {path!r} is not supported by this firmware{detail}; " 158 "a firmware update may be required" 159 )