ralio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ralio/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """Official Python SDK for the Ralio agentic payment API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from .client import RalioClient
8
+ from .errors import (
9
+ RalioAPIError,
10
+ RalioAuthError,
11
+ RalioConfigError,
12
+ RalioError,
13
+ RalioNotFoundError,
14
+ RalioPermissionError,
15
+ RalioRateLimitError,
16
+ RalioRegistrationError,
17
+ RalioValidationError,
18
+ )
19
+ from .registration import register
20
+ from .types import (
21
+ Agent,
22
+ ChatReply,
23
+ ChatStreamEvent,
24
+ CredentialBinding,
25
+ Message,
26
+ Page,
27
+ PaymentInstruction,
28
+ PaymentIntent,
29
+ Transaction,
30
+ )
31
+
32
+ # Single source of truth is the installed package metadata (pyproject.toml).
33
+ try:
34
+ __version__ = version("ralio")
35
+ except PackageNotFoundError: # running from a source tree without an install
36
+ __version__ = "0.0.0+unknown"
37
+
38
+ __all__ = [
39
+ "RalioClient",
40
+ "register",
41
+ "Agent",
42
+ "ChatReply",
43
+ "ChatStreamEvent",
44
+ "CredentialBinding",
45
+ "Message",
46
+ "Page",
47
+ "PaymentIntent",
48
+ "PaymentInstruction",
49
+ "Transaction",
50
+ "RalioError",
51
+ "RalioConfigError",
52
+ "RalioRegistrationError",
53
+ "RalioAPIError",
54
+ "RalioAuthError",
55
+ "RalioPermissionError",
56
+ "RalioNotFoundError",
57
+ "RalioValidationError",
58
+ "RalioRateLimitError",
59
+ ]
ralio/_crypto.py ADDED
@@ -0,0 +1,161 @@
1
+ """ES256 / DPoP crypto primitives for the Ralio machine-auth path.
2
+
3
+ Everything here mirrors what the Ralio API expects byte-for-byte:
4
+
5
+ - P-256 (ES256) is the only curve the token endpoint accepts.
6
+ - The public JWK is the canonical RFC 7638 form (``crv``/``kty``/``x``/``y``
7
+ only, sorted), so the thumbprint computed here matches the ``cnf.jkt`` the
8
+ server stamps on the access token and the fingerprint the owner confirms.
9
+ - Client assertions follow RFC 7521/7523; DPoP proofs follow RFC 9449.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import contextlib
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import secrets
20
+ import tempfile
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import jwt as pyjwt
26
+ from cryptography.hazmat.primitives import serialization
27
+ from cryptography.hazmat.primitives.asymmetric import ec
28
+ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
29
+
30
+ CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
31
+
32
+ # The server rejects assertions older than 300s; stay well under to absorb skew.
33
+ _CLIENT_ASSERTION_TTL_SECONDS = 60
34
+ _SECRET_FILE_MODE = 0o600
35
+
36
+
37
+ def b64url(raw: bytes) -> str:
38
+ """Unpadded base64url — the only form RFC 7515/7517/7638 accept."""
39
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
40
+
41
+
42
+ def generate_keypair() -> tuple[EllipticCurvePrivateKey, dict[str, str]]:
43
+ """Mint a P-256 keypair and return ``(private_key, canonical_public_jwk)``."""
44
+ private_key = ec.generate_private_key(ec.SECP256R1())
45
+ return private_key, public_jwk(private_key)
46
+
47
+
48
+ def public_jwk(private_key: EllipticCurvePrivateKey) -> dict[str, str]:
49
+ """Return the canonical RFC 7638 public JWK for *private_key*."""
50
+ numbers = private_key.public_key().public_numbers()
51
+ return {
52
+ "crv": "P-256",
53
+ "kty": "EC",
54
+ "x": b64url(numbers.x.to_bytes(32, "big")),
55
+ "y": b64url(numbers.y.to_bytes(32, "big")),
56
+ }
57
+
58
+
59
+ def jwk_thumbprint(canonical_jwk: dict[str, str]) -> str:
60
+ """RFC 7638 thumbprint of a canonical JWK — used as ``kid`` and key id."""
61
+ canonical_json = json.dumps(canonical_jwk, separators=(",", ":"), sort_keys=True)
62
+ return b64url(hashlib.sha256(canonical_json.encode("ascii")).digest())
63
+
64
+
65
+ def save_private_key(path: str | Path, private_key: EllipticCurvePrivateKey) -> Path:
66
+ """Write *private_key* as PKCS8 PEM at *path*, mode 0600, atomically."""
67
+ dest = Path(path)
68
+ dest.parent.mkdir(parents=True, exist_ok=True)
69
+ pem = private_key.private_bytes(
70
+ encoding=serialization.Encoding.PEM,
71
+ format=serialization.PrivateFormat.PKCS8,
72
+ encryption_algorithm=serialization.NoEncryption(),
73
+ )
74
+ tmp_fd, tmp_path = tempfile.mkstemp(dir=dest.parent, prefix=".tmp-")
75
+ try:
76
+ with contextlib.suppress(OSError, AttributeError):
77
+ # fchmod unavailable on Windows; set perms before any bytes land.
78
+ os.fchmod(tmp_fd, _SECRET_FILE_MODE)
79
+ with os.fdopen(tmp_fd, "wb") as f:
80
+ tmp_fd = -1
81
+ f.write(pem)
82
+ os.replace(tmp_path, dest)
83
+ except BaseException:
84
+ if tmp_fd >= 0:
85
+ os.close(tmp_fd)
86
+ with contextlib.suppress(OSError):
87
+ os.unlink(tmp_path)
88
+ raise
89
+ return dest
90
+
91
+
92
+ def load_private_key(path: str | Path) -> EllipticCurvePrivateKey:
93
+ """Load a PKCS8 PEM P-256 private key from *path*."""
94
+ key = serialization.load_pem_private_key(Path(path).read_bytes(), password=None)
95
+ if not isinstance(key, EllipticCurvePrivateKey):
96
+ raise ValueError("Ralio credentials require a P-256 (EC) private key")
97
+ return key
98
+
99
+
100
+ def sign_client_assertion(
101
+ private_key: EllipticCurvePrivateKey,
102
+ *,
103
+ client_id: str,
104
+ audience: str,
105
+ kid: str,
106
+ ttl_seconds: int = _CLIENT_ASSERTION_TTL_SECONDS,
107
+ ) -> str:
108
+ """Sign an RFC 7523 JWT bearer client assertion.
109
+
110
+ ``iss`` and ``sub`` both equal *client_id*; ``aud`` is the absolute token
111
+ endpoint URL. ``kid`` is the JWK thumbprint so the server can locate the
112
+ binding directly.
113
+ """
114
+ iat = int(time.time())
115
+ payload = {
116
+ "iss": client_id,
117
+ "sub": client_id,
118
+ "aud": audience,
119
+ "iat": iat,
120
+ "exp": iat + ttl_seconds,
121
+ "jti": secrets.token_urlsafe(16),
122
+ }
123
+ return pyjwt.encode(payload, _pem(private_key), algorithm="ES256", headers={"kid": kid})
124
+
125
+
126
+ def sign_dpop_proof(
127
+ private_key: EllipticCurvePrivateKey,
128
+ *,
129
+ method: str,
130
+ url: str,
131
+ access_token: str,
132
+ jwk: dict[str, str],
133
+ ) -> str:
134
+ """Sign a single-use DPoP proof (RFC 9449) for one method + URL + token.
135
+
136
+ *url* must already have its query and fragment stripped (``htu`` per
137
+ RFC 9449 §4.2). The embedded ``jwk`` must be the canonical public JWK so
138
+ its thumbprint matches the access token's ``cnf.jkt``.
139
+ """
140
+ iat = int(time.time())
141
+ payload: dict[str, Any] = {
142
+ "htm": method.upper(),
143
+ "htu": url,
144
+ "iat": iat,
145
+ "jti": secrets.token_urlsafe(16),
146
+ "ath": b64url(hashlib.sha256(access_token.encode("ascii")).digest()),
147
+ }
148
+ return pyjwt.encode(
149
+ payload,
150
+ _pem(private_key),
151
+ algorithm="ES256",
152
+ headers={"typ": "dpop+jwt", "alg": "ES256", "jwk": dict(jwk)},
153
+ )
154
+
155
+
156
+ def _pem(private_key: EllipticCurvePrivateKey) -> bytes:
157
+ return private_key.private_bytes(
158
+ encoding=serialization.Encoding.PEM,
159
+ format=serialization.PrivateFormat.PKCS8,
160
+ encryption_algorithm=serialization.NoEncryption(),
161
+ )
ralio/_store.py ADDED
@@ -0,0 +1,105 @@
1
+ """On-disk credential store, shared with the Ralio CLI.
2
+
3
+ The layout mirrors the CLI byte-for-byte so :func:`ralio.register` and
4
+ ``ralio auth agent`` are interchangeable — either one can write the
5
+ credentials and the other (or :class:`ralio.RalioClient`) can consume them:
6
+
7
+ - ``~/.ralio/credentials.json`` (0600) — ``client_id``, ``key_jkt``, tokens.
8
+ - ``~/.ralio/keys/<jkt>.pem`` (0600) — the P-256 private key, PKCS8 PEM,
9
+ named by its RFC 7638 thumbprint.
10
+
11
+ ``RALIO_CONFIG_DIR`` overrides ``~/.ralio`` (tests, multi-tenant hosts).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import json
18
+ import os
19
+ import tempfile
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ DEFAULT_BASE_URL = "https://api.ralio.co"
24
+
25
+ _DIR_MODE = 0o700
26
+ _SECRET_FILE_MODE = 0o600
27
+
28
+
29
+ def resolve_base_url(explicit: str | None = None) -> str:
30
+ """Explicit value, else ``RALIO_API_URL``, else production. No trailing slash."""
31
+ return (explicit or _env("RALIO_API_URL") or DEFAULT_BASE_URL).rstrip("/")
32
+
33
+
34
+ def config_dir() -> Path:
35
+ return Path(_env("RALIO_CONFIG_DIR") or Path.home() / ".ralio")
36
+
37
+
38
+ def credentials_path() -> Path:
39
+ return config_dir() / "credentials.json"
40
+
41
+
42
+ def key_path_for(jkt: str) -> Path:
43
+ """Default on-disk location for the key whose RFC 7638 thumbprint is *jkt*."""
44
+ return config_dir() / "keys" / f"{jkt}.pem"
45
+
46
+
47
+ def save_credentials(creds: dict[str, Any]) -> None:
48
+ """Persist *creds* at the CLI-compatible credentials path, mode 0600, atomically."""
49
+ _ensure_secret_dir(config_dir())
50
+ _write_secret_file(credentials_path(), json.dumps(creds, indent=2) + "\n")
51
+
52
+
53
+ def load_credentials() -> dict[str, Any] | None:
54
+ """Load persisted credentials, or ``None`` when absent or unreadable."""
55
+ try:
56
+ raw = credentials_path().read_text()
57
+ except OSError:
58
+ return None
59
+ try:
60
+ parsed = json.loads(raw)
61
+ except ValueError:
62
+ return None
63
+ return parsed if isinstance(parsed, dict) else None
64
+
65
+
66
+ def ensure_keys_dir() -> None:
67
+ """Create the keys directory (and config dir) with owner-only permissions."""
68
+ _ensure_secret_dir(config_dir())
69
+ _ensure_secret_dir(config_dir() / "keys")
70
+
71
+
72
+ def delete_private_key(path: str | Path) -> None:
73
+ """Remove a private key file, if present. Idempotent."""
74
+ Path(path).unlink(missing_ok=True)
75
+
76
+
77
+ def _env(name: str) -> str | None:
78
+ """Env var value, with the empty string treated as unset."""
79
+ return os.environ.get(name) or None
80
+
81
+
82
+ def _ensure_secret_dir(directory: Path) -> None:
83
+ directory.mkdir(parents=True, exist_ok=True)
84
+ # mkdir's mode is masked by the umask and skipped for pre-existing dirs.
85
+ with contextlib.suppress(OSError):
86
+ directory.chmod(_DIR_MODE)
87
+
88
+
89
+ def _write_secret_file(path: Path, data: str) -> None:
90
+ """Atomic secret write: temp file chmodded 0600 before any bytes land."""
91
+ tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, prefix=".tmp-")
92
+ try:
93
+ with contextlib.suppress(OSError, AttributeError):
94
+ # fchmod unavailable on Windows.
95
+ os.fchmod(tmp_fd, _SECRET_FILE_MODE)
96
+ with os.fdopen(tmp_fd, "w") as f:
97
+ tmp_fd = -1
98
+ f.write(data)
99
+ os.replace(tmp_path, path)
100
+ except BaseException:
101
+ if tmp_fd >= 0:
102
+ os.close(tmp_fd)
103
+ with contextlib.suppress(OSError):
104
+ os.unlink(tmp_path)
105
+ raise
ralio/auth.py ADDED
@@ -0,0 +1,111 @@
1
+ """Access-token lifecycle for the machine path.
2
+
3
+ A :class:`TokenManager` mints tokens via the ``private_key_jwt`` client
4
+ assertion, caches the access token until shortly before expiry, and rotates
5
+ the refresh token. All token mutations are serialised behind a lock:
6
+
7
+ > Presenting a previously-rotated refresh token is treated as a replay attack
8
+ > and revokes the whole chain.
9
+
10
+ so two threads must never race a refresh.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ import time
17
+
18
+ import httpx
19
+ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
20
+
21
+ from . import _crypto
22
+ from .errors import raise_for_response
23
+
24
+
25
+ class TokenManager:
26
+ def __init__(
27
+ self,
28
+ *,
29
+ client_id: str,
30
+ private_key: EllipticCurvePrivateKey,
31
+ kid: str,
32
+ token_url: str,
33
+ http: httpx.Client,
34
+ scopes: tuple[str, ...] | None = None,
35
+ refresh_leeway_seconds: float = 300.0,
36
+ ) -> None:
37
+ self._client_id = client_id
38
+ self._private_key = private_key
39
+ self._kid = kid
40
+ self._token_url = token_url
41
+ self._http = http
42
+ self._scopes = scopes
43
+ self._leeway = refresh_leeway_seconds
44
+
45
+ self._lock = threading.Lock()
46
+ self._access_token: str | None = None
47
+ self._refresh_token: str | None = None
48
+ self._expires_at = 0.0
49
+
50
+ def access_token(self) -> str:
51
+ """Return a valid access token, minting or refreshing as needed."""
52
+ with self._lock:
53
+ if self._access_token and time.time() < self._expires_at - self._leeway:
54
+ return self._access_token
55
+ return self._obtain_locked()
56
+
57
+ def force_refresh(self) -> str:
58
+ """Discard the cached token and obtain a fresh one. Used after a 401."""
59
+ with self._lock:
60
+ self._access_token = None
61
+ return self._obtain_locked()
62
+
63
+ def _obtain_locked(self) -> str:
64
+ if self._refresh_token:
65
+ try:
66
+ return self._refresh_locked()
67
+ except Exception:
68
+ # Refresh chains can be revoked or expired; fall back to a
69
+ # fresh client-assertion mint, which always works while the
70
+ # binding is active.
71
+ self._refresh_token = None
72
+ return self._mint_locked()
73
+
74
+ def _mint_locked(self) -> str:
75
+ assertion = _crypto.sign_client_assertion(
76
+ self._private_key,
77
+ client_id=self._client_id,
78
+ audience=self._token_url,
79
+ kid=self._kid,
80
+ )
81
+ data = {
82
+ "grant_type": "client_credentials",
83
+ "client_assertion_type": _crypto.CLIENT_ASSERTION_TYPE,
84
+ "client_assertion": assertion,
85
+ }
86
+ if self._scopes:
87
+ data["scope"] = " ".join(self._scopes)
88
+ return self._exchange_locked(data)
89
+
90
+ def _refresh_locked(self) -> str:
91
+ assert self._refresh_token is not None
92
+ return self._exchange_locked(
93
+ {
94
+ "grant_type": "refresh_token",
95
+ "refresh_token": self._refresh_token,
96
+ "client_id": self._client_id,
97
+ }
98
+ )
99
+
100
+ def _exchange_locked(self, data: dict[str, str]) -> str:
101
+ response = self._http.post(
102
+ self._token_url,
103
+ data=data,
104
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
105
+ )
106
+ raise_for_response(response)
107
+ body = response.json()
108
+ self._access_token = body["access_token"]
109
+ self._refresh_token = body.get("refresh_token") or self._refresh_token
110
+ self._expires_at = time.time() + float(body.get("expires_in", 1800))
111
+ return self._access_token
ralio/client.py ADDED
@@ -0,0 +1,131 @@
1
+ """The top-level :class:`RalioClient`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from types import TracebackType
7
+
8
+ import httpx
9
+ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
10
+
11
+ from . import _crypto, _store
12
+ from .auth import TokenManager
13
+ from .errors import RalioConfigError
14
+ from .resources import (
15
+ AgentsResource,
16
+ ChatResource,
17
+ PaymentIntentsResource,
18
+ TransactionsResource,
19
+ )
20
+ from .transport import Transport
21
+
22
+
23
+ class RalioClient:
24
+ """Synchronous client for the Ralio API, authenticated via a credential
25
+ binding (OAuth 2.1 ``client_credentials`` + ``private_key_jwt`` + DPoP).
26
+
27
+ After a one-time :func:`ralio.register` on this host, no configuration is
28
+ needed::
29
+
30
+ client = ralio.RalioClient() # reads the persisted credentials
31
+ reply = client.chat.send(message="What's my balance?")
32
+
33
+ To manage credentials yourself, pass ``client_id`` and
34
+ ``private_key_path`` together.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ client_id: str | None = None,
41
+ private_key_path: str | Path | None = None,
42
+ base_url: str | None = None,
43
+ scopes: list[str] | None = None,
44
+ timeout: float = 30.0,
45
+ ) -> None:
46
+ self._base_url = _store.resolve_base_url(base_url)
47
+ client_id, key_path = _resolve_credentials(client_id, private_key_path)
48
+ private_key = _load_private_key(key_path)
49
+ public_jwk = _crypto.public_jwk(private_key)
50
+ kid = _crypto.jwk_thumbprint(public_jwk)
51
+
52
+ # SSE streams need no read timeout; keep connect/write/pool bounded.
53
+ self._http = httpx.Client(
54
+ timeout=httpx.Timeout(timeout, read=None),
55
+ )
56
+ tokens = TokenManager(
57
+ client_id=client_id,
58
+ private_key=private_key,
59
+ kid=kid,
60
+ token_url=f"{self._base_url}/oauth/token",
61
+ http=self._http,
62
+ scopes=tuple(scopes) if scopes else None,
63
+ )
64
+ transport = Transport(
65
+ base_url=self._base_url,
66
+ http=self._http,
67
+ tokens=tokens,
68
+ private_key=private_key,
69
+ public_jwk=public_jwk,
70
+ )
71
+
72
+ self.agents = AgentsResource(transport)
73
+ self.chat = ChatResource(transport, self.agents)
74
+ self.transactions = TransactionsResource(transport)
75
+ self.payment_intents = PaymentIntentsResource(transport)
76
+
77
+ def close(self) -> None:
78
+ self._http.close()
79
+
80
+ def __enter__(self) -> RalioClient:
81
+ return self
82
+
83
+ def __exit__(
84
+ self,
85
+ exc_type: type[BaseException] | None,
86
+ exc: BaseException | None,
87
+ tb: TracebackType | None,
88
+ ) -> None:
89
+ self.close()
90
+
91
+
92
+ def _resolve_credentials(
93
+ client_id: str | None,
94
+ private_key_path: str | Path | None,
95
+ ) -> tuple[str, Path]:
96
+ """Resolve the binding handle and key path, falling back to the store."""
97
+ if client_id and private_key_path:
98
+ return client_id, Path(private_key_path)
99
+ if client_id or private_key_path:
100
+ raise RalioConfigError(
101
+ "client_id and private_key_path must be passed together; omit both "
102
+ "to use the credentials persisted by ralio.register()."
103
+ )
104
+
105
+ stored = _store.load_credentials() or {}
106
+ stored_client_id = stored.get("client_id")
107
+ stored_key_path = stored.get("key_path")
108
+ stored_jkt = stored.get("key_jkt")
109
+ key_path: Path | None = None
110
+ if isinstance(stored_key_path, str) and stored_key_path:
111
+ key_path = Path(stored_key_path)
112
+ elif isinstance(stored_jkt, str) and stored_jkt:
113
+ key_path = _store.key_path_for(stored_jkt)
114
+ if not isinstance(stored_client_id, str) or not stored_client_id or key_path is None:
115
+ raise RalioConfigError(
116
+ f"No Ralio credentials found at {_store.credentials_path()}. Run "
117
+ "ralio.register() (or `ralio auth agent`) on this host first, or "
118
+ "pass client_id and private_key_path explicitly."
119
+ )
120
+ return stored_client_id, key_path
121
+
122
+
123
+ def _load_private_key(key_path: Path) -> EllipticCurvePrivateKey:
124
+ try:
125
+ return _crypto.load_private_key(key_path)
126
+ except FileNotFoundError:
127
+ raise RalioConfigError(
128
+ f"Private key missing at {key_path} — the binding may have been "
129
+ "revoked and the key removed. Re-run ralio.register() with a "
130
+ "fresh ticket."
131
+ ) from None
ralio/errors.py ADDED
@@ -0,0 +1,106 @@
1
+ """Exception hierarchy for the Ralio SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx
8
+
9
+
10
+ class RalioError(Exception):
11
+ """Base class for every error raised by the SDK."""
12
+
13
+
14
+ class RalioConfigError(RalioError):
15
+ """Local configuration problem — missing key file, bad arguments."""
16
+
17
+
18
+ class RalioRegistrationError(RalioError):
19
+ """A credential-binding registration failed — the ticket was invalid,
20
+ expired, or already consumed, the public key was unusable, or the
21
+ server's response didn't match the local key."""
22
+
23
+
24
+ class RalioAPIError(RalioError):
25
+ """An error response from the Ralio API.
26
+
27
+ Carries the HTTP ``status_code``, the server-supplied ``detail`` string,
28
+ and the ``WWW-Authenticate`` challenge when present (DPoP/OAuth failures
29
+ put the specific reason there).
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ message: str,
35
+ *,
36
+ status_code: int,
37
+ detail: str | None = None,
38
+ www_authenticate: str | None = None,
39
+ ) -> None:
40
+ super().__init__(message)
41
+ self.status_code = status_code
42
+ self.detail = detail
43
+ self.www_authenticate = www_authenticate
44
+
45
+
46
+ class RalioAuthError(RalioAPIError):
47
+ """401 — missing/invalid token, failed client assertion, or rejected proof."""
48
+
49
+
50
+ class RalioPermissionError(RalioAPIError):
51
+ """403 — token lacks the required scope, or the resource isn't owned."""
52
+
53
+
54
+ class RalioNotFoundError(RalioAPIError):
55
+ """404 — resource does not exist."""
56
+
57
+
58
+ class RalioValidationError(RalioAPIError):
59
+ """422 — invalid field values or a business-rule violation."""
60
+
61
+
62
+ class RalioRateLimitError(RalioAPIError):
63
+ """429 — rate limited. Back off and retry."""
64
+
65
+
66
+ _STATUS_MAP: dict[int, type[RalioAPIError]] = {
67
+ 401: RalioAuthError,
68
+ 403: RalioPermissionError,
69
+ 404: RalioNotFoundError,
70
+ 422: RalioValidationError,
71
+ 429: RalioRateLimitError,
72
+ }
73
+
74
+
75
+ def raise_for_response(response: httpx.Response) -> None:
76
+ """Raise the appropriate :class:`RalioAPIError` if *response* is an error."""
77
+ if response.is_success:
78
+ return
79
+ detail = _extract_detail(response)
80
+ cls = _STATUS_MAP.get(response.status_code, RalioAPIError)
81
+ message = detail or f"HTTP {response.status_code}"
82
+ raise cls(
83
+ message,
84
+ status_code=response.status_code,
85
+ detail=detail,
86
+ www_authenticate=response.headers.get("www-authenticate"),
87
+ )
88
+
89
+
90
+ def _extract_detail(response: httpx.Response) -> str | None:
91
+ """Return the FastAPI ``detail`` field, or the raw body as a fallback."""
92
+ try:
93
+ payload = response.json()
94
+ except (ValueError, json.JSONDecodeError):
95
+ return response.text or None
96
+ if isinstance(payload, dict):
97
+ detail = payload.get("detail")
98
+ if isinstance(detail, str):
99
+ return detail
100
+ if detail is not None:
101
+ return json.dumps(detail)
102
+ # OAuth-style error bodies use error/error_description instead.
103
+ oauth = payload.get("error_description") or payload.get("error")
104
+ if isinstance(oauth, str):
105
+ return oauth
106
+ return response.text or None
ralio/py.typed ADDED
File without changes