Skip to content

Commit 578e568

Browse files
njbrakeclaude
andcommitted
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>
1 parent 05c045f commit 578e568

4 files changed

Lines changed: 773 additions & 89 deletions

File tree

src/otari/_base.py

Lines changed: 104 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,102 @@ 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+
275+
def map_api_exception(error: ApiException) -> OtariError:
276+
"""Map a generated ``ApiException`` to a typed otari exception.
277+
278+
``ApiException`` carries ``.status`` (int) and ``.body`` (the raw JSON
279+
string the gateway returned) plus ``.headers``. The gateway encodes the
280+
human-readable reason under the ``detail`` key (FastAPI convention).
281+
282+
Most status mappings only apply in platform mode; in non-platform mode
283+
the generic :class:`OtariError` is raised so the caller still gets a
284+
single SDK exception type. The one cross-mode case is
285+
:class:`UnsupportedCapabilityError`, surfaced in both modes.
286+
"""
287+
status = error.status if isinstance(error.status, int) else 0
288+
headers = error.headers or {}
289+
detail = extract_detail(error)
290+
correlation_id = _header_get(headers, "x-correlation-id")
291+
retry_after = _header_get(headers, "retry-after")
292+
293+
full = f"{detail} (correlation_id={correlation_id})" if correlation_id else detail
294+
295+
# Unsupported-capability is surfaced regardless of mode.
296+
if status == 400 and _UNSUPPORTED_MODERATION_RE.search(detail):
297+
provider = _parse_unsupported_provider(detail)
298+
capability = "multimodal_moderation" if "multimodal" in detail else "moderation"
299+
return UnsupportedCapabilityError(
300+
full,
301+
status_code=status,
302+
original_error=error,
303+
provider_name=PROVIDER_NAME,
304+
provider=provider,
305+
capability=capability,
306+
)
307+
308+
if status in (401, 403):
309+
return AuthenticationError(
310+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
311+
)
312+
if status == 402:
313+
return InsufficientFundsError(
314+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
315+
)
316+
if status == 404:
317+
return ModelNotFoundError(
318+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
319+
)
320+
if status == 409:
321+
return BatchNotCompleteError(
322+
full,
323+
status_code=status,
324+
original_error=error,
325+
provider_name=PROVIDER_NAME,
326+
batch_id=_extract_batch_id(detail),
327+
batch_status=_extract_status(detail),
328+
)
329+
if status == 429:
330+
return RateLimitError(
331+
full,
332+
status_code=status,
333+
original_error=error,
334+
provider_name=PROVIDER_NAME,
335+
retry_after=retry_after,
336+
)
337+
if status == 504:
338+
return GatewayTimeoutError(
339+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
340+
)
341+
# 502 and any other 5xx are upstream-provider failures.
342+
if status == 502 or 500 <= status < 600:
343+
return UpstreamProviderError(
344+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
345+
)
346+
347+
return OtariError(
348+
full, status_code=status, original_error=error, provider_name=PROVIDER_NAME
349+
)
350+

src/otari/control_plane.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,20 @@
1818

1919
from __future__ import annotations
2020

21-
from functools import cached_property
22-
from typing import TYPE_CHECKING, Any, cast
21+
from functools import cached_property, wraps
22+
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast
2323

2424
from otari import _client as _cp
25+
from otari._base import map_api_exception
2526
from otari._client.api.budgets_api import BudgetsApi
2627
from otari._client.api.keys_api import KeysApi
2728
from otari._client.api.pricing_api import PricingApi
2829
from otari._client.api.usage_api import UsageApi
2930
from otari._client.api.users_api import UsersApi
31+
from otari._client.exceptions import ApiException
3032

3133
if TYPE_CHECKING:
34+
from collections.abc import Callable
3235
from datetime import datetime
3336

3437
from otari._client import (
@@ -49,6 +52,29 @@
4952
)
5053

5154

55+
_P = ParamSpec("_P")
56+
_R = TypeVar("_R")
57+
58+
59+
def _translate(fn: Callable[_P, _R]) -> Callable[_P, _R]:
60+
"""Map a generated ``ApiException`` to a typed :class:`otari.errors.OtariError`.
61+
62+
The inference client maps generated exceptions in ``client.py``; the
63+
control-plane ergonomic aliases get the same treatment here so callers see a
64+
single SDK error type instead of the raw generated ``ApiException``. The
65+
``raw`` escape hatch is intentionally left unwrapped.
66+
"""
67+
68+
@wraps(fn)
69+
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
70+
try:
71+
return fn(*args, **kwargs)
72+
except ApiException as exc:
73+
raise map_api_exception(exc) from exc
74+
75+
return wrapper
76+
77+
5278
class KeysResource:
5379
"""Ergonomic accessors for the API-keys management endpoints.
5480
@@ -59,18 +85,23 @@ class KeysResource:
5985
def __init__(self, api: KeysApi) -> None:
6086
self.raw = api
6187

88+
@_translate
6289
def create(self, request: CreateKeyRequest, **kwargs: Any) -> CreateKeyResponse:
6390
return self.raw.create_key_v1_keys_post(request, **kwargs)
6491

92+
@_translate
6593
def get(self, key_id: str, **kwargs: Any) -> KeyInfo:
6694
return self.raw.get_key_v1_keys_key_id_get(key_id, **kwargs)
6795

96+
@_translate
6897
def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[KeyInfo]:
6998
return self.raw.list_keys_v1_keys_get(skip, limit, **kwargs)
7099

100+
@_translate
71101
def update(self, key_id: str, request: UpdateKeyRequest, **kwargs: Any) -> KeyInfo:
72102
return self.raw.update_key_v1_keys_key_id_patch(key_id, request, **kwargs)
73103

104+
@_translate
74105
def delete(self, key_id: str, **kwargs: Any) -> None:
75106
self.raw.delete_key_v1_keys_key_id_delete(key_id, **kwargs)
76107

@@ -85,23 +116,29 @@ class UsersResource:
85116
def __init__(self, api: UsersApi) -> None:
86117
self.raw = api
87118

119+
@_translate
88120
def create(self, request: CreateUserRequest, **kwargs: Any) -> UserResponse:
89121
return self.raw.create_user_v1_users_post(request, **kwargs)
90122

123+
@_translate
91124
def get(self, user_id: str, **kwargs: Any) -> UserResponse:
92125
return self.raw.get_user_v1_users_user_id_get(user_id, **kwargs)
93126

127+
@_translate
94128
def update(self, user_id: str, request: UpdateUserRequest, **kwargs: Any) -> UserResponse:
95129
return self.raw.update_user_v1_users_user_id_patch(user_id, request, **kwargs)
96130

131+
@_translate
97132
def delete(self, user_id: str, **kwargs: Any) -> None:
98133
self.raw.delete_user_v1_users_user_id_delete(user_id, **kwargs)
99134

135+
@_translate
100136
def get_usage(self, user_id: str, **kwargs: Any) -> list[UsageLogResponse]:
101137
return self.raw.get_user_usage_v1_users_user_id_usage_get(user_id, **kwargs)
102138

103139
# Defined last: a method named ``list`` shadows the ``list`` builtin for any
104140
# ``list[...]`` annotation that follows it in this class body.
141+
@_translate
105142
def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[UserResponse]:
106143
return self.raw.list_users_v1_users_get(skip, limit, **kwargs)
107144

@@ -116,18 +153,23 @@ class BudgetsResource:
116153
def __init__(self, api: BudgetsApi) -> None:
117154
self.raw = api
118155

156+
@_translate
119157
def create(self, request: CreateBudgetRequest, **kwargs: Any) -> BudgetResponse:
120158
return self.raw.create_budget_v1_budgets_post(request, **kwargs)
121159

160+
@_translate
122161
def get(self, budget_id: str, **kwargs: Any) -> BudgetResponse:
123162
return self.raw.get_budget_v1_budgets_budget_id_get(budget_id, **kwargs)
124163

164+
@_translate
125165
def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[BudgetResponse]:
126166
return self.raw.list_budgets_v1_budgets_get(skip, limit, **kwargs)
127167

168+
@_translate
128169
def update(self, budget_id: str, request: UpdateBudgetRequest, **kwargs: Any) -> BudgetResponse:
129170
return self.raw.update_budget_v1_budgets_budget_id_patch(budget_id, request, **kwargs)
130171

172+
@_translate
131173
def delete(self, budget_id: str, **kwargs: Any) -> None:
132174
self.raw.delete_budget_v1_budgets_budget_id_delete(budget_id, **kwargs)
133175

@@ -142,20 +184,25 @@ class PricingResource:
142184
def __init__(self, api: PricingApi) -> None:
143185
self.raw = api
144186

187+
@_translate
145188
def get(self, model_key: str, **kwargs: Any) -> PricingResponse:
146189
return self.raw.get_pricing_v1_pricing_model_key_get(model_key, **kwargs)
147190

191+
@_translate
148192
def set(self, request: SetPricingRequest, **kwargs: Any) -> PricingResponse:
149193
return self.raw.set_pricing_v1_pricing_post(request, **kwargs)
150194

195+
@_translate
151196
def delete(self, model_key: str, **kwargs: Any) -> None:
152197
self.raw.delete_pricing_v1_pricing_model_key_delete(model_key, **kwargs)
153198

199+
@_translate
154200
def get_history(self, model_key: str, **kwargs: Any) -> list[PricingResponse]:
155201
return self.raw.get_pricing_history_v1_pricing_model_key_history_get(model_key, **kwargs)
156202

157203
# Defined last: a method named ``list`` shadows the ``list`` builtin for any
158204
# ``list[...]`` annotation that follows it in this class body.
205+
@_translate
159206
def list(self, skip: int | None = None, limit: int | None = None, **kwargs: Any) -> list[PricingResponse]:
160207
return self.raw.list_pricing_v1_pricing_get(skip, limit, **kwargs)
161208

@@ -170,6 +217,7 @@ class UsageResource:
170217
def __init__(self, api: UsageApi) -> None:
171218
self.raw = api
172219

220+
@_translate
173221
def list(
174222
self,
175223
start_date: datetime | None = None,

0 commit comments

Comments
 (0)