Skip to content

Commit 0227e85

Browse files
authored
Security hardening, async session, and extension system improvements (#8)
* fix: security hardening, async session, and foundation improvements - Redact API key from repr output (token field repr=False) - Warn on non-HTTPS base_url and verify_ssl=False - Clamp negative Retry-After to zero - Add request_id to APIError from x-request-id header - Add async support to SessionManager (async_open/async_close/async_status/__aenter__/__aexit__) - Raise IonQError on failed pagination instead of silent empty iterator - Include exception type name in non-retryable connection errors * feat: strengthen extension system for downstream SDKs - Add on_error hook to EventHook/AsyncEventHook protocols, fired when the transport raises after retries are exhausted - Fix precedence: explicit caller args > extension > defaults (was: extension silently overrode caller args) - Add error_mapper to ClientExtension for downstream exception translation (e.g. ionq_core.NotFoundError -> QiskitError) - Add debug_hooks flag to ClientExtension; when True, hook exceptions propagate instead of being swallowed - Fix async client auth header duplication: use constants instead of hardcoded strings that could diverge from sync path
1 parent 829f234 commit 0227e85

14 files changed

Lines changed: 648 additions & 66 deletions

ionq_core/_exceptions.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,17 @@ class APITimeoutError(APIConnectionError):
1818
class APIError(IonQError):
1919
"""Raised when the IonQ API returns an error response."""
2020

21-
def __init__(self, status_code: int, body: dict | str | None = None, message: str | None = None) -> None:
21+
def __init__(
22+
self,
23+
status_code: int,
24+
body: dict | str | None = None,
25+
message: str | None = None,
26+
*,
27+
request_id: str | None = None,
28+
) -> None:
2229
self.status_code = status_code
2330
self.body = body
31+
self.request_id = request_id
2432
self.message = message or f"HTTP {status_code}"
2533
super().__init__(self.message)
2634

@@ -50,8 +58,10 @@ def __init__(
5058
body: dict | str | None = None,
5159
message: str | None = None,
5260
retry_after: float | None = None,
61+
*,
62+
request_id: str | None = None,
5363
) -> None:
54-
super().__init__(status_code, body, message)
64+
super().__init__(status_code, body, message, request_id=request_id)
5565
self.retry_after = retry_after
5666

5767

@@ -73,11 +83,13 @@ def raise_for_status(
7383
body: dict | str | None = None,
7484
retry_after: float | None = None,
7585
message: str | None = None,
86+
*,
87+
request_id: str | None = None,
7688
) -> None:
7789
"""Raise the appropriate exception for an error status code."""
7890
if status_code < 400:
7991
return
8092
exc_cls = _STATUS_TO_EXCEPTION.get(status_code, ServerError if status_code >= 500 else APIError)
8193
if exc_cls is RateLimitError:
82-
raise RateLimitError(status_code, body, message, retry_after)
83-
raise exc_cls(status_code, body, message)
94+
raise RateLimitError(status_code, body, message, retry_after, request_id=request_id)
95+
raise exc_cls(status_code, body, message, request_id=request_id)

ionq_core/_extensions.py

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ class EventHook(Protocol):
3333
Hooks are for observation only (logging, metrics, telemetry) - they
3434
must not mutate the request or response. For mutation, use a custom
3535
httpx transport instead.
36+
37+
``on_error`` is called when the underlying transport raises an
38+
exception (after retries are exhausted). Hooks that do not implement
39+
``on_error`` are silently skipped.
3640
"""
3741

3842
def on_request(self, request: httpx.Request) -> None: ...
@@ -53,6 +57,9 @@ class ClientExtension:
5357
5458
All fields are optional and additive - they layer on top of the
5559
defaults that IonQClient already provides.
60+
61+
Explicit caller arguments to ``IonQClient()`` take precedence over
62+
extension values, which in turn take precedence over factory defaults.
5663
"""
5764

5865
user_agent_token: str | None = None
@@ -64,35 +71,57 @@ class ClientExtension:
6471
timeout: httpx.Timeout | None = None
6572
transport_wrapper: Callable[[httpx.BaseTransport], httpx.BaseTransport] | None = None
6673
async_transport_wrapper: Callable[[httpx.AsyncBaseTransport], httpx.AsyncBaseTransport] | None = None
74+
error_mapper: Callable[[Exception], Exception] | None = None # return same object to skip mapping
75+
debug_hooks: bool = False
6776

6877

69-
def _fire_hooks(hooks, method: str, *args) -> None:
78+
def _fire_hooks(hooks: tuple, method: str, *args, debug: bool = False) -> None:
7079
for hook in hooks:
80+
fn = getattr(hook, method, None)
81+
if fn is None:
82+
continue
7183
try:
72-
getattr(hook, method)(*args)
84+
fn(*args)
7385
except Exception:
86+
if debug:
87+
raise
7488
logger.exception("%s raised; ignoring", method)
7589

7690

77-
async def _afire_hooks(hooks, method: str, *args) -> None:
91+
async def _afire_hooks(hooks: tuple, method: str, *args, debug: bool = False) -> None:
7892
for hook in hooks:
93+
fn = getattr(hook, method, None)
94+
if fn is None:
95+
continue
7996
try:
80-
await getattr(hook, method)(*args)
97+
await fn(*args)
8198
except Exception:
99+
if debug:
100+
raise
82101
logger.exception("%s raised; ignoring", method)
83102

84103

85104
class HookTransport(httpx.BaseTransport):
86-
"""Transport decorator that invokes EventHook instances."""
105+
"""Transport decorator that invokes EventHook instances.
106+
107+
Sits between the retry transport (inner) and the user wrapper (outer).
108+
Hooks observe the final request/response after retries resolve.
109+
``on_error`` fires when the inner transport raises.
110+
"""
87111

88-
def __init__(self, transport: httpx.BaseTransport, hooks: tuple[EventHook, ...]) -> None:
112+
def __init__(self, transport: httpx.BaseTransport, hooks: tuple[EventHook, ...], *, debug: bool = False) -> None:
89113
self._transport = transport
90114
self._hooks = hooks
115+
self._debug = debug
91116

92117
def handle_request(self, request: httpx.Request) -> httpx.Response:
93-
_fire_hooks(self._hooks, "on_request", request)
94-
response = self._transport.handle_request(request)
95-
_fire_hooks(self._hooks, "on_response", request, response)
118+
_fire_hooks(self._hooks, "on_request", request, debug=self._debug)
119+
try:
120+
response = self._transport.handle_request(request)
121+
except Exception as exc:
122+
_fire_hooks(self._hooks, "on_error", request, exc, debug=self._debug)
123+
raise
124+
_fire_hooks(self._hooks, "on_response", request, response, debug=self._debug)
96125
return response
97126

98127
def close(self) -> None:
@@ -102,15 +131,62 @@ def close(self) -> None:
102131
class AsyncHookTransport(httpx.AsyncBaseTransport):
103132
"""Async counterpart of HookTransport."""
104133

105-
def __init__(self, transport: httpx.AsyncBaseTransport, hooks: tuple[AsyncEventHook, ...]) -> None:
134+
def __init__(
135+
self, transport: httpx.AsyncBaseTransport, hooks: tuple[AsyncEventHook, ...], *, debug: bool = False
136+
) -> None:
106137
self._transport = transport
107138
self._hooks = hooks
139+
self._debug = debug
108140

109141
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
110-
await _afire_hooks(self._hooks, "on_request", request)
111-
response = await self._transport.handle_async_request(request)
112-
await _afire_hooks(self._hooks, "on_response", request, response)
142+
await _afire_hooks(self._hooks, "on_request", request, debug=self._debug)
143+
try:
144+
response = await self._transport.handle_async_request(request)
145+
except Exception as exc:
146+
await _afire_hooks(self._hooks, "on_error", request, exc, debug=self._debug)
147+
raise
148+
await _afire_hooks(self._hooks, "on_response", request, response, debug=self._debug)
113149
return response
114150

115151
async def aclose(self) -> None:
116152
await self._transport.aclose()
153+
154+
155+
class _ErrorMapperTransport(httpx.BaseTransport):
156+
"""Translates exceptions via an error_mapper callback for downstream SDKs."""
157+
158+
def __init__(self, transport: httpx.BaseTransport, mapper: Callable[[Exception], Exception]) -> None:
159+
self._transport = transport
160+
self._mapper = mapper
161+
162+
def handle_request(self, request: httpx.Request) -> httpx.Response:
163+
try:
164+
return self._transport.handle_request(request)
165+
except Exception as exc:
166+
mapped = self._mapper(exc)
167+
if mapped is not exc:
168+
raise mapped from exc
169+
raise
170+
171+
def close(self) -> None:
172+
self._transport.close()
173+
174+
175+
class _AsyncErrorMapperTransport(httpx.AsyncBaseTransport):
176+
"""Async counterpart of _ErrorMapperTransport."""
177+
178+
def __init__(self, transport: httpx.AsyncBaseTransport, mapper: Callable[[Exception], Exception]) -> None:
179+
self._transport = transport
180+
self._mapper = mapper
181+
182+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
183+
try:
184+
return await self._transport.handle_async_request(request)
185+
except Exception as exc:
186+
mapped = self._mapper(exc)
187+
if mapped is not exc:
188+
raise mapped from exc
189+
raise
190+
191+
async def aclose(self) -> None:
192+
await self._transport.aclose()

ionq_core/_pagination.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections.abc import AsyncIterator, Iterator
77
from typing import TYPE_CHECKING
88

9+
from ._exceptions import IonQError
910
from .api.default import get_jobs, get_session_jobs
1011
from .types import UNSET, Unset
1112

@@ -39,7 +40,7 @@ def iter_jobs(
3940
next_=next_cursor,
4041
)
4142
if response is None:
42-
return
43+
raise IonQError("Failed to fetch jobs")
4344
yield from response.jobs
4445
if response.next_ is None:
4546
return
@@ -69,7 +70,7 @@ async def aiter_jobs(
6970
next_=next_cursor,
7071
)
7172
if response is None:
72-
return
73+
raise IonQError("Failed to fetch jobs")
7374
for job in response.jobs:
7475
yield job
7576
if response.next_ is None:
@@ -100,7 +101,7 @@ def iter_session_jobs(
100101
next_=next_cursor,
101102
)
102103
if response is None:
103-
return
104+
raise IonQError("Failed to fetch jobs")
104105
yield from response.jobs
105106
if response.next_ is None:
106107
return
@@ -130,7 +131,7 @@ async def aiter_session_jobs(
130131
next_=next_cursor,
131132
)
132133
if response is None:
133-
return
134+
raise IonQError("Failed to fetch jobs")
134135
for job in response.jobs:
135136
yield job
136137
if response.next_ is None:

ionq_core/_session.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,6 @@
1616

1717
logger = logging.getLogger("ionq_core")
1818

19-
_SETTINGS_MAP = {
20-
"_max_jobs": "job_count_limit",
21-
"_max_time": "duration_limit_min",
22-
}
23-
2419

2520
class SessionManager:
2621
"""Convenience wrapper around session create / end / status APIs.
@@ -56,7 +51,11 @@ def session_id(self) -> str | None:
5651
return self._session_id
5752

5853
def _build_settings(self) -> SessionSettingsRequest | None:
59-
kwargs = {api: getattr(self, attr) for attr, api in _SETTINGS_MAP.items() if getattr(self, attr) is not None}
54+
kwargs: dict = {}
55+
if self._max_jobs is not None:
56+
kwargs["job_count_limit"] = self._max_jobs
57+
if self._max_time is not None:
58+
kwargs["duration_limit_min"] = self._max_time
6059
if self._max_cost is not None:
6160
kwargs["cost_limit"] = SessionCostLimit(unit="usd", value=self._max_cost)
6261
return SessionSettingsRequest(**kwargs) if kwargs else None
@@ -92,9 +91,47 @@ def status(self) -> str:
9291
raise IonQError(f"Failed to fetch session {self._session_id}")
9392
return session.status
9493

94+
async def async_open(self) -> None:
95+
"""Create a new session on the backend (async)."""
96+
if self._session_id is not None:
97+
raise IonQError("Session already open")
98+
settings = self._build_settings()
99+
body = CreateSessionRequest(backend=self._backend, **({"settings": settings} if settings else {}))
100+
session = await create_session.asyncio(client=self._client, body=body)
101+
if session is None:
102+
raise IonQError("Failed to create session")
103+
self._session_id = session.id
104+
logger.info("Opened session %s", self._session_id)
105+
106+
async def async_close(self) -> None:
107+
"""End the session (async). Suppresses exceptions so cleanup is safe."""
108+
if self._session_id is None:
109+
return
110+
try:
111+
await end_session.asyncio(session_id=self._session_id, client=self._client)
112+
logger.info("Closed session %s", self._session_id)
113+
except Exception:
114+
logger.warning("Failed to end session %s", self._session_id, exc_info=True)
115+
116+
async def async_status(self) -> str:
117+
"""Query current session status (async)."""
118+
if self._session_id is None:
119+
raise IonQError("No session ID; call open() first")
120+
session = await get_session.asyncio(session_id=self._session_id, client=self._client)
121+
if session is None:
122+
raise IonQError(f"Failed to fetch session {self._session_id}")
123+
return session.status
124+
95125
def __enter__(self) -> SessionManager:
96126
self.open()
97127
return self
98128

99129
def __exit__(self, *exc: object) -> None:
100130
self.close()
131+
132+
async def __aenter__(self) -> SessionManager:
133+
await self.async_open()
134+
return self
135+
136+
async def __aexit__(self, *exc: object) -> None:
137+
await self.async_close()

ionq_core/_transport.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def _parse_retry_after(response: httpx.Response) -> float | None:
2626
if header is None:
2727
return None
2828
try:
29-
return float(header)
29+
return max(0.0, float(header))
3030
except ValueError:
3131
parsed = email.utils.parsedate(header)
3232
if parsed is not None:
@@ -43,7 +43,6 @@ def _backoff_delays(max_retries: int) -> Iterator[float]:
4343

4444
def _parse_error_body(response: httpx.Response) -> dict | str | None:
4545
try:
46-
response.read()
4746
return response.json()
4847
except Exception:
4948
text = response.text
@@ -55,7 +54,8 @@ def _raise_for_response(response: httpx.Response) -> None:
5554
return
5655
body = _parse_error_body(response)
5756
message = body.get("message") or body.get("error") if isinstance(body, dict) else None
58-
raise_for_status(response.status_code, body, _parse_retry_after(response), message)
57+
request_id = response.headers.get("x-request-id")
58+
raise_for_status(response.status_code, body, _parse_retry_after(response), message, request_id=request_id)
5959

6060

6161
def _retry_delay(delay: float, last_response: httpx.Response | None) -> float:
@@ -127,7 +127,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
127127
response = self._transport.handle_request(request)
128128
except Exception as exc:
129129
if not _is_retryable_exc(request, exc):
130-
raise APIConnectionError(str(exc)) from exc
130+
raise APIConnectionError(f"{type(exc).__name__}: {exc}") from exc
131131
last_exc, last_response = exc, None
132132
continue
133133

@@ -171,7 +171,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
171171
response = await self._transport.handle_async_request(request)
172172
except Exception as exc:
173173
if not _is_retryable_exc(request, exc):
174-
raise APIConnectionError(str(exc)) from exc
174+
raise APIConnectionError(f"{type(exc).__name__}: {exc}") from exc
175175
last_exc, last_response = exc, None
176176
continue
177177

ionq_core/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ class AuthenticatedClient:
177177
_client: httpx.Client | None = field(default=None, init=False)
178178
_async_client: httpx.AsyncClient | None = field(default=None, init=False)
179179

180-
token: str
180+
token: str = field(repr=False)
181181
prefix: str = "Bearer"
182182
auth_header_name: str = "Authorization"
183183

0 commit comments

Comments
 (0)