diff --git a/CHANGELOG.md b/CHANGELOG.md index 706ed79..c58ae1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/ionq_core/_transport.py b/ionq_core/_transport.py index 1550635..fd1f97f 100644 --- a/ionq_core/_transport.py +++ b/ionq_core/_transport.py @@ -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: @@ -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)) diff --git a/ionq_core/client.py b/ionq_core/client.py index 35c8097..fc5327d 100644 --- a/ionq_core/client.py +++ b/ionq_core/client.py @@ -43,7 +43,7 @@ class Client: raise_on_unexpected_status: bool = field(default=False, kw_only=True) _base_url: str = field(alias="base_url") _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") - _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers", repr=False) _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") @@ -173,7 +173,7 @@ class AuthenticatedClient: raise_on_unexpected_status: bool = field(default=False, kw_only=True) _base_url: str = field(alias="base_url") _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") - _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers", repr=False) _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") @@ -272,4 +272,3 @@ async def __aenter__(self) -> "AuthenticatedClient": async def __aexit__(self, *args: Any, **kwargs: Any) -> None: """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" await self.get_async_httpx_client().__aexit__(*args, **kwargs) - diff --git a/ionq_core/extensions.py b/ionq_core/extensions.py index 6babc9e..f43def6 100644 --- a/ionq_core/extensions.py +++ b/ionq_core/extensions.py @@ -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 diff --git a/ionq_core/ionq_client.py b/ionq_core/ionq_client.py index 52d2286..bdcb867 100644 --- a/ionq_core/ionq_client.py +++ b/ionq_core/ionq_client.py @@ -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, @@ -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: @@ -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, ) ) diff --git a/ionq_core/pagination.py b/ionq_core/pagination.py index 5469d7b..8ba781e 100644 --- a/ionq_core/pagination.py +++ b/ionq_core/pagination.py @@ -36,32 +36,63 @@ logger = logging.getLogger("ionq_core") +DEFAULT_MAX_PAGES: int = 10_000 +"""Default page cap for the pagination helpers. -def _paginate(fetch: Callable[..., Any], label: str, *args: Any, **kwargs: Any) -> Iterator[Job]: +The ``next`` cursor is entirely server-controlled, so without a cap a +misbehaving server that always returns a cursor could force the client to +fetch pages forever. Pass ``max_pages=None`` to opt out. +""" + + +def _next_cursor(response: Any, label: str, sent_cursor: Any, pages: int, max_pages: int | None) -> None | str: + """Validate the cursor of a fetched page and return it (``None`` = done).""" + cursor = response.next_ + if cursor is None: + return None + if cursor == sent_cursor: + raise IonQError(f"Server repeated pagination cursor {cursor!r} while fetching {label}") + if max_pages is not None and pages >= max_pages: + raise IonQError( + f"{label} pagination exceeded max_pages={max_pages}; pass a larger max_pages (or None to disable the cap)" + ) + logger.debug("Fetching next page of %s (cursor=%s)", label, cursor) + return cursor + + +def _paginate( + fetch: Callable[..., Any], label: str, *args: Any, max_pages: int | None = DEFAULT_MAX_PAGES, **kwargs: Any +) -> Iterator[Job]: kwargs["next_"] = UNSET + pages = 0 while True: response = fetch(*args, **kwargs) if response is None: raise IonQError(f"Failed to fetch {label}") + pages += 1 yield from response.jobs - if response.next_ is None: + cursor = _next_cursor(response, label, kwargs["next_"], pages, max_pages) + if cursor is None: return - kwargs["next_"] = response.next_ - logger.debug("Fetching next page of %s (cursor=%s)", label, response.next_) + kwargs["next_"] = cursor -async def _apaginate(fetch: Callable[..., Any], label: str, *args: Any, **kwargs: Any) -> AsyncIterator[Job]: +async def _apaginate( + fetch: Callable[..., Any], label: str, *args: Any, max_pages: int | None = DEFAULT_MAX_PAGES, **kwargs: Any +) -> AsyncIterator[Job]: kwargs["next_"] = UNSET + pages = 0 while True: response = await fetch(*args, **kwargs) if response is None: raise IonQError(f"Failed to fetch {label}") + pages += 1 for job in response.jobs: yield job - if response.next_ is None: + cursor = _next_cursor(response, label, kwargs["next_"], pages, max_pages) + if cursor is None: return - kwargs["next_"] = response.next_ - logger.debug("Fetching next page of %s (cursor=%s)", label, response.next_) + kwargs["next_"] = cursor def iter_jobs( @@ -72,6 +103,7 @@ def iter_jobs( session_id: str | Unset = UNSET, submitter_id: str | Unset = UNSET, limit: int | Unset = UNSET, + max_pages: int | None = DEFAULT_MAX_PAGES, ) -> Iterator[Job]: """Iterate over all jobs, automatically following pagination cursors. @@ -83,16 +115,22 @@ def iter_jobs( submitter_id: Filter by submitter user ID. limit: Maximum number of jobs per page (server default applies if unset). + max_pages: Maximum number of pages to fetch before raising, as a + guard against a server that never stops returning cursors. + Defaults to `DEFAULT_MAX_PAGES` (10,000); pass ``None`` to + disable the cap. Yields: Individual job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response. + IonQError: If the API returns a ``None`` response, repeats a + pagination cursor, or exceeds ``max_pages``. """ return _paginate( get_jobs.sync, "jobs", + max_pages=max_pages, client=client, status=status, target=target, @@ -110,6 +148,7 @@ def aiter_jobs( session_id: str | Unset = UNSET, submitter_id: str | Unset = UNSET, limit: int | Unset = UNSET, + max_pages: int | None = DEFAULT_MAX_PAGES, ) -> AsyncIterator[Job]: """Async version of `iter_jobs`. @@ -120,16 +159,21 @@ def aiter_jobs( session_id: Filter by session ID. submitter_id: Filter by submitter user ID. limit: Maximum number of jobs per page. + max_pages: Maximum number of pages to fetch before raising. + Defaults to `DEFAULT_MAX_PAGES` (10,000); ``None`` disables + the cap. Yields: Individual job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response. + IonQError: If the API returns a ``None`` response, repeats a + pagination cursor, or exceeds ``max_pages``. """ return _apaginate( get_jobs.asyncio, "jobs", + max_pages=max_pages, client=client, status=status, target=target, @@ -147,6 +191,7 @@ def iter_session_jobs( target: str | Unset = UNSET, submitter_id: str | Unset = UNSET, limit: int | Unset = UNSET, + max_pages: int | None = DEFAULT_MAX_PAGES, ) -> Iterator[Job]: """Iterate over all jobs in a specific session. @@ -159,17 +204,22 @@ def iter_session_jobs( target: Filter by backend target name. submitter_id: Filter by submitter user ID. limit: Maximum number of jobs per page. + max_pages: Maximum number of pages to fetch before raising. + Defaults to `DEFAULT_MAX_PAGES` (10,000); ``None`` disables + the cap. Yields: Individual job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response. + IonQError: If the API returns a ``None`` response, repeats a + pagination cursor, or exceeds ``max_pages``. """ return _paginate( get_session_jobs.sync, "session jobs", session_id, + max_pages=max_pages, client=client, status=status, target=target, @@ -186,6 +236,7 @@ def aiter_session_jobs( target: str | Unset = UNSET, submitter_id: str | Unset = UNSET, limit: int | Unset = UNSET, + max_pages: int | None = DEFAULT_MAX_PAGES, ) -> AsyncIterator[Job]: """Async version of `iter_session_jobs`. @@ -196,17 +247,22 @@ def aiter_session_jobs( target: Filter by backend target name. submitter_id: Filter by submitter user ID. limit: Maximum number of jobs per page. + max_pages: Maximum number of pages to fetch before raising. + Defaults to `DEFAULT_MAX_PAGES` (10,000); ``None`` disables + the cap. Yields: Individual job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response. + IonQError: If the API returns a ``None`` response, repeats a + pagination cursor, or exceeds ``max_pages``. """ return _apaginate( get_session_jobs.asyncio, "session jobs", session_id, + max_pages=max_pages, client=client, status=status, target=target, diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index 347043c..4132ee2 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -4,6 +4,12 @@ literal_enums: true post_hooks: - "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.py" + # get_httpx_client()/get_async_httpx_client() copy "Authorization: " + # into _headers, so the headers dict must stay out of __repr__ too. + - "perl -pi -e 's/(alias=\"headers\")\\)/$1, repr=False)/' client.py" + # ruff format excludes client.py (see pyproject), so normalize its EOF here to + # keep pre-commit's end-of-file-fixer and the generated-code check agreeing. + - "perl -0777 -pi -e 's/\\n+\\z/\\n/' client.py" - "perl -0777 -pi -e '$y=(gmtime)[5]+1900;s/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $y IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" - "ruff check . --fix-only" - "ruff format ." diff --git a/tests/conftest.py b/tests/conftest.py index 07876a8..32d627b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,11 +3,19 @@ import pytest from ionq_core import AuthenticatedClient, Client +from ionq_core._transport import DualTransport, ErrorRaisingTransport from ionq_core.ionq_client import DEFAULT_BASE_URL BASE_URL = "https://test.invalid" + urlparse(DEFAULT_BASE_URL).path +def socket_transports(transport: ErrorRaisingTransport): + """The innermost (sync, async) transport pair that actually opens connections.""" + dual = transport._transport._sync_transport._sync_transport + assert isinstance(dual, DualTransport) + return dual._sync_transport, dual._async_transport + + def make_job_json(job_id, status="completed", **overrides): """Minimal valid job dict usable as both BaseJob and GetJobResponse.""" return { diff --git a/tests/test_ionq_client.py b/tests/test_ionq_client.py index 9190679..b174324 100644 --- a/tests/test_ionq_client.py +++ b/tests/test_ionq_client.py @@ -1,3 +1,4 @@ +import ssl import warnings import httpx @@ -5,6 +6,11 @@ from ionq_core import IonQClient, __version__ from ionq_core._transport import ErrorRaisingTransport +from tests.conftest import socket_transports + + +def _socket_transports(client): + return socket_transports(client.get_httpx_client()._transport) class TestIonQClient: @@ -77,6 +83,15 @@ def test_token_not_in_repr(self): c = IonQClient(api_key="super-secret-key") assert "super-secret-key" not in repr(c) + def test_token_not_in_repr_after_sync_client_created(self): + c = IonQClient(api_key="super-secret-key") + c.get_httpx_client() + assert "super-secret-key" not in repr(c) + + def test_token_not_in_repr_after_async_client_created(self, auth_client): + auth_client.get_async_httpx_client() + assert "test-api-key" not in repr(auth_client) + def test_http_base_url_warns(self): with pytest.warns(UserWarning, match="does not use HTTPS"): IonQClient(api_key="key", base_url="http://api.ionq.co/v0.4") @@ -90,6 +105,24 @@ def test_verify_ssl_false_warns(self): with pytest.warns(UserWarning, match="verify_ssl=False"): IonQClient(api_key="key", verify_ssl=False) + def test_verify_ssl_context_reaches_socket_transports(self): + ctx = ssl.create_default_context() + sync_t, async_t = _socket_transports(IonQClient(api_key="key", verify_ssl=ctx)) + assert sync_t._pool._ssl_context is ctx + assert async_t._pool._ssl_context is ctx + + def test_verify_ssl_false_disables_verification(self): + with pytest.warns(UserWarning, match="verify_ssl=False"): + c = IonQClient(api_key="key", verify_ssl=False) + sync_t, async_t = _socket_transports(c) + assert sync_t._pool._ssl_context.verify_mode == ssl.CERT_NONE + assert async_t._pool._ssl_context.verify_mode == ssl.CERT_NONE + + def test_verify_ssl_default_verifies(self): + sync_t, async_t = _socket_transports(IonQClient(api_key="key")) + assert sync_t._pool._ssl_context.verify_mode == ssl.CERT_REQUIRED + assert async_t._pool._ssl_context.verify_mode == ssl.CERT_REQUIRED + def test_async_client_inherits_follow_redirects(self): ac = IonQClient(api_key="key", follow_redirects=True).get_async_httpx_client() assert ac.follow_redirects is True diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 6234389..df07827 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -94,3 +94,47 @@ async def test_multiple_pages(self, httpx_mock, auth_client): httpx_mock.add_response(json=_jobs_page(["j2"])) jobs = [j async for j in aiter_session_jobs(auth_client, "sess-1")] assert [j.id for j in jobs] == ["j1", "j2"] + + +class TestPaginationBounds: + def test_sync_repeated_cursor_raises(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="loop")) + httpx_mock.add_response(json=_jobs_page(["j2"], next_cursor="loop")) + with pytest.raises(IonQError, match="repeated pagination cursor"): + list(iter_jobs(auth_client)) + + async def test_async_repeated_cursor_raises(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="loop")) + httpx_mock.add_response(json=_jobs_page(["j2"], next_cursor="loop")) + with pytest.raises(IonQError, match="repeated pagination cursor"): + async for _ in aiter_jobs(auth_client): + pass + + def test_sync_max_pages_exceeded_raises(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="c1")) + with pytest.raises(IonQError, match="max_pages=1"): + list(iter_jobs(auth_client, max_pages=1)) + + async def test_async_max_pages_exceeded_raises(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="c1")) + with pytest.raises(IonQError, match="max_pages=1"): + async for _ in aiter_jobs(auth_client, max_pages=1): + pass + + def test_sync_max_pages_none_disables_cap(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="c1")) + httpx_mock.add_response(json=_jobs_page(["j2"], next_cursor="c2")) + httpx_mock.add_response(json=_jobs_page(["j3"])) + jobs = list(iter_jobs(auth_client, max_pages=None)) + assert [j.id for j in jobs] == ["j1", "j2", "j3"] + + def test_session_jobs_max_pages_threaded(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="c1")) + with pytest.raises(IonQError, match="max_pages=1"): + list(iter_session_jobs(auth_client, "sess-1", max_pages=1)) + + async def test_async_session_jobs_max_pages_threaded(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="c1")) + with pytest.raises(IonQError, match="max_pages=1"): + async for _ in aiter_session_jobs(auth_client, "sess-1", max_pages=1): + pass diff --git a/tests/test_transport.py b/tests/test_transport.py index c3aa02f..9139e24 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,7 +1,18 @@ +import ssl + import httpx import pytest - -from ionq_core._transport import ErrorRaisingTransport, build_transport +from httpx_retries import Retry, RetryTransport + +from ionq_core._transport import ( + PRE_SEND_EXCEPTIONS, + RETRYABLE_STATUS_CODES, + DualTransport, + ErrorRaisingTransport, + NonIdempotentRetry, + _retry_stack, + build_transport, +) from ionq_core.exceptions import ( APIConnectionError, APITimeoutError, @@ -11,7 +22,7 @@ RateLimitError, ServerError, ) -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, socket_transports _URL = f"{BASE_URL}/backends" @@ -48,14 +59,141 @@ def _wrap(responses): return ErrorRaisingTransport(fake), fake +def _stack(responses, max_retries=2): + fake = FakeTransport(responses) + return _retry_stack(fake, max_retries, RETRYABLE_STATUS_CODES), fake + + +@pytest.fixture +def no_retry_sleep(monkeypatch): + async def _asleep(self, response): + return None + + monkeypatch.setattr(Retry, "sleep", lambda self, response: None) + monkeypatch.setattr(Retry, "asleep", _asleep) + + class TestBuildTransport: def test_returns_error_raising(self): assert isinstance(build_transport(), ErrorRaisingTransport) - def test_retries_post_requests(self): - transport = build_transport() - retry = transport._transport.retry - assert "POST" in retry.allowed_methods + def test_post_policy_is_pre_send_only(self): + outer = build_transport()._transport + assert isinstance(outer, RetryTransport) + assert isinstance(outer.retry, NonIdempotentRetry) + assert outer.retry.allowed_methods == frozenset({"POST"}) + assert not outer.retry.is_retryable_status_code(503) + assert outer.retry.retryable_exceptions == PRE_SEND_EXCEPTIONS + + def test_idempotent_policy_excludes_post(self): + inner = build_transport()._transport._sync_transport + assert isinstance(inner, RetryTransport) + assert "POST" not in inner.retry.allowed_methods + assert inner.retry.status_forcelist == RETRYABLE_STATUS_CODES + assert inner.retry.is_retryable_status_code(503) + + def test_verify_context_reaches_socket_transports(self): + ctx = ssl.create_default_context() + sync_t, async_t = socket_transports(build_transport(verify=ctx)) + assert sync_t._pool._ssl_context is ctx + assert async_t._pool._ssl_context is ctx + + def test_verify_default_enforces_verification(self): + sync_t, async_t = socket_transports(build_transport()) + assert sync_t._pool._ssl_context.verify_mode == ssl.CERT_REQUIRED + assert async_t._pool._ssl_context.verify_mode == ssl.CERT_REQUIRED + + def test_close_full_stack(self): + build_transport().close() + + async def test_aclose_full_stack(self): + await build_transport().aclose() + + +@pytest.mark.usefixtures("no_retry_sleep") +class TestRetryPolicySync: + def test_post_not_resent_after_retryable_status(self): + stack, fake = _stack([_resp(503), _resp(200)]) + assert stack.handle_request(_req("POST")).status_code == 503 + assert fake.call_count == 1 + + def test_post_not_resent_after_429(self): + stack, fake = _stack([_resp(429, headers={"retry-after": "0"}), _resp(200)]) + assert stack.handle_request(_req("POST")).status_code == 429 + assert fake.call_count == 1 + + def test_get_retried_on_retryable_status(self): + stack, fake = _stack([_resp(503), _resp(200)]) + assert stack.handle_request(_req("GET")).status_code == 200 + assert fake.call_count == 2 + + def test_get_retried_on_read_timeout(self): + stack, fake = _stack([httpx.ReadTimeout("timed out"), _resp(200)]) + assert stack.handle_request(_req("GET")).status_code == 200 + assert fake.call_count == 2 + + def test_get_returns_last_response_when_exhausted(self): + stack, fake = _stack([_resp(503), _resp(503), _resp(503)]) + assert stack.handle_request(_req("GET")).status_code == 503 + assert fake.call_count == 3 + + @pytest.mark.parametrize( + "exc", + [httpx.ConnectError("refused"), httpx.ConnectTimeout("connect"), httpx.PoolTimeout("pool")], + ids=["connect-error", "connect-timeout", "pool-timeout"], + ) + def test_post_retried_on_pre_send_failure(self, exc): + stack, fake = _stack([exc, _resp(200)]) + assert stack.handle_request(_req("POST")).status_code == 200 + assert fake.call_count == 2 + + def test_post_not_retried_on_read_timeout(self): + stack, fake = _stack([httpx.ReadTimeout("timed out"), _resp(200)]) + with pytest.raises(httpx.ReadTimeout): + stack.handle_request(_req("POST")) + assert fake.call_count == 1 + + def test_post_not_retried_on_remote_protocol_error(self): + stack, fake = _stack([httpx.RemoteProtocolError("server disconnected"), _resp(200)]) + with pytest.raises(httpx.RemoteProtocolError): + stack.handle_request(_req("POST")) + assert fake.call_count == 1 + + def test_post_zero_retries(self): + stack, fake = _stack([httpx.ConnectError("refused")], max_retries=0) + with pytest.raises(httpx.ConnectError): + stack.handle_request(_req("POST")) + assert fake.call_count == 1 + + +@pytest.mark.usefixtures("no_retry_sleep") +class TestRetryPolicyAsync: + async def test_post_not_resent_after_retryable_status(self): + stack, fake = _stack([_resp(503), _resp(200)]) + assert (await stack.handle_async_request(_req("POST"))).status_code == 503 + assert fake.call_count == 1 + + async def test_post_retried_on_connect_error(self): + stack, fake = _stack([httpx.ConnectError("refused"), _resp(200)]) + assert (await stack.handle_async_request(_req("POST"))).status_code == 200 + assert fake.call_count == 2 + + async def test_get_retried_on_retryable_status(self): + stack, fake = _stack([_resp(503), _resp(200)]) + assert (await stack.handle_async_request(_req("GET"))).status_code == 200 + assert fake.call_count == 2 + + +class TestDualTransport: + def test_sync_requests_use_sync_transport(self): + dual = DualTransport(FakeTransport([_resp(200)]), FakeTransport([_resp(201)])) + assert dual.handle_request(_req()).status_code == 200 + dual.close() + + async def test_async_requests_use_async_transport(self): + dual = DualTransport(FakeTransport([_resp(200)]), FakeTransport([_resp(201)])) + assert (await dual.handle_async_request(_req())).status_code == 201 + await dual.aclose() class TestErrorRaisingTransportSync: