@@ -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
85104class 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:
102131class 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 ()
0 commit comments