Skip to content

Commit 47dd032

Browse files
njbrakeclaude
andauthored
fix(control-plane): map generated ApiException to typed OtariError (#21)
* fix(control-plane): map generated ApiException to typed OtariError Control-plane resources called the generated client directly, so HTTP errors surfaced as the raw ApiException instead of a typed OtariError (the inference path already maps these in client.py). Extract the mapping into module-level map_api_exception/extract_detail helpers and route every control-plane ergonomic alias through a _translate decorator; the raw escape hatch is left unwrapped. Adds a parametrized unit test across all five resources. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(control-plane): drop unrelated uv.lock, tidy spacing Keep the PR a focused bug fix: remove the uv.lock this branch added (committing a lockfile for the first time is a project-wide decision unrelated to the error-mapping fix, and CI does not consume it), and collapse a stray extra blank line between the two new module functions in _base.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(endpoints): defer new gateway management endpoints in drift manifest The gateway spec grew ~18 dashboard/admin management endpoints (aliases, provider-credentials, providers, settings, model metadata/discovery, usage/count, key rotate, budget reset-logs) that no SDK shell wraps yet. The endpoint-coverage drift gate fetches the live gateway spec, so these turned the gate red on every SDK independently of the error-mapping fix. Defer them under [excluded] with a "not yet wrapped" reason to restore a green gate; promote to [covered] when a shell surfaces them. Kept identical across all four SDKs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0796aa4 commit 47dd032

4 files changed

Lines changed: 221 additions & 89 deletions

File tree

sdk-endpoints.txt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,31 @@ GET /v1/files # not yet wrapped
6868
GET /v1/files/{file_id} # not yet wrapped
6969
GET /v1/files/{file_id}/content # not yet wrapped
7070
DELETE /v1/files/{file_id} # not yet wrapped
71+
72+
# Dashboard / admin management surface: present in the gateway spec, not yet
73+
# wrapped by any SDK shell. Deferred here so the drift gate stays green; move to
74+
# [covered] when a shell surfaces them. Kept identical across all four SDKs.
75+
# Model aliases
76+
GET /v1/aliases # not yet wrapped
77+
POST /v1/aliases # not yet wrapped
78+
DELETE /v1/aliases/{name} # not yet wrapped
79+
# Provider credentials (runtime provider management)
80+
GET /v1/provider-credentials # not yet wrapped
81+
POST /v1/provider-credentials # not yet wrapped
82+
POST /v1/provider-credentials/test # not yet wrapped
83+
PATCH /v1/provider-credentials/{instance} # not yet wrapped
84+
DELETE /v1/provider-credentials/{instance} # not yet wrapped
85+
POST /v1/provider-credentials/{instance}/test # not yet wrapped
86+
# Providers catalogue
87+
GET /v1/providers # not yet wrapped
88+
GET /v1/providers/catalog # not yet wrapped
89+
# Runtime settings
90+
GET /v1/settings # not yet wrapped
91+
PATCH /v1/settings # not yet wrapped
92+
# Model discovery / metadata
93+
GET /v1/models/discoverable # not yet wrapped
94+
GET /v1/models/metadata # not yet wrapped
95+
# Other control-plane extensions of already-covered resources
96+
GET /v1/usage/count # not yet wrapped
97+
POST /v1/keys/{key_id}/rotate # not yet wrapped
98+
GET /v1/budgets/{budget_id}/reset-logs # not yet wrapped

src/otari/_base.py

Lines changed: 103 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -154,98 +154,16 @@ def __init__(
154154
def _map_api_exception(self, error: ApiException) -> OtariError:
155155
"""Map a generated ``ApiException`` to a typed otari exception.
156156
157-
``ApiException`` carries ``.status`` (int) and ``.body`` (the raw JSON
158-
string the gateway returned) plus ``.headers``. The gateway encodes the
159-
human-readable reason under the ``detail`` key (FastAPI convention).
160-
161-
Most status mappings only apply in platform mode; in non-platform mode
162-
the generic :class:`OtariError` is raised so the caller still gets a
163-
single SDK exception type. The one cross-mode case is
164-
:class:`UnsupportedCapabilityError`, surfaced in both modes.
157+
Thin wrapper over the module-level :func:`map_api_exception` so the
158+
control-plane resources can reuse the same mapping without a client
159+
instance.
165160
"""
166-
status = error.status if isinstance(error.status, int) else 0
167-
headers = error.headers or {}
168-
detail = self._extract_detail(error)
169-
correlation_id = _header_get(headers, "x-correlation-id")
170-
retry_after = _header_get(headers, "retry-after")
171-
172-
full = f"{detail} (correlation_id={correlation_id})" if correlation_id else detail
173-
174-
# Unsupported-capability is surfaced regardless of mode.
175-
if status == 400 and _UNSUPPORTED_MODERATION_RE.search(detail):
176-
provider = _parse_unsupported_provider(detail)
177-
capability = "multimodal_moderation" if "multimodal" in detail else "moderation"
178-
return UnsupportedCapabilityError(
179-
full,
180-
status_code=status,
181-
original_error=error,
182-
provider_name=PROVIDER_NAME,
183-
provider=provider,
184-
capability=capability,
185-
)
186-
187-
if status in (401, 403):
188-
return AuthenticationError(
189-
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
190-
)
191-
if status == 402:
192-
return InsufficientFundsError(
193-
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
194-
)
195-
if status == 404:
196-
return ModelNotFoundError(
197-
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
198-
)
199-
if status == 409:
200-
return BatchNotCompleteError(
201-
full,
202-
status_code=status,
203-
original_error=error,
204-
provider_name=PROVIDER_NAME,
205-
batch_id=_extract_batch_id(detail),
206-
batch_status=_extract_status(detail),
207-
)
208-
if status == 429:
209-
return RateLimitError(
210-
full,
211-
status_code=status,
212-
original_error=error,
213-
provider_name=PROVIDER_NAME,
214-
retry_after=retry_after,
215-
)
216-
if status == 504:
217-
return GatewayTimeoutError(
218-
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
219-
)
220-
# 502 and any other 5xx are upstream-provider failures.
221-
if status == 502 or 500 <= status < 600:
222-
return UpstreamProviderError(
223-
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
224-
)
225-
226-
return OtariError(
227-
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
228-
)
161+
return map_api_exception(error)
229162

230163
@staticmethod
231164
def _extract_detail(error: ApiException) -> str:
232165
"""Pull the gateway's human-readable detail from an ``ApiException`` body."""
233-
body = error.body
234-
if isinstance(body, (bytes, bytearray)):
235-
body = body.decode("utf-8", "replace")
236-
if isinstance(body, str) and body:
237-
try:
238-
parsed = json.loads(body)
239-
except (ValueError, TypeError):
240-
return body
241-
if isinstance(parsed, dict):
242-
detail = parsed.get("detail") or parsed.get("message") or parsed.get("error")
243-
if isinstance(detail, str):
244-
return detail
245-
if detail is not None:
246-
return str(detail)
247-
return body
248-
return error.reason or "An error occurred"
166+
return extract_detail(error)
249167

250168
def _map_streaming_response(self, response: httpx.Response, body: bytes) -> OtariError:
251169
"""Map a failed raw streaming response to a typed otari exception.
@@ -331,3 +249,101 @@ def _extract_status(message: str) -> str | None:
331249
def _url_encode(value: str) -> str:
332250
"""Percent-encode a single URL component."""
333251
return urllib.parse.quote(value, safe="")
252+
253+
254+
def extract_detail(error: ApiException) -> str:
255+
"""Pull the gateway's human-readable detail from an ``ApiException`` body."""
256+
body = error.body
257+
if isinstance(body, (bytes, bytearray)):
258+
body = body.decode("utf-8", "replace")
259+
if isinstance(body, str) and body:
260+
try:
261+
parsed = json.loads(body)
262+
except (ValueError, TypeError):
263+
return body
264+
if isinstance(parsed, dict):
265+
detail = parsed.get("detail") or parsed.get("message") or parsed.get("error")
266+
if isinstance(detail, str):
267+
return detail
268+
if detail is not None:
269+
return str(detail)
270+
return body
271+
return error.reason or "An error occurred"
272+
273+
274+
def map_api_exception(error: ApiException) -> OtariError:
275+
"""Map a generated ``ApiException`` to a typed otari exception.
276+
277+
``ApiException`` carries ``.status`` (int) and ``.body`` (the raw JSON
278+
string the gateway returned) plus ``.headers``. The gateway encodes the
279+
human-readable reason under the ``detail`` key (FastAPI convention).
280+
281+
Most status mappings only apply in platform mode; in non-platform mode
282+
the generic :class:`OtariError` is raised so the caller still gets a
283+
single SDK exception type. The one cross-mode case is
284+
:class:`UnsupportedCapabilityError`, surfaced in both modes.
285+
"""
286+
status = error.status if isinstance(error.status, int) else 0
287+
headers = error.headers or {}
288+
detail = extract_detail(error)
289+
correlation_id = _header_get(headers, "x-correlation-id")
290+
retry_after = _header_get(headers, "retry-after")
291+
292+
full = f"{detail} (correlation_id={correlation_id})" if correlation_id else detail
293+
294+
# Unsupported-capability is surfaced regardless of mode.
295+
if status == 400 and _UNSUPPORTED_MODERATION_RE.search(detail):
296+
provider = _parse_unsupported_provider(detail)
297+
capability = "multimodal_moderation" if "multimodal" in detail else "moderation"
298+
return UnsupportedCapabilityError(
299+
full,
300+
status_code=status,
301+
original_error=error,
302+
provider_name=PROVIDER_NAME,
303+
provider=provider,
304+
capability=capability,
305+
)
306+
307+
if status in (401, 403):
308+
return AuthenticationError(
309+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
310+
)
311+
if status == 402:
312+
return InsufficientFundsError(
313+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
314+
)
315+
if status == 404:
316+
return ModelNotFoundError(
317+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
318+
)
319+
if status == 409:
320+
return BatchNotCompleteError(
321+
full,
322+
status_code=status,
323+
original_error=error,
324+
provider_name=PROVIDER_NAME,
325+
batch_id=_extract_batch_id(detail),
326+
batch_status=_extract_status(detail),
327+
)
328+
if status == 429:
329+
return RateLimitError(
330+
full,
331+
status_code=status,
332+
original_error=error,
333+
provider_name=PROVIDER_NAME,
334+
retry_after=retry_after,
335+
)
336+
if status == 504:
337+
return GatewayTimeoutError(
338+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
339+
)
340+
# 502 and any other 5xx are upstream-provider failures.
341+
if status == 502 or 500 <= status < 600:
342+
return UpstreamProviderError(
343+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
344+
)
345+
346+
return OtariError(
347+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
348+
)
349+

0 commit comments

Comments
 (0)