Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,6 @@ def client_from_platform(
return client_cls(
base_url=str(platform.base_url).rstrip("/"),
workspace=platform.workspace,
default_headers=platform._custom_headers, # type: ignore[arg-type]
http_client=platform._client, # type: ignore[arg-type]
)
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import asyncio
import inspect
import json
import time
from collections.abc import Mapping
from pathlib import Path
Expand All @@ -28,6 +29,7 @@
StaticToken,
TokenProvider,
)
from nemo_platform_plugin.client.errors import ConflictError, raise_for_status
from nemo_platform_plugin.client.response import (
AsyncNemoBinaryResponse,
AsyncNemoPaginatedResponse,
Expand Down Expand Up @@ -115,11 +117,13 @@ def __init__(
workspace: str | None = None,
auth: TokenProvider | str | None = None,
retry: RetryPolicy | None = None,
default_headers: Mapping[str, str] | None = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._workspace = workspace
self._auth: TokenProvider | None = StaticToken(auth) if isinstance(auth, str) else auth
self._retry = retry
self._default_headers = dict(default_headers) if default_headers else {}

@property
def base_url(self) -> str:
Expand Down Expand Up @@ -158,6 +162,8 @@ def _resolve_path(self, request: PreparedRequest) -> str:

def _request_headers(self, request: PreparedRequest) -> dict[str, str] | None:
headers: dict[str, str] = {}
if self._default_headers:
headers.update(self._default_headers)
if request.content_type is not None:
headers["Content-Type"] = request.content_type
if request.extra_headers:
Expand All @@ -174,32 +180,30 @@ def _is_paginated(self, request: PreparedRequest) -> bool:
return get_origin(request.response_type) is Paginated

def _resolve_query_params(self, request: PreparedRequest) -> dict[str, str | int | bool] | None:
"""Filter out None values from query params for httpx."""
"""Filter out None values and JSON-serialize dicts/lists in query params."""
if request.query_params is None:
return None
filtered = {k: v for k, v in request.query_params.items() if v is not None}
filtered = {}
for k, v in request.query_params.items():
if v is None:
continue
if isinstance(v, (dict, list)):
filtered[k] = json.dumps(v)
else:
filtered[k] = v
return filtered or None

def _apply_client_options(self, request: PreparedRequest, response: NemoResponse) -> NemoResponse:
"""Apply blessed client options (e.g. ``exist_ok``) to the response.
def _raise_for_status(self, raw: httpx.Response, request: PreparedRequest) -> None:
"""Raise on non-2xx, unless a client option suppresses the error.

Options are stashed on ``PreparedRequest.client_options`` by the
endpoint decorator and applied here after the HTTP call completes.
For example, ``exist_ok=True`` swallows 409 Conflict.
"""
if not request.client_options:
return response

if request.client_options.get("exist_ok"):
if response.http_response.status_code == 409:
body = response.body
if body is None and request.response_type is not None:
try:
body = request.response_type.model_validate(response.http_response.json())
except (ValueError, TypeError):
pass
return NemoResponse(http_response=response.http_response, body=body, request=request)

return response
try:
raise_for_status(raw)
except ConflictError:
if request.client_options and request.client_options.get("exist_ok"):
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
raise


class NemoClient(BaseNemoClient):
Expand All @@ -216,7 +220,9 @@ def __init__(
retry: RetryPolicy | None = None,
http_client: httpx.Client | None = None,
) -> None:
super().__init__(base_url=base_url, workspace=workspace, auth=auth, retry=retry)
super().__init__(
base_url=base_url, workspace=workspace, auth=auth, retry=retry, default_headers=default_headers
)
self._http = http_client or httpx.Client(
headers=dict(default_headers) if default_headers else None,
timeout=timeout,
Expand Down Expand Up @@ -339,11 +345,11 @@ def send(
)

raw = self._request_with_retry(request, url, req_headers, params, resolved_retry)
self._raise_for_status(raw, request)
body = None
if raw.is_success and request.response_type is not None:
if request.response_type is not None:
body = request.response_type.model_validate(raw.json())
response = NemoResponse(http_response=raw, body=body, request=request)
return self._apply_client_options(request, response)
return NemoResponse(http_response=raw, body=body, request=request)

def _request_with_retry(
self,
Expand Down Expand Up @@ -408,7 +414,9 @@ def __init__(
retry: RetryPolicy | None = None,
http_client: httpx.AsyncClient | None = None,
) -> None:
super().__init__(base_url=base_url, workspace=workspace, auth=auth, retry=retry)
super().__init__(
base_url=base_url, workspace=workspace, auth=auth, retry=retry, default_headers=default_headers
)
self._http = http_client or httpx.AsyncClient(
headers=dict(default_headers) if default_headers else None,
timeout=timeout,
Expand Down Expand Up @@ -523,11 +531,11 @@ async def send(
)

raw = await self._request_with_retry(request, url, req_headers, params, resolved_retry)
self._raise_for_status(raw, request)
body = None
if raw.is_success and request.response_type is not None:
if request.response_type is not None:
body = request.response_type.model_validate(raw.json())
response = NemoResponse(http_response=raw, body=body, request=request)
return self._apply_client_options(request, response)
return NemoResponse(http_response=raw, body=body, request=request)

async def _request_with_retry(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def _build_prepared_request(
elif name == "body":
if not isinstance(value, BaseModel):
raise TypeError(f"body must be a BaseModel instance, got {type(value).__name__}")
content = value.model_dump_json().encode()
content = value.model_dump_json(exclude_unset=True).encode()
content_type = "application/json"
elif name == "content":
content = value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""HTTP error hierarchy for the NemoClient.

Provides :class:`NemoHTTPError` and status-code-specific subclasses
(e.g. :class:`NotFoundError`, :class:`ConflictError`) raised by
:func:`raise_for_status` on non-2xx responses.
"""

from __future__ import annotations

import httpx


class NemoHTTPError(Exception):
"""Raised on non-2xx HTTP responses.

Attributes:
http_response: The raw httpx response.
status_code: The HTTP status code.
detail: A human-readable error message extracted from the response
body (``{"detail": "..."}`` convention used by FastAPI / NeMo
Platform), or the raw response text as a fallback.
body: The parsed JSON response body, or None.
"""

def __init__(self, http_response: httpx.Response) -> None:
self.http_response = http_response
self.status_code = http_response.status_code
self.detail = self._extract_detail(http_response)
self.body = self._extract_body(http_response)
super().__init__(f"HTTP {self.status_code}: {self.detail}")

@staticmethod
def _extract_body(resp: httpx.Response) -> object | None:
try:
return resp.json()
except Exception:
return None

@staticmethod
def _extract_detail(resp: httpx.Response) -> str:
try:
body = resp.json()
if isinstance(body, dict) and isinstance(body.get("detail"), str):
return body["detail"]
except Exception:
pass
try:
return resp.text
except Exception:
return resp.reason_phrase or f"HTTP {resp.status_code}"


# ---------------------------------------------------------------------------
# Status-code-specific errors
# ---------------------------------------------------------------------------


class BadRequestError(NemoHTTPError):
"""HTTP 400"""
Comment thread
matthewgrossman marked this conversation as resolved.


class AuthenticationError(NemoHTTPError):
"""HTTP 401"""


class PermissionDeniedError(NemoHTTPError):
"""HTTP 403"""


class NotFoundError(NemoHTTPError):
"""HTTP 404"""


class ConflictError(NemoHTTPError):
"""HTTP 409"""


class UnprocessableEntityError(NemoHTTPError):
"""HTTP 422"""


class RateLimitError(NemoHTTPError):
"""HTTP 429"""


class InternalServerError(NemoHTTPError):
"""HTTP 500+"""


_STATUS_CODE_TO_ERROR: dict[int, type[NemoHTTPError]] = {
400: BadRequestError,
401: AuthenticationError,
403: PermissionDeniedError,
404: NotFoundError,
409: ConflictError,
422: UnprocessableEntityError,
429: RateLimitError,
500: InternalServerError,
}


def raise_for_status(http_response: httpx.Response) -> None:
"""Raise status-code-specific NemoHTTPError subclass for non-2xx responses."""
if 200 <= http_response.status_code < 300:
return
error_cls = _STATUS_CODE_TO_ERROR.get(http_response.status_code, NemoHTTPError)
if error_cls is NemoHTTPError and http_response.status_code >= 500:
error_cls = InternalServerError
raise error_cls(http_response)
Loading
Loading