ionq_core.extensions

Extension API for downstream SDKs building on ionq-core.

This module provides the ClientExtension configuration bundle and the EventHook / AsyncEventHook protocols that allow downstream SDKs to customize client behavior without modifying this library. Extensions are passed to IonQClient via the extension parameter.

Example:
from ionq_core import IonQClient, ClientExtension, EventHook
import httpx


class LoggingHook(EventHook):
    def on_request(self, request: httpx.Request) -> None:
        print(f"--> {request.method} {request.url}")

    def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
        print(f"<-- {response.status_code}")


ext = ClientExtension(
    user_agent_token="my-sdk/1.0",
    event_hooks=(LoggingHook(),),
    max_retries=5,
)
client = IonQClient(extension=ext)
  1# SPDX-FileCopyrightText: 2026 IonQ, Inc.
  2# SPDX-License-Identifier: Apache-2.0
  3
  4"""Extension API for downstream SDKs building on ionq-core.
  5
  6This module provides the `ClientExtension` configuration bundle and the
  7`EventHook` / `AsyncEventHook` protocols that allow downstream SDKs to
  8customize client behavior without modifying this library. Extensions are
  9passed to `IonQClient` via the ``extension`` parameter.
 10
 11Example:
 12    ```python
 13    from ionq_core import IonQClient, ClientExtension, EventHook
 14    import httpx
 15
 16
 17    class LoggingHook(EventHook):
 18        def on_request(self, request: httpx.Request) -> None:
 19            print(f"--> {request.method} {request.url}")
 20
 21        def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
 22            print(f"<-- {response.status_code}")
 23
 24
 25    ext = ClientExtension(
 26        user_agent_token="my-sdk/1.0",
 27        event_hooks=(LoggingHook(),),
 28        max_retries=5,
 29    )
 30    client = IonQClient(extension=ext)
 31    ```
 32"""
 33
 34__all__ = ["AsyncEventHook", "ClientExtension", "EventHook"]
 35
 36import logging
 37from collections.abc import Callable
 38from typing import Protocol, runtime_checkable
 39
 40import attrs
 41import httpx
 42
 43logger = logging.getLogger("ionq_core")
 44
 45
 46@runtime_checkable
 47class EventHook(Protocol):
 48    """Protocol for observing HTTP requests and responses (sync).
 49
 50    Implement this protocol and pass instances via
 51    `ClientExtension.event_hooks` to receive callbacks on every request.
 52
 53    Hook exceptions are logged and suppressed by default. Set
 54    ``debug_hooks=True`` on `ClientExtension` to re-raise them instead.
 55    """
 56
 57    def on_request(self, request: httpx.Request) -> None:
 58        """Called after the request is built but before it is sent.
 59
 60        Args:
 61            request: The outgoing HTTP request.
 62        """
 63        ...
 64
 65    def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
 66        """Called after a response is received.
 67
 68        Args:
 69            request: The original HTTP request.
 70            response: The HTTP response. The body has already been read.
 71        """
 72        ...
 73
 74
 75@runtime_checkable
 76class AsyncEventHook(Protocol):
 77    """Async counterpart of `EventHook` for the async client path.
 78
 79    Implement this protocol and pass instances via
 80    `ClientExtension.async_event_hooks`.
 81    """
 82
 83    async def on_request(self, request: httpx.Request) -> None:
 84        """Called after the request is built but before it is sent.
 85
 86        Args:
 87            request: The outgoing HTTP request.
 88        """
 89        ...
 90
 91    async def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
 92        """Called after a response is received.
 93
 94        Args:
 95            request: The original HTTP request.
 96            response: The HTTP response.
 97        """
 98        ...
 99
