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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- `CostModel` model, replaced by `ApiCostModel`.
- The `python-dateutil` runtime dependency, no longer needed now that generated code uses `datetime.fromisoformat`.

### Security

- `verify_ssl` passed to `IonQClient` now reaches the transports that actually open connections. Previously the injected retry transport caused httpx to silently ignore it, so a custom `ssl.SSLContext` (e.g. certificate pinning or a private CA) fell back to the system trust store without warning.
- POST requests (`create_job`, `clone_job`, `create_session`, `end_session`) are no longer re-sent after a retryable HTTP status (429/5xx) or a mid-request network failure, either of which could silently duplicate a billable job or session. POSTs now retry only on connection failures raised before the request is sent (connection refused/timeout, pool timeout); idempotent methods retry as before.
- `repr()` of `Client` and `AuthenticatedClient` no longer includes the default-headers dict, which contains the `Authorization: apiKey <token>` credential once the underlying httpx client has been created.
- `iter_jobs`, `aiter_jobs`, `iter_session_jobs`, and `aiter_session_jobs` now raise `IonQError` if the server repeats a pagination cursor or the page count exceeds the new `max_pages` argument (default 10,000; pass `None` to disable), so a misbehaving server can no longer force an unbounded fetch loop.

## [0.1.1] - 2026-04-30

### Changed
Expand Down
146 changes: 124 additions & 22 deletions ionq_core/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,42 @@
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).
transport stack: socket-opening httpx transports (carrying the caller's TLS
verification config) wrapped by two ``RetryTransport`` layers (from
httpx-retries) wrapped by ``ErrorRaisingTransport``.

Idempotent methods are retried on status codes 429, 500, 502, 503, and
520-529 as well as on transient network failures, with exponential backoff
(factor 0.5, jitter 0.5, max 60s). POST requests are never re-sent once the
request may have reached the server - the IonQ API's POST endpoints create
jobs and sessions, so a transparent re-send can duplicate a billable
submission - and instead retry only on `PRE_SEND_EXCEPTIONS`.
"""

import ssl

import httpx
from httpx_retries import Retry, RetryTransport

from .exceptions import APIConnectionError, APITimeoutError, raise_for_status

RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, *range(520, 530)})
"""HTTP status codes that trigger an automatic retry."""
"""HTTP status codes that trigger an automatic retry (idempotent methods only)."""

DEFAULT_MAX_RETRIES: int = 2
"""Default number of retry attempts for transient errors."""

PRE_SEND_EXCEPTIONS: tuple[type[httpx.HTTPError], ...] = (
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.PoolTimeout,
)
"""Failures raised before any request bytes reach the server.

Retrying on these cannot duplicate a server-side effect, so they are safe
to retry even for non-idempotent methods like POST.
"""


def _raise_for_response(response: httpx.Response) -> None:
try:
Expand Down Expand Up @@ -88,34 +106,118 @@ async def aclose(self) -> None:
await self._transport.aclose()


class DualTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
"""Pairs a sync and an async transport behind both httpx interfaces.

httpx ignores its ``verify`` argument when an explicit transport is
supplied, so the transports that actually open sockets must be
constructed with the caller's TLS configuration themselves. This class
lets `build_transport` hand a single object to both ``httpx.Client``
and ``httpx.AsyncClient``.

Args:
sync_transport: Transport used for synchronous requests.
async_transport: Transport used for asynchronous requests.
"""

def __init__(self, sync_transport: httpx.BaseTransport, async_transport: httpx.AsyncBaseTransport) -> None:
self._sync_transport = sync_transport
self._async_transport = async_transport

def handle_request(self, request: httpx.Request) -> httpx.Response:
return self._sync_transport.handle_request(request)

async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
return await self._async_transport.handle_async_request(request)

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

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


class NonIdempotentRetry(Retry):
"""Retry policy that never re-sends a request once a response arrived.

A response with a status in `RETRYABLE_STATUS_CODES` (e.g. a
load-balancer 503) can be returned after the server has already
committed the request's side effect. Re-sending a POST at that point
can duplicate a billable job or session, so this policy declares every
status code non-retryable and relies on ``retry_on_exceptions``
(`PRE_SEND_EXCEPTIONS`) to bound retries to failures where the request
never left the client.
"""

def is_retryable_status_code(self, status_code: int) -> bool:
return False


def _retry_stack(
transport: httpx.BaseTransport | httpx.AsyncBaseTransport,
max_retries: int,
retryable_status_codes: frozenset[int],
) -> RetryTransport:
"""Wrap ``transport`` (implementing both httpx interfaces) in the two-policy retry stack.

