Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
28 changes: 4 additions & 24 deletions src/layerv_qurl/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,7 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]:
return body


# ---- Spec-derived input validation --------------------------------------
# These mirror the constraints documented on each request schema in
# qurl/api/openapi.yaml so obvious mistakes fail fast with a ValueError
# instead of round-tripping to the API and coming back as a generic 400.
# ---- Spec-derived input validation (mirrors openapi.yaml constraints) ----

MAX_TARGET_URL = 2048
MAX_LABEL = 500
Expand Down Expand Up @@ -410,13 +407,7 @@ def parse_quota(data: dict[str, Any]) -> Quota:
total_accesses=usage_data.get("total_accesses", 0),
)
return Quota(
# Fall back to the same sentinel the dataclass default uses
# (see ``Quota.plan`` in types.py) so a malformed API response
# that omits the field produces a consistent "not-yet-populated"
# value regardless of whether the Quota was constructed via
# parse_quota or directly. In practice the /v1/quota endpoint
# always returns a populated plan string, so this fallback is
# only hit for malformed responses or internal bootstrap paths.
# Match the Quota dataclass default for consistency on malformed responses.
plan=data.get("plan", "unknown"),
period_start=_parse_dt(data.get("period_start")),
period_end=_parse_dt(data.get("period_end")),
Expand Down Expand Up @@ -457,22 +448,14 @@ def _validate_batch_create_shape(data: Any) -> None:
"""

def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> ValidationError:
# DEBUG log carries structural hints (type + top-level key names
# only — JSON keys come from the published schema, not user
# data) so operators can triage shape-guard trips without
# leaking raw body content into logs.
logger.debug(
"batch_create shape guard tripped: %s (type=%s, top_level_keys=%s)",
reason,
type(data).__name__,
top_level_keys,
)
# Uses `ValidationError` (subclass) not bare `QURLError` so
# `except ValidationError` catches shape-guard trips; `code=
# "unexpected_response"` distinguishes from client-side
# preflight (`client_validation`). `status=0` is the SDK
# convention for all client-detected failures (not real HTTP
# status). See qurl-typescript's `unexpectedResponseError`.
# ValidationError with code="unexpected_response" (not "client_validation")
# so callers can distinguish server shape mismatches from preflight errors.
return ValidationError(
status=0,
code="unexpected_response",
Expand Down Expand Up @@ -671,9 +654,6 @@ def build_list_params(
raise ValueError(
f"limit: must be an integer between 1 and 100 (got {limit})"
)
# ``status`` is a QURLStatus (Literal | str) — covered by the ``str`` arm.
# Ordered most-specific to least-specific: datetime/int are concrete
# types, str is the widening arm, None is the "drop" sentinel.
pairs: dict[str, datetime | int | str | None] = {
"limit": limit,
"cursor": cursor,
Expand Down
41 changes: 6 additions & 35 deletions src/layerv_qurl/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,12 @@ def __init__(
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._max_retries = max_retries
self._user_agent = user_agent or default_user_agent()
self._client = http_client or httpx.AsyncClient(timeout=timeout)
self._owns_client = http_client is None
self._base_headers: dict[str, str] = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": self._user_agent,
"User-Agent": user_agent or default_user_agent(),
}

def __repr__(self) -> str:
Expand Down Expand Up @@ -517,10 +516,6 @@ async def batch_create(
) from exc
except ValueError as exc:
raise ValueError(f"batch_create items[{i}]: {exc}") from exc
# `BatchCreateItem` is structurally a `dict[str, Any]` at runtime —
# TypedDicts compile to plain dicts and carry no runtime overhead.
# The `cast` narrows the type for `build_body` without any runtime
# conversion.
serialized = [build_body(cast("dict[str, Any]", item)) for item in items]
# HTTP 400 carries structured per-item errors on this endpoint —
# whitelist it so the generic error path doesn't swallow the body.
Expand Down Expand Up @@ -589,35 +584,11 @@ async def _raw_request(
) -> tuple[Any, dict[str, Any] | None]:
"""Issue an HTTP request and parse the JSON envelope.

``allow_statuses`` lets a caller opt specific non-2xx codes out of
the default raise-on-error path and receive the parsed body
instead. This is used by :meth:`batch_create`, where the API
returns a structured ``BatchCreateOutput`` on HTTP 400 (all items
rejected) — raising would drop the per-item errors.

**`allow_statuses` takes precedence over retries.** The check
order in the response-handling loop is:

1. ``response.status_code < 400 or in allow_statuses`` →
return the parsed body immediately as a success.
2. Otherwise, build an error and check the retry filter
(``RETRYABLE_STATUS_POST`` for POST, ``RETRYABLE_STATUS``
for everything else).

This means a status listed in ``allow_statuses`` is returned
to the caller **without ever running through the retry
filter**, even if that status would normally be retried. For
the only current use case (``batch_create`` with
``allow_statuses=(400,)``) the interaction is harmless because
400 isn't in any retry set — a 400 carries the authoritative
per-item errors and retrying would just reproduce them.

Callers adding a *retryable* status (e.g. 429 or 5xx) to
``allow_statuses`` should be aware this bypasses the SDK's
retry path entirely: the status is surfaced on the first
attempt with no transparent backoff. If that's not what you
want, leave the status out of ``allow_statuses`` and let the
normal retry logic handle it.
``allow_statuses`` opts specific non-2xx codes out of the
raise-on-error path, returning the parsed body instead. Used by
:meth:`batch_create` (``allow_statuses=(400,)``) so per-item
errors aren't swallowed. Allowed statuses bypass the retry
filter entirely — they're returned on the first attempt.
"""
url = f"{self._base_url}{path}"
last_error: Exception | None = None
Expand Down
41 changes: 6 additions & 35 deletions src/layerv_qurl/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,12 @@ def __init__(
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._max_retries = max_retries
self._user_agent = user_agent or default_user_agent()
self._client = http_client or httpx.Client(timeout=timeout)
self._owns_client = http_client is None
self._base_headers: dict[str, str] = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": self._user_agent,
"User-Agent": user_agent or default_user_agent(),
}

def __repr__(self) -> str:
Expand Down Expand Up @@ -531,10 +530,6 @@ def batch_create(
) from exc
except ValueError as exc:
raise ValueError(f"batch_create items[{i}]: {exc}") from exc
# `BatchCreateItem` is structurally a `dict[str, Any]` at runtime —
# TypedDicts compile to plain dicts and carry no runtime overhead.
# The `cast` narrows the type for `build_body` without any runtime
# conversion.
serialized = [build_body(cast("dict[str, Any]", item)) for item in items]
# HTTP 400 carries structured per-item errors on this endpoint —
# whitelist it so the generic error path doesn't swallow the body.
Expand Down Expand Up @@ -603,35 +598,11 @@ def _raw_request(
) -> tuple[Any, dict[str, Any] | None]:
"""Issue an HTTP request and parse the JSON envelope.

``allow_statuses`` lets a caller opt specific non-2xx codes out of
the default raise-on-error path and receive the parsed body
instead. This is used by :meth:`batch_create`, where the API
returns a structured ``BatchCreateOutput`` on HTTP 400 (all items
rejected) — raising would drop the per-item errors.

**`allow_statuses` takes precedence over retries.** The check
order in the response-handling loop is:

1. ``response.status_code < 400 or in allow_statuses`` →
return the parsed body immediately as a success.
2. Otherwise, build an error and check the retry filter
(``RETRYABLE_STATUS_POST`` for POST, ``RETRYABLE_STATUS``
for everything else).

This means a status listed in ``allow_statuses`` is returned
to the caller **without ever running through the retry
filter**, even if that status would normally be retried. For
the only current use case (``batch_create`` with
``allow_statuses=(400,)``) the interaction is harmless because
400 isn't in any retry set — a 400 carries the authoritative
per-item errors and retrying would just reproduce them.

Callers adding a *retryable* status (e.g. 429 or 5xx) to
``allow_statuses`` should be aware this bypasses the SDK's
retry path entirely: the status is surfaced on the first
attempt with no transparent backoff. If that's not what you
want, leave the status out of ``allow_statuses`` and let the
normal retry logic handle it.
``allow_statuses`` opts specific non-2xx codes out of the
raise-on-error path, returning the parsed body instead. Used by
:meth:`batch_create` (``allow_statuses=(400,)``) so per-item
errors aren't swallowed. Allowed statuses bypass the retry
filter entirely — they're returned on the first attempt.
"""
url = f"{self._base_url}{path}"
last_error: Exception | None = None
Expand Down
Loading