100
101@attrs.frozen
102class ClientExtension:
103    """Declarative configuration bundle for downstream SDK integration.
104
105    All fields are optional. Pass an instance to `IonQClient` via the
106    ``extension`` parameter to customize client behavior.
107
108    Attributes:
109        user_agent_token: Extra token appended to the ``User-Agent`` header
110            (e.g. ``"my-sdk/1.0"``).
111        default_headers: Headers merged into every request.
112        event_hooks: Sync `EventHook` instances invoked on every request.
113        async_event_hooks: Async `AsyncEventHook` instances invoked on
114            every async request.
115        retryable_status_codes: HTTP status codes that should trigger a retry.
116            Overrides the default set (429, 500, 502, 503, 520-529).
117        max_retries: Maximum retry attempts. Overrides the default of 2.
118        timeout: Request timeout. Overrides the default of 60 seconds.
119        transport_wrapper: Callable that wraps the sync transport, useful for
120            adding middleware (e.g. caching, tracing).
121        async_transport_wrapper: Callable that wraps the async transport.
122        error_mapper: Callable that maps exceptions raised by the transport
123            to downstream-specific exception types. Return the original
124            exception to leave it unchanged.
125        debug_hooks: If ``True``, hook exceptions are re-raised instead of
126            being logged and suppressed. Useful during development.
127    """
128
129    user_agent_token: str | None = None
130    default_headers: dict[str, str] = attrs.Factory(dict)
131    event_hooks: tuple[EventHook, ...] = ()
132    async_event_hooks: tuple[AsyncEventHook, ...] = ()
133    retryable_status_codes: frozenset[int] | None = None
134    max_retries: int | None = None
135    timeout: httpx.Timeout | None = None
136    transport_wrapper: Callable[[httpx.BaseTransport], httpx.BaseTransport] | None = None
137    async_transport_wrapper: Callable[[httpx.AsyncBaseTransport], httpx.AsyncBaseTransport] | None = None
138    error_mapper: Callable[[Exception], Exception] | None = None
139    debug_hooks: bool = False
140
141
142def _fire_hooks(hooks: tuple, method: str, *args, debug: bool = False) -> None:
143    for hook in hooks:
144        fn = getattr(hook, method, None)
145        if fn is None:
146            continue
147        try:
148            fn(*args)
149        except Exception:
150            if debug:
151                raise
152            logger.exception("%s raised; ignoring", method)
153
154
155async def _afire_hooks(hooks: tuple, method: str, *args, debug: bool = False) -> None:
156    for hook in hooks:
157        fn = getattr(hook, method, None)
158        if fn is None:
159            continue
160        try:
161            await fn(*args)
162        except Exception:
163            if debug:
164                raise
165            logger.exception("%s raised; ignoring", method)
166
167
168class HookTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
169    """Transport decorator that invokes `EventHook` instances and optionally maps exceptions.
170
171    Wraps an inner transport, firing hook callbacks before and after each
172    request. If a request raises an exception, ``on_error`` hooks are fired
173    and the optional ``error_mapper`` is applied before re-raising.
174
175    This class implements both ``httpx.BaseTransport`` and
176    ``httpx.AsyncBaseTransport``, so a single instance can be used for
177    both sync and async clients.
178
179    Args:
180        transport: The inner transport to wrap.
181        hooks: Tuple of `EventHook` or `AsyncEventHook` instances.
182        debug: If ``True``, hook exceptions are re-raised instead of
183            being logged and suppressed.
184        error_mapper: Optional callable that maps transport exceptions
185            to custom exception types.
186    """
187
188    def __init__(
189        self,
190        transport,
191        hooks: tuple = (),
192        *,
193        debug: bool = False,
194        error_mapper: Callable[[Exception], Exception] | None = None,
195    ) -> None:
196        self._transport = transport
197        self._hooks = hooks
198        self._debug = debug
199        self._error_mapper = error_mapper
200
201    def _map_error(self, exc: Exception) -> None:
202        if self._error_mapper is not None:
203            mapped = self._error_mapper(exc)
204            if mapped is not exc:
205                raise mapped from exc
206
207    def handle_request(self, request: httpx.Request) -> httpx.Response:
208        _fire_hooks(self._hooks, "on_request", request, debug=self._debug)
209        try:
210            response = self._transport.handle_request(request)
211        except Exception as exc:
212            _fire_hooks(self._hooks, "on_error", request, exc, debug=self._debug)
213            self._map_error(exc)
214            raise
215        _fire_hooks(self._hooks, "on_response", request, response, debug=self._debug)
216        return response
217
218    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
219        await _afire_hooks(self._hooks, "on_request", request, debug=self._debug)
220        try:
221            response = await self._transport.handle_async_request(request)
222        except Exception as exc:
223            await _afire_hooks(self._hooks, "on_error", request, exc, debug=self._debug)
224            self._map_error(exc)
225            raise
226        await _afire_hooks(self._hooks, "on_response", request, response, debug=self._debug)
227        return response
228
229    def close(self) -> None:
230        self._transport.close()
231
232    async def aclose(self) -> None:
233        await self._transport.aclose()
@runtime_checkable
class AsyncEventHook(typing.Protocol):
76@runtime_checkable
77class AsyncEventHook(Protocol):
78    """Async counterpart of `EventHook` for the async client path.
79
80    Implement this protocol and pass instances via
81    `ClientExtension.async_event_hooks`.
82    """
83
84    async def on_request(self, request: httpx.Request) -> None:
85        """Called after the request is built but before it is sent.
86
87        Args:
88            request: The outgoing HTTP request.
89        """
90        ...
91
92    async def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
93        """Called after a response is received.
94
95        Args:
96            request: The original HTTP request.
97            response: The HTTP response.
98        """
99        ...

