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()
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.
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)
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.
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.
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-Agentheader (e.g."my-sdk/1.0"). - default_headers: Headers merged into every request.
- event_hooks: Sync
EventHookinstances invoked on every request. - async_event_hooks: Async
AsyncEventHookinstances 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.
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.
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.
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)
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.
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.