``RetryTransport`` applies its policy only to methods in
``allowed_methods`` and forwards everything else untouched, which gives a
per-method split: the outer layer handles only POST and retries only
`PRE_SEND_EXCEPTIONS`, while every other method falls through to the
inner layer, which retries ``retryable_status_codes`` and transient
network failures for idempotent methods.
"""
idempotent = RetryTransport(
transport=transport,
retry=Retry(
total=max_retries,
backoff_factor=0.5,
backoff_jitter=0.5,
max_backoff_wait=60.0,
status_forcelist=retryable_status_codes,
),
)
return RetryTransport(
transport=idempotent,
retry=NonIdempotentRetry(
total=max_retries,
backoff_factor=0.5,
backoff_jitter=0.5,
max_backoff_wait=60.0,
allowed_methods=("POST",),
retry_on_exceptions=PRE_SEND_EXCEPTIONS,
),
)


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

Creates a ``RetryTransport`` (from httpx-retries) with exponential
backoff, wrapped by `ErrorRaisingTransport` for structured error handling.
The socket-opening httpx transports are constructed here with ``verify``
because httpx ignores its own ``verify`` argument once a transport is
supplied, making this the only place the caller's TLS configuration takes
effect. They are wrapped by two ``RetryTransport`` layers (POST retries
only on pre-send connection failures; idempotent methods also retry
``retryable_status_codes``) and 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`.
retryable_status_codes: HTTP status codes that trigger a retry of an
idempotent request. Defaults to `RETRYABLE_STATUS_CODES`.
verify: TLS verification applied to the underlying connections:
``True`` (default) uses the system trust store, an
``ssl.SSLContext`` uses the caller's context, ``False`` disables
verification.

Returns:
A configured `ErrorRaisingTransport` ready to be passed to an
httpx client.
"""
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"},
)
)
)
real = DualTransport(httpx.HTTPTransport(verify=verify), httpx.AsyncHTTPTransport(verify=verify))
return ErrorRaisingTransport(_retry_stack(real, max_retries, retryable_status_codes))
5 changes: 2 additions & 3 deletions ionq_core/client.py

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

6 changes: 4 additions & 2 deletions ionq_core/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ class ClientExtension:
event_hooks: Sync `EventHook` instances invoked on every request.
async_event_hooks: Async `AsyncEventHook` instances invoked on
every async request.
retryable_status_codes: HTTP status codes that should trigger a retry.
Overrides the default set (429, 500, 502, 503, 520-529).
retryable_status_codes: HTTP status codes that should trigger a retry
of an idempotent request (POST is never retried after a response
is received). Overrides the default set (429, 500, 502, 503,
520-529).
max_retries: Maximum retry attempts. Overrides the default of 2.
timeout: Request timeout. Overrides the default of 60 seconds.
transport_wrapper: Callable that wraps the sync transport, useful for
Expand Down
17 changes: 11 additions & 6 deletions ionq_core/ionq_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ def IonQClient(
environment variable.
base_url: API base URL. Defaults to the IonQ production API.
max_retries: Maximum retry attempts for transient errors (429, 5xx).
Defaults to 2. Set to 0 to disable retries.
Defaults to 2. Set to 0 to disable retries. POST requests are
retried only on connection failures raised before the request is
sent, never after a response is received, so a retry cannot
duplicate a job or session.
timeout: Request timeout as an ``httpx.Timeout`` instance. Defaults to
60 seconds with a 10-second connect timeout.
additional_user_agent: Extra token appended to the User-Agent header,
Expand Down Expand Up @@ -140,6 +143,7 @@ def IonQClient(
sync_transport = async_transport = build_transport(
effective_retries,
ext.retryable_status_codes or RETRYABLE_STATUS_CODES,
verify=kwargs.get("verify_ssl", True),
)

if ext.event_hooks or ext.error_mapper:
Expand Down Expand Up @@ -173,17 +177,18 @@ def IonQClient(
)
# `set_async_httpx_client` bypasses `AuthenticatedClient`'s lazy auth-header
# injection (see generated `client.py::get_async_httpx_client`), so we merge
# `Authorization` in manually here. The `_verify_ssl` / `_follow_redirects`
# fields are private on the generated `AuthenticatedClient` but are the only
# way to mirror the caller's choices onto the async transport; do not add a
# public accessor in the hand-written layer — they belong to generated code.
# `Authorization` in manually here. TLS verification travels inside
# `async_transport` (httpx ignores `verify=` once a transport is supplied).
# The `_follow_redirects` field is private on the generated
# `AuthenticatedClient` but is the only way to mirror the caller's choice
# onto the async client; do not add a public accessor in the hand-written
# layer — it belongs to generated code.
client.set_async_httpx_client(
httpx.AsyncClient(
base_url=base_url,
headers={**headers, _AUTH_HEADER: f"{_AUTH_PREFIX} {key}"},
timeout=effective_timeout,
transport=async_transport,
verify=client._verify_ssl,
follow_redirects=client._follow_redirects,
)
)
Expand Down
Loading