Async counterpart of EventHook for the async client path.

Implement this protocol and pass instances via ClientExtension.async_event_hooks.

AsyncEventHook(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)
async def on_request(self, request: httpx.Request) -> None:
84    async def on_request(self, request: httpx.Request) -> None:
85        """Called after the request is built but before it is sent.
86
87        Args:
88            request: The outgoing HTTP request.
89        """
90        ...

Called after the request is built but before it is sent.

Arguments:
  • request: The outgoing HTTP request.
async def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
92    async def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
93        """Called after a response is received.
94
95        Args:
96            request: The original HTTP request.
97            response: The HTTP response.
98        """
99        ...

Called after a response is received.

Arguments:
  • request: The original HTTP request.
  • response: The HTTP response.
@attrs.frozen
class ClientExtension:
102@attrs.frozen
103class ClientExtension:
104    """Declarative configuration bundle for downstream SDK integration.
105
106    All fields are optional. Pass an instance to `IonQClient` via the
107    ``extension`` parameter to customize client behavior.
108
109    Attributes:
110        user_agent_token: Extra token appended to the ``User-Agent`` header
111            (e.g. ``"my-sdk/1.0"``).
112        default_headers: Headers merged into every request.
113        event_hooks: Sync `EventHook` instances invoked on every request.
114        async_event_hooks: Async `AsyncEventHook` instances invoked on
115            every async request.
116        retryable_status_codes: HTTP status codes that should trigger a retry.
117            Overrides the default set (429, 500, 502, 503, 520-529).
118        max_retries: Maximum retry attempts. Overrides the default of 2.
119        timeout: Request timeout. Overrides the default of 60 seconds.
120        transport_wrapper: Callable that wraps the sync transport, useful for
121            adding middleware (e.g. caching, tracing).
122        async_transport_wrapper: Callable that wraps the async transport.
123        error_mapper: Callable that maps exceptions raised by the transport
124            to downstream-specific exception types. Return the original
125            exception to leave it unchanged.
126        debug_hooks: If ``True``, hook exceptions are re-raised instead of
127            being logged and suppressed. Useful during development.
128    """
129
130    user_agent_token: str | None = None
131    default_headers: dict[str, str] = attrs.Factory(dict)
132    event_hooks: tuple[EventHook, ...] = ()
133    async_event_hooks: tuple[AsyncEventHook, ...] = ()
134    retryable_status_codes: frozenset[int] | None = None
135    max_retries: int | None = None
136    timeout: httpx.Timeout | None = None
137    transport_wrapper: Callable[[httpx.BaseTransport], httpx.BaseTransport] | None = None
138    async_transport_wrapper: Callable[[httpx.AsyncBaseTransport], httpx.AsyncBaseTransport] | None = None
139    error_mapper: Callable[[Exception], Exception] | None = None
140    debug_hooks: bool = False

Declarative configuration bundle for downstream SDK integration.

All fields are optional. Pass an instance to IonQClient via the extension parameter to customize client behavior.

Attributes:
  • user_agent_token: Extra token appended to the User-Agent header (e.g. "my-sdk/1.0").
  • default_headers: Headers merged into every request.
  • 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).
  • 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 adding middleware (e.g. caching, tracing).
  • async_transport_wrapper: Callable that wraps the async transport.
  • error_mapper: Callable that maps exceptions raised by the transport to downstream-specific exception types. Return the original exception to leave it unchanged.
  • debug_hooks: If True, hook exceptions are re-raised instead of being logged and suppressed. Useful during development.
ClientExtension( user_agent_token: str | None = None, default_headers: dict[str, str] = NOTHING, event_hooks: tuple[EventHook, ...] = (), async_event_hooks: tuple[AsyncEventHook, ...] = (), retryable_status_codes: frozenset[int] | None = None, max_retries: int | None = None, timeout: httpx.Timeout | None = None, transport_wrapper: Callable[[httpx.BaseTransport], httpx.BaseTransport] | None = None, async_transport_wrapper: Callable[[httpx.AsyncBaseTransport], httpx.AsyncBaseTransport] | None = None, error_mapper: Callable[[Exception], Exception] | None = None, debug_hooks: bool = False)
48def __init__(self, user_agent_token=attr_dict['user_agent_token'].default, default_headers=NOTHING, event_hooks=attr_dict['event_hooks'].default, async_event_hooks=attr_dict['async_event_hooks'].default, retryable_status_codes=attr_dict['retryable_status_codes'].default, max_retries=attr_dict['max_retries'].default, timeout=attr_dict['timeout'].default, transport_wrapper=attr_dict['transport_wrapper'].default, async_transport_wrapper=attr_dict['async_transport_wrapper'].default, error_mapper=attr_dict['error_mapper'].default, debug_hooks=attr_dict['debug_hooks'].default):
49    _setattr = _cached_setattr_get(self)
50    _setattr('user_agent_token', user_agent_token)
51    if default_headers is not NOTHING:
52        _setattr('default_headers', default_headers)
53    else:
54        _setattr('default_headers', __attr_factory_default_headers())
55    _setattr('event_hooks', event_hooks)
56    _setattr('async_event_hooks', async_event_hooks)
57    _setattr('retryable_status_codes', retryable_status_codes)
58    _setattr('max_retries', max_retries)
59    _setattr('timeout', timeout)
60    _setattr('transport_wrapper', transport_wrapper)
61    _setattr('async_transport_wrapper', async_transport_wrapper)
62    _setattr('error_mapper', error_mapper)
63    _setattr('debug_hooks', debug_hooks)

Method generated by attrs for class ClientExtension.

user_agent_token: str | None
default_headers: dict[str, str]
event_hooks: tuple[EventHook, ...]
async_event_hooks: tuple[AsyncEventHook, ...]
retryable_status_codes: frozenset[int] | None
max_retries: int | None
timeout: httpx.Timeout | None
transport_wrapper: Callable[[httpx.BaseTransport], httpx.BaseTransport] | None
async_transport_wrapper: Callable[[httpx.AsyncBaseTransport], httpx.AsyncBaseTransport] | None
error_mapper: Callable[[Exception], Exception] | None
debug_hooks: bool
@runtime_checkable
class EventHook(typing.Protocol):
47@runtime_checkable
48class EventHook(Protocol):
49    """Protocol for observing HTTP requests and responses (sync).
50
51    Implement this protocol and pass instances via
52    `ClientExtension.event_hooks` to receive callbacks on every request.
53
54    Hook exceptions are logged and suppressed by default. Set
55    ``debug_hooks=True`` on `ClientExtension` to re-raise them instead.
56    """
57
58    def on_request(self, request: httpx.Request) -> None:
59        """Called after the request is built but before it is sent.
60
61        Args:
62            request: The outgoing HTTP request.
63        """
64        ...
65
66    def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
67        """Called after a response is received.
68
69        Args:
70            request: The original HTTP request.
71            response: The HTTP response. The body has already been read.
72        """
73        ...

Protocol for observing HTTP requests and responses (sync).

Implement this protocol and pass instances via ClientExtension.event_hooks to receive callbacks on every request.

Hook exceptions are logged and suppressed by default. Set debug_hooks=True on ClientExtension to re-raise them instead.

EventHook(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)
def on_request(self, request: httpx.Request) -> None:
58    def on_request(self, request: httpx.Request) -> None:
59        """Called after the request is built but before it is sent.
60
61        Args:
62            request: The outgoing HTTP request.
63        """
64        ...

Called after the request is built but before it is sent.

Arguments:
  • request: The outgoing HTTP request.
def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
66    def on_response(self, request: httpx.Request, response: httpx.Response) -> None:
67        """Called after a response is received.
68
69        Args:
70            request: The original HTTP request.
71            response: The HTTP response. The body has already been read.
72        """
73        ...

Called after a response is received.

Arguments:
  • request: The original HTTP request.
  • response: The HTTP response. The body has already been read.