robotrace-dev 0.1.0a2__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.
robotrace/__init__.py ADDED
@@ -0,0 +1,219 @@
1
+ """RoboTrace — observability and evals for AI robots.
2
+
3
+ The public API in 30 seconds:
4
+
5
+ import robotrace as rt
6
+
7
+ # 1. Quickstart — one call per run.
8
+ rt.init(api_key="rt_…", base_url="https://app.robotrace.dev")
9
+ rt.log_episode(
10
+ name="pick_and_place v3 morning warmup",
11
+ policy_version="pap-v3.2.1",
12
+ env_version="halcyon-cell-rev4",
13
+ git_sha="abc1234",
14
+ seed=8124,
15
+ video="/tmp/run.mp4",
16
+ sensors="/tmp/sensors.bin",
17
+ actions="/tmp/actions.parquet",
18
+ duration_s=47.2,
19
+ fps=30,
20
+ metadata={"task": "pick_and_place"},
21
+ )
22
+
23
+ # 2. Streaming — explicit control of the lifecycle.
24
+ with rt.start_episode(name="…", policy_version="…") as ep:
25
+ ep.upload_video("/tmp/run.mp4")
26
+ # auto-finalize: ready on clean exit, failed on exception.
27
+
28
+ # 3. Multiple deployments at once — explicit Client.
29
+ with rt.Client(api_key="…", base_url="…") as client:
30
+ client.log_episode(...)
31
+
32
+ `log_episode` is the **sacred** signature per AGENTS.md — once 1.0
33
+ ships, breakages require a major bump and at least one minor of
34
+ deprecation warnings.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from collections.abc import Mapping, Sequence
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ from ._version import __version__
44
+ from .client import (
45
+ ENV_API_KEY,
46
+ ENV_BASE_URL,
47
+ Client,
48
+ EpisodeSource,
49
+ )
50
+ from .episode import (
51
+ ArtifactKind,
52
+ Episode,
53
+ EpisodeFinalStatus,
54
+ UploadUrl,
55
+ )
56
+ from .errors import (
57
+ APIError,
58
+ AuthError,
59
+ ConfigurationError,
60
+ ConflictError,
61
+ NotFoundError,
62
+ RobotraceError,
63
+ ServerError,
64
+ TransportError,
65
+ ValidationError,
66
+ )
67
+
68
+ __all__ = [
69
+ "__version__",
70
+ # Public types
71
+ "Client",
72
+ "Episode",
73
+ "UploadUrl",
74
+ "ArtifactKind",
75
+ "EpisodeSource",
76
+ "EpisodeFinalStatus",
77
+ # Errors
78
+ "RobotraceError",
79
+ "ConfigurationError",
80
+ "TransportError",
81
+ "APIError",
82
+ "AuthError",
83
+ "NotFoundError",
84
+ "ConflictError",
85
+ "ValidationError",
86
+ "ServerError",
87
+ # Top-level convenience (backed by the default client)
88
+ "init",
89
+ "close",
90
+ "start_episode",
91
+ "log_episode",
92
+ # Env-var names exposed for tooling that wants to validate them
93
+ "ENV_API_KEY",
94
+ "ENV_BASE_URL",
95
+ ]
96
+
97
+ # ── module-level "default client" plumbing ──────────────────────────
98
+ #
99
+ # Mirrors what `requests.get(...)` does — convenient for scripts, but
100
+ # we explicitly support and document `Client(...)` for tests and any
101
+ # multi-deployment setup. The default client is constructed lazily on
102
+ # first use, so importing `robotrace` never hits the network or
103
+ # requires env vars.
104
+
105
+ _default_client: Client | None = None
106
+
107
+
108
+ def init(
109
+ *,
110
+ api_key: str | None = None,
111
+ base_url: str | None = None,
112
+ timeout: float | None = None,
113
+ ) -> None:
114
+ """Configure the module-level default Client.
115
+
116
+ Calling this twice rebuilds the client; the previous one (if any)
117
+ is closed first. Safe to call from process startup.
118
+ """
119
+ global _default_client
120
+ if _default_client is not None:
121
+ try:
122
+ _default_client.close()
123
+ except Exception:
124
+ pass
125
+ _default_client = Client(api_key=api_key, base_url=base_url, timeout=timeout)
126
+
127
+
128
+ def close() -> None:
129
+ """Close the module-level default client. Safe to call twice."""
130
+ global _default_client
131
+ if _default_client is not None:
132
+ try:
133
+ _default_client.close()
134
+ finally:
135
+ _default_client = None
136
+
137
+
138
+ def _ensure_default_client() -> Client:
139
+ """Lazy-construct the default client from env vars on first use.
140
+
141
+ Lets users skip the explicit `init(...)` call when they've set
142
+ `ROBOTRACE_API_KEY` / `ROBOTRACE_BASE_URL` (the common case for
143
+ CI-driven episode logging from a robot rig).
144
+ """
145
+ global _default_client
146
+ if _default_client is None:
147
+ _default_client = Client()
148
+ return _default_client
149
+
150
+
151
+ def start_episode(
152
+ *,
153
+ name: str | None = None,
154
+ source: EpisodeSource = "real",
155
+ robot: str | None = None,
156
+ policy_version: str | None = None,
157
+ env_version: str | None = None,
158
+ git_sha: str | None = None,
159
+ seed: int | None = None,
160
+ fps: float | None = None,
161
+ metadata: Mapping[str, Any] | None = None,
162
+ artifacts: Sequence[ArtifactKind] = ("video", "sensors", "actions"),
163
+ ) -> Episode:
164
+ """Open a new run on the configured deployment. See `Client.start_episode`."""
165
+ return _ensure_default_client().start_episode(
166
+ name=name,
167
+ source=source,
168
+ robot=robot,
169
+ policy_version=policy_version,
170
+ env_version=env_version,
171
+ git_sha=git_sha,
172
+ seed=seed,
173
+ fps=fps,
174
+ metadata=metadata,
175
+ artifacts=artifacts,
176
+ )
177
+
178
+
179
+ def log_episode(
180
+ *,
181
+ name: str | None = None,
182
+ source: EpisodeSource = "real",
183
+ robot: str | None = None,
184
+ policy_version: str | None = None,
185
+ env_version: str | None = None,
186
+ git_sha: str | None = None,
187
+ seed: int | None = None,
188
+ video: str | Path | None = None,
189
+ sensors: str | Path | None = None,
190
+ actions: str | Path | None = None,
191
+ duration_s: float | None = None,
192
+ fps: float | None = None,
193
+ metadata: Mapping[str, Any] | None = None,
194
+ status: EpisodeFinalStatus = "ready",
195
+ ) -> Episode:
196
+ """Log a complete episode in one call. See `Client.log_episode`.
197
+
198
+ This is the **sacred** signature per AGENTS.md. Don't change it
199
+ without bumping a major version and shipping deprecation warnings
200
+ for at least one minor first.
201
+ """
202
+ return _ensure_default_client().log_episode(
203
+ name=name,
204
+ source=source,
205
+ robot=robot,
206
+ policy_version=policy_version,
207
+ env_version=env_version,
208
+ git_sha=git_sha,
209
+ seed=seed,
210
+ video=video,
211
+ sensors=sensors,
212
+ actions=actions,
213
+ duration_s=duration_s,
214
+ fps=fps,
215
+ metadata=metadata,
216
+ status=status,
217
+ )
218
+
219
+
@@ -0,0 +1,262 @@
1
+ """On-disk credentials store for the `robotrace` CLI.
2
+
3
+ The CLI's `login` command writes a small TOML file at
4
+ ``~/.robotrace/credentials`` that the Python SDK auto-loads when
5
+ neither an explicit `api_key=` kwarg nor the `ROBOTRACE_API_KEY`
6
+ environment variable is set. The same file backs `whoami` and
7
+ `logout`.
8
+
9
+ Format
10
+ ------
11
+
12
+ ::
13
+
14
+ [default]
15
+ api_key = "rt_…"
16
+ base_url = "https://app.robotrace.dev"
17
+ client_id = "<uuid>"
18
+ user_email = "art@robotrace.dev"
19
+ written_at = "2026-05-04T20:08:14Z"
20
+
21
+ We support **profiles** in case a single workstation talks to more
22
+ than one deployment (production + a staging cell). The MVP only
23
+ reads/writes ``[default]``; the file format leaves room to extend
24
+ without breaking older SDKs that ignore unknown profiles.
25
+
26
+ Security
27
+ --------
28
+
29
+ The file holds a long-lived API key, so the writer enforces
30
+ ``chmod 0600`` after writing. We never log the key value. Reads
31
+ fail loudly if the file is world-readable on systems that ship a
32
+ strict ``UMASK`` policy — the user can re-run ``robotrace login``
33
+ and the file will be re-created with the right perms.
34
+
35
+ Cross-platform notes
36
+ --------------------
37
+
38
+ * On POSIX we ``os.chmod`` to 0600 after a fresh write.
39
+ * On Windows ``os.chmod`` is largely a no-op for the read/write/
40
+ execute bits we care about, so we settle for storing in the
41
+ user's profile directory and rely on filesystem ACLs there.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import json
47
+ import os
48
+ import sys
49
+ from dataclasses import dataclass
50
+ from datetime import datetime, timezone
51
+ from pathlib import Path
52
+
53
+ # tomllib only landed in 3.11; we still support 3.10 per pyproject.
54
+ if sys.version_info >= (3, 11):
55
+ import tomllib as _tomllib # noqa: PLC0415
56
+ else: # pragma: no cover — exercised on 3.10
57
+ _tomllib = None # type: ignore[assignment]
58
+
59
+
60
+ CREDENTIALS_DIR_NAME = ".robotrace"
61
+ CREDENTIALS_FILE_NAME = "credentials"
62
+ DEFAULT_PROFILE = "default"
63
+
64
+
65
+ @dataclass
66
+ class StoredCredentials:
67
+ """One profile's worth of credentials."""
68
+
69
+ api_key: str
70
+ base_url: str
71
+ client_id: str | None = None
72
+ user_email: str | None = None
73
+ written_at: str | None = None
74
+
75
+
76
+ def credentials_path() -> Path:
77
+ """Resolve the path to the credentials file.
78
+
79
+ Uses ``$ROBOTRACE_HOME`` when set (handy for tests and CI), then
80
+ falls back to ``~/.robotrace`` on every platform — matches the
81
+ convention popular among devtools (``~/.aws``, ``~/.docker``,
82
+ ``~/.kube``).
83
+ """
84
+ override = os.environ.get("ROBOTRACE_HOME")
85
+ if override:
86
+ return Path(override) / CREDENTIALS_FILE_NAME
87
+ return Path.home() / CREDENTIALS_DIR_NAME / CREDENTIALS_FILE_NAME
88
+
89
+
90
+ def write_credentials(creds: StoredCredentials, *, profile: str = DEFAULT_PROFILE) -> Path:
91
+ """Persist `creds` under `profile` in the credentials file.
92
+
93
+ Returns the absolute path written. Creates the parent dir with
94
+ mode 0700, then writes the file with mode 0600. Raises on any
95
+ filesystem error — login is the last step of the flow, so a
96
+ failure to persist is worth surfacing loudly.
97
+ """
98
+ path = credentials_path()
99
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
100
+
101
+ existing = _read_all_profiles(path)
102
+ existing[profile] = {
103
+ "api_key": creds.api_key,
104
+ "base_url": creds.base_url,
105
+ "client_id": creds.client_id,
106
+ "user_email": creds.user_email,
107
+ "written_at": creds.written_at or _now_iso(),
108
+ }
109
+
110
+ _atomic_write_toml(path, existing)
111
+ try:
112
+ os.chmod(path, 0o600)
113
+ except OSError:
114
+ # On Windows / odd filesystems chmod can fail; the dir
115
+ # permission and user-profile location are our backstop.
116
+ pass
117
+ return path
118
+
119
+
120
+ def read_credentials(*, profile: str = DEFAULT_PROFILE) -> StoredCredentials | None:
121
+ """Load `profile` from the credentials file, or `None` if absent.
122
+
123
+ Returns `None` for any of: missing dir, missing file, profile
124
+ not present, malformed file. The SDK falls back to env-var auth
125
+ in those cases — we never want a corrupt creds file to surface
126
+ as a confusing "API key not provided" error.
127
+ """
128
+ path = credentials_path()
129
+ if not path.is_file():
130
+ return None
131
+ try:
132
+ all_profiles = _read_all_profiles(path)
133
+ except Exception:
134
+ return None
135
+ raw = all_profiles.get(profile)
136
+ if not isinstance(raw, dict):
137
+ return None
138
+ api_key = raw.get("api_key")
139
+ base_url = raw.get("base_url")
140
+ if not isinstance(api_key, str) or not isinstance(base_url, str):
141
+ return None
142
+ if not api_key or not base_url:
143
+ return None
144
+ client_id = raw.get("client_id")
145
+ user_email = raw.get("user_email")
146
+ written_at = raw.get("written_at")
147
+ return StoredCredentials(
148
+ api_key=api_key,
149
+ base_url=base_url,
150
+ client_id=client_id if isinstance(client_id, str) else None,
151
+ user_email=user_email if isinstance(user_email, str) else None,
152
+ written_at=written_at if isinstance(written_at, str) else None,
153
+ )
154
+
155
+
156
+ def delete_credentials(*, profile: str = DEFAULT_PROFILE) -> bool:
157
+ """Remove `profile` from the credentials file. Returns True if
158
+ something was removed, False otherwise.
159
+
160
+ If the resulting file has no profiles left, the file (and its
161
+ parent dir, if empty) are deleted to leave a clean filesystem.
162
+ """
163
+ path = credentials_path()
164
+ if not path.is_file():
165
+ return False
166
+ try:
167
+ existing = _read_all_profiles(path)
168
+ except Exception:
169
+ return False
170
+ if profile not in existing:
171
+ return False
172
+ existing.pop(profile)
173
+ if existing:
174
+ _atomic_write_toml(path, existing)
175
+ try:
176
+ os.chmod(path, 0o600)
177
+ except OSError:
178
+ pass
179
+ else:
180
+ try:
181
+ path.unlink()
182
+ except OSError:
183
+ return False
184
+ try:
185
+ path.parent.rmdir()
186
+ except OSError:
187
+ # Dir not empty (other tools' creds in there) — fine.
188
+ pass
189
+ return True
190
+
191
+
192
+ # ── internals ────────────────────────────────────────────────────────
193
+
194
+
195
+ def _now_iso() -> str:
196
+ return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
197
+
198
+
199
+ def _read_all_profiles(path: Path) -> dict[str, object]:
200
+ """Parse the whole credentials file into a {profile: {...}} dict.
201
+
202
+ Tolerant of either canonical TOML (which `_atomic_write_toml`
203
+ produces) or a fallback JSON-encoded body that older SDKs
204
+ might have written. Returns ``{}`` on parse errors so the
205
+ caller can treat it as a fresh write.
206
+ """
207
+ if _tomllib is not None:
208
+ try:
209
+ with path.open("rb") as fh:
210
+ return dict(_tomllib.load(fh))
211
+ except Exception:
212
+ # fall through to JSON
213
+ pass
214
+
215
+ try:
216
+ text = path.read_text(encoding="utf-8")
217
+ except OSError:
218
+ return {}
219
+
220
+ # Last-resort JSON parser for environments without tomllib that
221
+ # somehow ended up with a JSON-formatted creds file.
222
+ try:
223
+ loaded = json.loads(text)
224
+ except Exception:
225
+ return {}
226
+ return dict(loaded) if isinstance(loaded, dict) else {}
227
+
228
+
229
+ def _atomic_write_toml(path: Path, data: dict[str, object]) -> None:
230
+ """Write `data` to `path` as TOML, atomically.
231
+
232
+ We don't take a hard dep on a TOML *writer* (the stdlib gained
233
+ one in 3.11 only as ``tomllib`` for *reading*). Hand-rolling the
234
+ minimal subset we use — ``[section]`` headers and ``key = "str"``
235
+ pairs — avoids a third-party dep on the SDK install path.
236
+ """
237
+ lines: list[str] = ["# robotrace credentials\n# managed by `robotrace login` — do not commit.\n\n"]
238
+ # Stable ordering keeps diffs readable when the user opens the
239
+ # file out of curiosity.
240
+ for profile in sorted(data.keys()):
241
+ section = data[profile]
242
+ if not isinstance(section, dict):
243
+ continue
244
+ lines.append(f"[{profile}]\n")
245
+ for key in sorted(section.keys()):
246
+ value = section.get(key)
247
+ if value is None:
248
+ continue
249
+ if not isinstance(value, str):
250
+ # Force-stringify; the schema is all-strings today.
251
+ value = str(value)
252
+ lines.append(f'{key} = "{_escape_toml(value)}"\n')
253
+ lines.append("\n")
254
+
255
+ tmp = path.with_suffix(path.suffix + ".tmp")
256
+ tmp.write_text("".join(lines), encoding="utf-8")
257
+ os.replace(tmp, path)
258
+
259
+
260
+ def _escape_toml(value: str) -> str:
261
+ """Escape the bare-minimum characters that break a basic TOML string."""
262
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
robotrace/_http.py ADDED
@@ -0,0 +1,203 @@
1
+ """Internal HTTP wrapper.
2
+
3
+ Centralizes:
4
+ • auth header construction
5
+ • base_url normalization
6
+ • status-code → exception mapping
7
+ • redaction of the API key in error messages
8
+
9
+ Public API is intentionally minimal — call sites use only `request()`,
10
+ `upload_file()`, and the dataclass shapes. Avoids spreading httpx
11
+ specifics through `client.py` / `episode.py`.
12
+
13
+ NEVER log the value of the `Authorization` header or any request body
14
+ to the ingest endpoint. Both can carry secrets per AGENTS.md.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Mapping
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ from . import _version
26
+ from .errors import (
27
+ APIError,
28
+ AuthError,
29
+ ConflictError,
30
+ NotFoundError,
31
+ ServerError,
32
+ TransportError,
33
+ ValidationError,
34
+ )
35
+
36
+ USER_AGENT = f"robotrace-python/{_version.__version__}"
37
+
38
+ # Default request timeout. Generous on read because the create call
39
+ # may block briefly on R2 signing; the upload PUT to R2 has its own
40
+ # (longer) timeout in `upload_file`.
41
+ DEFAULT_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0)
42
+
43
+ # Object-storage uploads can run minutes for multi-GB videos. We let
44
+ # httpx stream the body so memory stays flat regardless of file size,
45
+ # and bump the read/write timeout accordingly.
46
+ UPLOAD_TIMEOUT = httpx.Timeout(connect=15.0, read=600.0, write=600.0, pool=15.0)
47
+
48
+
49
+ class HTTPClient:
50
+ """Thin wrapper around httpx.Client with RoboTrace defaults."""
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ api_key: str,
56
+ base_url: str,
57
+ timeout: httpx.Timeout | float | None = None,
58
+ transport: httpx.BaseTransport | None = None,
59
+ ) -> None:
60
+ self._api_key = api_key
61
+ # Trim trailing slash so we can join paths with a leading slash
62
+ # without ending up with `//api/...`.
63
+ self._base_url = base_url.rstrip("/")
64
+ self._client = httpx.Client(
65
+ base_url=self._base_url,
66
+ timeout=timeout if timeout is not None else DEFAULT_TIMEOUT,
67
+ headers={
68
+ "User-Agent": USER_AGENT,
69
+ "Authorization": f"Bearer {api_key}",
70
+ "Accept": "application/json",
71
+ },
72
+ # `transport` is a hook for tests (httpx.MockTransport).
73
+ # Production callers leave it None and let httpx pick.
74
+ transport=transport,
75
+ )
76
+
77
+ @property
78
+ def base_url(self) -> str:
79
+ return self._base_url
80
+
81
+ def close(self) -> None:
82
+ self._client.close()
83
+
84
+ # ── core request ────────────────────────────────────────────────
85
+
86
+ def request(
87
+ self,
88
+ method: str,
89
+ path: str,
90
+ *,
91
+ json: Mapping[str, Any] | None = None,
92
+ ) -> dict[str, Any]:
93
+ """Send a JSON request and return the parsed JSON response.
94
+
95
+ Maps HTTP errors to the typed exception hierarchy. The raised
96
+ exception's `response_body` always carries the parsed body
97
+ (or raw text) so callers can introspect server-supplied
98
+ details without re-parsing.
99
+ """
100
+ try:
101
+ response = self._client.request(method, path, json=json)
102
+ except httpx.TimeoutException as exc:
103
+ raise TransportError(f"timeout calling {path}: {exc}") from exc
104
+ except httpx.HTTPError as exc:
105
+ raise TransportError(f"transport error calling {path}: {exc}") from exc
106
+
107
+ return self._parse_response(response, path)
108
+
109
+ # ── streaming upload to a signed PUT URL ────────────────────────
110
+
111
+ def upload_file(
112
+ self,
113
+ url: str,
114
+ path: str | Path,
115
+ *,
116
+ content_type: str,
117
+ ) -> int:
118
+ """PUT `path` to `url` with `content_type`. Returns bytes uploaded.
119
+
120
+ Streams the file from disk so we don't blow up on multi-GB
121
+ videos. Uses a fresh httpx client (no auth header — the
122
+ signed URL carries the credentials) and a long timeout.
123
+ """
124
+ file_path = Path(path)
125
+ if not file_path.is_file():
126
+ raise FileNotFoundError(f"artifact not found: {file_path}")
127
+ size = file_path.stat().st_size
128
+
129
+ try:
130
+ with file_path.open("rb") as fh, httpx.Client(
131
+ timeout=UPLOAD_TIMEOUT,
132
+ # Don't inherit our auth header here — the signed URL
133
+ # already carries the auth as query params.
134
+ ) as client:
135
+ response = client.put(
136
+ url,
137
+ content=fh,
138
+ headers={
139
+ "Content-Type": content_type,
140
+ "Content-Length": str(size),
141
+ "User-Agent": USER_AGENT,
142
+ },
143
+ )
144
+ except httpx.TimeoutException as exc:
145
+ raise TransportError(f"upload timeout for {file_path.name}: {exc}") from exc
146
+ except httpx.HTTPError as exc:
147
+ raise TransportError(f"upload transport error for {file_path.name}: {exc}") from exc
148
+
149
+ if response.status_code >= 400:
150
+ # Object storage error bodies are XML, not JSON. Surface
151
+ # the raw text in the exception so the user can debug
152
+ # bucket / CORS / signature mismatches.
153
+ raise APIError(
154
+ f"upload failed for {file_path.name} ({response.status_code})",
155
+ status_code=response.status_code,
156
+ response_body=response.text[:1000],
157
+ )
158
+ return size
159
+
160
+ # ── internals ───────────────────────────────────────────────────
161
+
162
+ def _parse_response(self, response: httpx.Response, path: str) -> dict[str, Any]:
163
+ # Try JSON first; fall back to raw text so 5xx HTML pages
164
+ # don't crash error reporting.
165
+ try:
166
+ body: object = response.json()
167
+ except ValueError:
168
+ body = response.text
169
+
170
+ if response.is_success:
171
+ if isinstance(body, dict):
172
+ return body
173
+ # The ingest endpoints always return JSON objects; anything
174
+ # else is a server contract violation.
175
+ raise ServerError(
176
+ f"unexpected non-JSON success body from {path}",
177
+ status_code=response.status_code,
178
+ response_body=body,
179
+ )
180
+
181
+ message = self._error_message(body, response.status_code)
182
+ cls = _STATUS_TO_ERROR.get(response.status_code, APIError)
183
+ # 5xx falls through to ServerError. 401/404/409/4xx route to
184
+ # their typed subclasses for ergonomic catch blocks.
185
+ if response.status_code >= 500:
186
+ cls = ServerError
187
+ raise cls(message, status_code=response.status_code, response_body=body)
188
+
189
+ @staticmethod
190
+ def _error_message(body: object, status_code: int) -> str:
191
+ if isinstance(body, dict):
192
+ err = body.get("error")
193
+ if isinstance(err, str) and err:
194
+ return err
195
+ return f"HTTP {status_code}"
196
+
197
+
198
+ _STATUS_TO_ERROR: dict[int, type[APIError]] = {
199
+ 400: ValidationError,
200
+ 401: AuthError,
201
+ 404: NotFoundError,
202
+ 409: ConflictError,
203
+ }