Skip to content
Open
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
11 changes: 6 additions & 5 deletions .github/workflows/spec-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
env:
# Pinned (keep in sync with CONTRIBUTING.md); never derived from openapi.json,
# so a tampered vendored spec cannot point the check at a mirror that hides it.
SPEC_URL: https://api.ionq.co/v0.4/api-docs
Comment on lines +16 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What attack is this defending against? openapi.json is part of this repository. If an attack can edit the repository, nothing in the repository can defend against it.

I don't mind the simplification, but on the face of it this doesn't seem like a security issue.

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch latest spec
run: |
BASE_URL=$(jq -r '.servers[0].url' openapi.json)
echo "BASE_URL=${BASE_URL}" >> "$GITHUB_ENV"
curl -sf "${BASE_URL}/api-docs" -o /tmp/latest-spec.json
run: curl -sf "$SPEC_URL" -o /tmp/latest-spec.json
- name: Check for drift
id: drift
run: |
Expand All @@ -35,7 +36,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
{
echo "The spec at ${BASE_URL}/api-docs has diverged from the vendored openapi.json. Fetch the new spec and regenerate the client."
echo "The spec at ${SPEC_URL} has diverged from the vendored openapi.json. Fetch the new spec and regenerate the client."
printf '\n<details><summary>Diff (sorted, pretty-printed JSON)</summary>\n\n```diff\n'
head -c 60000 /tmp/spec.diff
[[ $(wc -c < /tmp/spec.diff) -gt 60000 ]] && printf '\n... (truncated)\n'
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Security

- Generated endpoints now reject the path-parameter values `""`, `"."`, and `".."` (raising `ValueError`) before any request is built. `urllib.parse.quote` never encodes dots, so an attacker-supplied identifier like `".."` previously survived into the URL and deleted a fixed path segment under RFC 3986 normalization (e.g. `/sessions/../jobs` -> `/jobs`), redirecting session-scoped reads to account-wide ones.
- `QctrlQaoaJobCreationPayloadExternalSettings.api_credentials` (a Q-CTRL API key) is now excluded from the attrs-generated `repr`, so logging or echoing a job payload can no longer disclose it. `to_dict()` and the wire format are unchanged.
- `AuthenticatedClient` no longer writes the `Authorization` value into its repr-visible, caller-owned headers dict when the httpx clients are built; the credential now lives only on the httpx clients themselves. `repr(client)` stays token-free after use, and a headers dict shared with other clients is no longer contaminated with the key.
- `RateLimitError.retry_after` is now validated by the default transport: values are clamped to at most 300 seconds and non-finite values (`inf`, `nan`, overflowing forms like `1e309`) are treated as absent, so a forged `Retry-After` header cannot drive callers that sleep on it into an unbounded wait or an `OverflowError`.
- The default transport now reads at most 64 KiB (decoded) of an error-response body instead of materializing the whole, transparently decompressed body, preventing memory exhaustion from compression-bomb error responses.
- `verify_ssl` passed to `IonQClient` is now applied to the underlying sync and async httpx transports. Previously the value was silently ignored (httpx disregards client-level `verify` when a custom transport is supplied), so custom CA bundles and pinned `ssl.SSLContext` objects had no effect and `verify_ssl=False` did not actually disable verification.
- The pagination helpers (`iter_jobs`, `aiter_jobs`, `iter_session_jobs`, `aiter_session_jobs`) now raise `IonQError` when the server-supplied `next` cursor is empty or repeats a previously seen cursor, instead of issuing authenticated requests in an unbounded loop.
- The weekly spec-drift workflow fetches the upstream spec from a URL pinned in the workflow instead of one derived from the vendored `openapi.json`, so a tampered spec can no longer point the drift check at a mirror that hides the tampering.

### Added

- `QctrlQaoaJobCreationPayload` and `QctrlQaoaJobInput` for submitting Q-CTRL QAOA maxcut combinatorial-optimization jobs via `create_job`. The `create_job` body union now also accepts `QctrlQaoaJobCreationPayload`.
Expand All @@ -17,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Changed

- POST requests are no longer retried automatically by the default transport. The API has no idempotency-key mechanism, so replaying `create_job` / `create_session` / `end_session` after an ambiguous gateway 5xx could duplicate billable work; idempotent methods retry as before. Callers that want POST retries must supply their own transport and handle deduplication.
- `NativeCircuitInput.qubits` and `JsonMultiCircuitInput.qubits` are now `int | Unset` (previously `float | Unset`), matching upstream's tightening to `format: int32, minimum: 1`. `QisCircuitInput.qubits` already had this type locally via the OpenAPI overlay; that overlay action has been removed now that upstream is correct natively.
- Regenerated with `openapi-python-client` 0.29.0. Generated models now parse timestamps with the standard library (`datetime.fromisoformat`) instead of `dateutil.parser.isoparse`.

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ uv run openapi-python-client generate \
--overwrite
```

Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` from `repr`, and run `ruff` fix-and-format.
Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` and the Q-CTRL `api_credentials` field from `repr`, keep the `Authorization` header out of repr-visible client state, route path parameters through `ionq_core._url.quote_path_param`, and run `ruff` fix-and-format.

Commit the regenerated files alongside the spec or template change that caused them. Spec drift is checked weekly by [`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if `openapi.json` falls behind upstream.

Expand Down
129 changes: 94 additions & 35 deletions ionq_core/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@

"""Transport layer: retry via httpx-retries, error raising for IonQ API responses.

This module provides the `ErrorRaisingTransport` that wraps httpx transports
to convert HTTP error responses and connection failures into structured
`IonQError` exceptions. The `build_transport` factory creates the default
transport stack: ``RetryTransport`` (from httpx-retries) wrapped by
``ErrorRaisingTransport``.

The default retry configuration retries on status codes 429, 500, 502, 503,
and 520-529 with exponential backoff (factor 0.5, jitter 0.5, max 60s).
`ErrorRaisingTransport` converts HTTP error responses and connection failures
into structured `IonQError` exceptions; `build_transport` assembles the default
stack used by `IonQClient`. Idempotent methods are retried on status codes 429,
500, 502, 503, and 520-529 with exponential backoff (factor 0.5, jitter 0.5,
max 60s); POST is never retried because the API has no idempotency keys, so a
replay after an ambiguous 5xx could duplicate billable work.

Error handling bounds what it trusts from the server: at most
`MAX_ERROR_BODY_BYTES` decoded bytes of an error body are read, and
``Retry-After`` is clamped to `MAX_RETRY_AFTER` seconds (non-finite values are
discarded) before being exposed on `RateLimitError.retry_after`.
Comment on lines +6 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`ErrorRaisingTransport` converts HTTP error responses and connection failures
into structured `IonQError` exceptions; `build_transport` assembles the default
stack used by `IonQClient`. Idempotent methods are retried on status codes 429,
500, 502, 503, and 520-529 with exponential backoff (factor 0.5, jitter 0.5,
max 60s); POST is never retried because the API has no idempotency keys, so a
replay after an ambiguous 5xx could duplicate billable work.
Error handling bounds what it trusts from the server: at most
`MAX_ERROR_BODY_BYTES` decoded bytes of an error body are read, and
``Retry-After`` is clamped to `MAX_RETRY_AFTER` seconds (non-finite values are
discarded) before being exposed on `RateLimitError.retry_after`.
This module provides the `ErrorRaisingTransport` that wraps httpx transports
to convert HTTP error responses and connection failures into structured
`IonQError` exceptions. The `build_transport` factory creates the default
transport stack: ``RetryTransport`` (from httpx-retries) wrapped by
``ErrorRaisingTransport``.
The default retry configuration retries on status codes 429, 500, 502, 503,
and 520-529 with exponential backoff (factor 0.5, jitter 0.5, max 60s).
POST, PATCH and other HTTP methods not documented as idempotent are never retried.
Error handling limits the length of the body read from the server to `MAX_ERROR_BODY_BYTES` and limits the maximum ``Retry-After`` delay to at move `MAX_RETRY_AFTER` seconds. Non-finite ``Retry-After`` values are ignored

The original text reads much better, so I manually merged the two.

"""

import json
import math
import ssl

import httpx
from httpx_retries import Retry, RetryTransport

Expand All @@ -24,38 +31,86 @@
DEFAULT_MAX_RETRIES: int = 2
"""Default number of retry attempts for transient errors."""

MAX_RETRY_AFTER: float = 300.0
"""Cap (seconds) on the server-supplied ``Retry-After``: callers are documented
to sleep on `RateLimitError.retry_after`, so a forged header must stay bounded."""

MAX_ERROR_BODY_BYTES: int = 64 * 1024
"""Maximum decoded bytes read from an error response body."""


def _read_error_body(response: httpx.Response) -> bytes:
"""Read at most `MAX_ERROR_BODY_BYTES` decoded bytes of an error body.

Streaming with a cap (instead of ``response.read()``) keeps a small
compressed body from inflating without limit in client memory: httpx
transparently applies whatever ``Content-Encoding`` the server chose.
"""
body = bytearray()
try:
for chunk in response.iter_bytes():
body += chunk
if len(body) >= MAX_ERROR_BODY_BYTES:
break
finally:
response.close()
return bytes(body[:MAX_ERROR_BODY_BYTES])


async def _aread_error_body(response: httpx.Response) -> bytes:
"""Async variant of `_read_error_body`."""
body = bytearray()
try:
async for chunk in response.aiter_bytes():
body += chunk
if len(body) >= MAX_ERROR_BODY_BYTES:
break
finally:
await response.aclose()
return bytes(body[:MAX_ERROR_BODY_BYTES])

def _raise_for_response(response: httpx.Response) -> None:

def _raise_for_response(response: httpx.Response, content: bytes) -> None:
try:
body: dict | str | None = response.json()
body: dict | str | None = json.loads(content)
except (ValueError, UnicodeDecodeError):
# json.JSONDecodeError subclasses ValueError; UnicodeDecodeError covers
# bodies that aren't decodable in the declared (or guessed) encoding.
body = (response.text or "")[:500] or None
body = content.decode(response.encoding or "utf-8", errors="replace")[:500] or None
message = (body.get("message") or body.get("error")) if isinstance(body, dict) else None
try:
retry_after = max(0.0, float(response.headers["retry-after"]))
parsed = float(response.headers["retry-after"])
except (KeyError, ValueError):
retry_after = None
else:
# float() accepts "inf" and overflow forms like "1e309"; a non-finite
# value is garbage, not advice, so treat it as absent.
retry_after = min(max(parsed, 0.0), MAX_RETRY_AFTER) if math.isfinite(parsed) else None
raise_for_status(response.status_code, body, retry_after, message, request_id=response.headers.get("x-request-id"))


class ErrorRaisingTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
"""Wraps a transport to raise structured IonQ exceptions on error responses.

For HTTP 4xx/5xx responses, reads the response body and raises the
appropriate `APIError` subclass. For connection and timeout errors from
httpx, raises `APIConnectionError` or `APITimeoutError` respectively.
For HTTP 4xx/5xx responses, reads the response body (capped at
`MAX_ERROR_BODY_BYTES` decoded bytes) and raises the appropriate
`APIError` subclass. For connection and timeout errors from httpx,
raises `APIConnectionError` or `APITimeoutError` respectively.

This class implements both sync and async transport interfaces so a
single instance works with both ``httpx.Client`` and ``httpx.AsyncClient``.

Args:
transport: The inner transport to wrap (typically a ``RetryTransport``).
transport: Inner transport for sync requests (typically a
``RetryTransport``).
async_transport: Inner transport for async requests; defaults to
``transport``. Separate inners let `build_transport` set TLS
options, which live on distinct sync/async httpx transports.
"""

def __init__(self, transport) -> None:
def __init__(self, transport, async_transport=None) -> None:
self._transport = transport
self._async_transport = async_transport if async_transport is not None else transport

def handle_request(self, request: httpx.Request) -> httpx.Response:
try:
Expand All @@ -65,57 +120,61 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
except httpx.HTTPError as exc:
raise APIConnectionError(f"{type(exc).__name__}: {exc}") from exc
if response.status_code >= 400:
response.read()
_raise_for_response(response)
_raise_for_response(response, _read_error_body(response))
return response

async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
try:
response = await self._transport.handle_async_request(request)
response = await self._async_transport.handle_async_request(request)
except httpx.TimeoutException as exc:
raise APITimeoutError(str(exc)) from exc
except httpx.HTTPError as exc:
raise APIConnectionError(f"{type(exc).__name__}: {exc}") from exc
if response.status_code >= 400:
await response.aread()
_raise_for_response(response)
_raise_for_response(response, await _aread_error_body(response))
return response

def close(self) -> None:
self._transport.close()

async def aclose(self) -> None:
await self._transport.aclose()
await self._async_transport.aclose()


def build_transport(
max_retries: int = DEFAULT_MAX_RETRIES,
retryable_status_codes: frozenset[int] = RETRYABLE_STATUS_CODES,
verify: ssl.SSLContext | str | bool = True,
) -> ErrorRaisingTransport:
"""Build the default transport stack for `IonQClient`.

Creates a ``RetryTransport`` (from httpx-retries) with exponential
Creates ``RetryTransport``s (from httpx-retries) with exponential
backoff, wrapped by `ErrorRaisingTransport` for structured error handling.

Args:
max_retries: Maximum number of retry attempts. Defaults to
`DEFAULT_MAX_RETRIES` (2).
retryable_status_codes: HTTP status codes that trigger a retry.
Defaults to `RETRYABLE_STATUS_CODES`.
verify: TLS verification (``True``/``False``, a CA bundle path, or an
``ssl.SSLContext``) applied to the underlying transports; httpx
ignores client-level ``verify`` when a custom transport is
supplied, so it must be configured here to take effect.

Returns:
A configured `ErrorRaisingTransport` ready to be passed to an
httpx client.
httpx client (sync or async).
"""
retry = Retry(
total=max_retries,
backoff_factor=0.5,
backoff_jitter=0.5,
max_backoff_wait=60.0,
status_forcelist=retryable_status_codes,
# POST is deliberately not retryable: without idempotency keys, a replay
# after an ambiguous 5xx could duplicate billable jobs.
Comment on lines +174 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# POST is deliberately not retryable: without idempotency keys, a replay
# after an ambiguous 5xx could duplicate billable jobs.

This comment feels out of place. The Retry class should document its own behaviour.

)
return ErrorRaisingTransport(
RetryTransport(
retry=Retry(
total=max_retries,
backoff_factor=0.5,
backoff_jitter=0.5,
max_backoff_wait=60.0,
status_forcelist=retryable_status_codes,
allowed_methods=Retry.RETRYABLE_METHODS | {"POST"},
)
)
RetryTransport(transport=httpx.HTTPTransport(verify=verify), retry=retry),
RetryTransport(transport=httpx.AsyncHTTPTransport(verify=verify), retry=retry),
)
24 changes: 24 additions & 0 deletions ionq_core/_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# SPDX-FileCopyrightText: 2026 IonQ, Inc.
# SPDX-License-Identifier: Apache-2.0

"""URL path-parameter encoding for the generated endpoint modules, wired in by
a post-generation hook in ``openapi-python-client-config.yaml``."""

from urllib.parse import quote


def quote_path_param(value: object) -> str:
"""Percent-encode ``value`` as a single URL path segment.

Rejects ``""``, ``"."``, and ``".."``: ``quote`` never encodes dots, so
those values would survive into the URL verbatim and collapse a fixed path
segment under RFC 3986 normalization (e.g. ``/sessions/../jobs`` ->
``/jobs``, turning a session-scoped request into an account-wide one).

Raises:
ValueError: If the value is ``""``, ``"."``, or ``".."``.
"""
segment = str(value)
if segment in ("", ".", ".."):
raise ValueError(f"Invalid URL path parameter {segment!r}: it would escape its path segment")
return quote(segment, safe="")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is / not safe?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea here seems to be that when constructing a URL from client supplied data, a common expectation might be that the path structure of the URL (i.e. the sequence of slashes) should not be controlled by the client data.

I don't know whether this is an issue in practice for our own APIs or whether I really find this solution compelling. For external facing services, an attacker can also just construct the URL and call it themselves. For internal services we should be very careful about including client controlled data in URLs and this monkeypatching of quote only sweeps the larger issue under the rug.

4 changes: 2 additions & 2 deletions ionq_core/api/backends/get_backend.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ionq_core/api/backends/get_backends.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions ionq_core/api/characterizations/get_characterization.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions ionq_core/api/default/cancel_job.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ionq_core/api/default/cancel_jobs.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading