Initial commit
This commit is contained in:
commit
d71d560d29
8 changed files with 1325 additions and 0 deletions
89
odms/errors.py
Normal file
89
odms/errors.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Exceptions raised by the ODMS client.
|
||||
|
||||
The split is by *what the caller should do*, not by HTTP status. Everything
|
||||
inherits :class:`OdmsError`, so a service that only wants "ODMS did not answer"
|
||||
can catch that one and be done.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class OdmsError(Exception):
|
||||
"""Base class for every failure this client raises."""
|
||||
|
||||
|
||||
class TransportError(OdmsError):
|
||||
"""The request never got an answer: DNS, TCP, TLS, or a read timeout.
|
||||
|
||||
Distinguished from an HTTP error because a request that never arrived can be
|
||||
retried safely even when it is not idempotent -- and one that may have
|
||||
arrived cannot. The client only auto-retries these for reads.
|
||||
"""
|
||||
|
||||
|
||||
class HTTPError(OdmsError):
|
||||
"""ODMS answered with a status the caller did not ask for."""
|
||||
|
||||
def __init__(self, status: int, url: str, body: str = "", detail: str = ""):
|
||||
self.status = status
|
||||
self.url = url
|
||||
self.body = body
|
||||
self.detail = detail or _detail_from(body) or body[:200]
|
||||
super().__init__(f"HTTP {status} from {url}" + (f": {self.detail}" if self.detail else ""))
|
||||
|
||||
|
||||
class NotFound(HTTPError):
|
||||
"""No such satellite, run or message.
|
||||
|
||||
ODMS returns this for a satellite that exists but is not public, too -- an
|
||||
anonymous caller cannot tell the two apart, which is the intent. If you
|
||||
expected a private object, check the token.
|
||||
"""
|
||||
|
||||
|
||||
class Unauthorized(HTTPError):
|
||||
"""The endpoint needs a token, and the one presented was missing or invalid.
|
||||
|
||||
ODMS's read surface is anonymous; only the expensive and the writing
|
||||
endpoints (OEM generation, message push, transform, TLE fitting, bulk
|
||||
propagation) are gated.
|
||||
"""
|
||||
|
||||
|
||||
class RateLimited(HTTPError):
|
||||
"""The per-IP hourly budget is spent.
|
||||
|
||||
``retry_after`` is the server's own advice in seconds when it gave one. The
|
||||
client retries these automatically up to ``max_retries``; seeing this
|
||||
exception means the budget is still spent after those attempts, so the fix
|
||||
is to slow down or hold a token, not to retry harder.
|
||||
"""
|
||||
|
||||
def __init__(self, status: int, url: str, body: str = "",
|
||||
detail: str = "", retry_after: float | None = None):
|
||||
self.retry_after = retry_after
|
||||
super().__init__(status, url, body, detail)
|
||||
|
||||
|
||||
class ServiceUnavailable(HTTPError):
|
||||
"""ODMS (or something it depends on) is busy or down.
|
||||
|
||||
Retried automatically. A persistent one usually means the propagation
|
||||
sidecar behind ODMS is saturated rather than ODMS itself being unwell.
|
||||
"""
|
||||
|
||||
|
||||
def _detail_from(body: str) -> str:
|
||||
"""The error message out of a JSON body, if that is what this is."""
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
if isinstance(parsed, dict):
|
||||
for key in ("detail", "error", "message"):
|
||||
value = parsed.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue