1717
1818from mpp import Challenge , Credential
1919from mpp ._parsing import ParseError
20+ from mpp .events import (
21+ CHALLENGE_RECEIVED ,
22+ CREDENTIAL_CREATED ,
23+ PAYMENT_FAILED ,
24+ PAYMENT_RESPONSE ,
25+ ClientPaymentFailedPayload ,
26+ EventDispatcher ,
27+ EventHandler ,
28+ Unsubscribe ,
29+ )
2030
2131logger = logging .getLogger (__name__ )
2232
@@ -35,6 +45,27 @@ async def create_credential(self, challenge: Challenge) -> Credential:
3545 ...
3646
3747
48+ def _client_payment_failed_payload (
49+ * ,
50+ challenge : Challenge | None ,
51+ challenges : list [Challenge ],
52+ credential : Credential | None ,
53+ error : Exception ,
54+ method : Method | None ,
55+ request : httpx .Request ,
56+ response : httpx .Response ,
57+ ) -> ClientPaymentFailedPayload :
58+ return {
59+ "challenge" : challenge ,
60+ "challenges" : challenges ,
61+ "credential" : credential ,
62+ "error" : error ,
63+ "method" : method ,
64+ "request" : request ,
65+ "response" : response ,
66+ }
67+
68+
3869class PaymentTransport (httpx .AsyncBaseTransport ):
3970 """httpx transport that handles 402 Payment Required responses.
4071
@@ -58,9 +89,31 @@ def __init__(
5889 self ,
5990 methods : Sequence [Method ],
6091 inner : httpx .AsyncBaseTransport | None = None ,
92+ events : EventDispatcher | None = None ,
6193 ) -> None :
6294 self ._methods = {m .name : m for m in methods }
6395 self ._inner = inner or httpx .AsyncHTTPTransport ()
96+ self ._events = events or EventDispatcher ()
97+
98+ def on (self , name : str , handler : EventHandler ) -> Unsubscribe :
99+ """Register a client payment event handler."""
100+ return self ._events .on (name , handler )
101+
102+ def on_challenge_received (self , handler : EventHandler ) -> Unsubscribe :
103+ """Register a handler for selected payment challenges."""
104+ return self .on (CHALLENGE_RECEIVED , handler )
105+
106+ def on_credential_created (self , handler : EventHandler ) -> Unsubscribe :
107+ """Register a handler for created credentials."""
108+ return self .on (CREDENTIAL_CREATED , handler )
109+
110+ def on_payment_response (self , handler : EventHandler ) -> Unsubscribe :
111+ """Register a handler for successful paid retry responses."""
112+ return self .on (PAYMENT_RESPONSE , handler )
113+
114+ def on_payment_failed (self , handler : EventHandler ) -> Unsubscribe :
115+ """Register a handler for failed automatic payment handling."""
116+ return self .on (PAYMENT_FAILED , handler )
64117
65118 async def handle_async_request (self , request : httpx .Request ) -> httpx .Response :
66119 """Handle request, automatically retrying on 402 with credentials."""
@@ -74,21 +127,43 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
74127 # Handle multiple WWW-Authenticate headers (per RFC 9110)
75128 www_auth_headers = response .headers .get_list ("www-authenticate" )
76129
77- challenge = None
78- matched_method = None
130+ challenges : list [ Challenge ] = []
131+ parse_error : ParseError | None = None
79132 for header in www_auth_headers :
80133 if not header .lower ().startswith ("payment " ):
81134 continue
82135 try :
83136 parsed = Challenge .from_www_authenticate (header )
84- if parsed .method in self ._methods :
85- challenge = parsed
86- matched_method = self ._methods [parsed .method ]
87- break
88- except ParseError :
137+ except ParseError as error :
138+ parse_error = error
89139 continue
140+ challenges .append (parsed )
141+
142+ challenge = None
143+ matched_method = None
144+ for parsed in challenges :
145+ if parsed .method in self ._methods :
146+ challenge = parsed
147+ matched_method = self ._methods [parsed .method ]
148+ break
90149
91150 if not challenge or not matched_method :
151+ if parse_error is not None or challenges :
152+ # Surface parse/method-selection failures to observers while
153+ # preserving the original 402 response for the caller.
154+ await self ._events .emit (
155+ PAYMENT_FAILED ,
156+ _client_payment_failed_payload (
157+ challenge = None ,
158+ challenges = challenges ,
159+ credential = None ,
160+ error = parse_error
161+ or ValueError ("No compatible payment method for challenges" ),
162+ method = None ,
163+ request = request ,
164+ response = response ,
165+ ),
166+ )
92167 return response
93168
94169 # Check expiry before paying (client-side guardrail)
@@ -97,12 +172,66 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
97172 expires_dt = datetime .fromisoformat (challenge .expires .replace ("Z" , "+00:00" ))
98173 if expires_dt < datetime .now (UTC ):
99174 logger .warning ("Challenge expired at %s, not paying" , challenge .expires )
175+ await self ._events .emit (
176+ PAYMENT_FAILED ,
177+ _client_payment_failed_payload (
178+ challenge = challenge ,
179+ challenges = challenges ,
180+ credential = None ,
181+ error = ValueError (f"Challenge expired at { challenge .expires } " ),
182+ method = matched_method ,
183+ request = request ,
184+ response = response ,
185+ ),
186+ )
100187 return response
101188 except ValueError :
102189 pass # If we can't parse, let server validate
103190
104- credential = await matched_method .create_credential (challenge )
105- auth_header = credential .to_authorization ()
191+ try :
192+ # challenge.received is the one client event that can override the
193+ # default credential creation path by returning a Credential.
194+ event_credential = await self ._events .emit (
195+ CHALLENGE_RECEIVED ,
196+ {
197+ "challenge" : challenge ,
198+ "challenges" : challenges ,
199+ "method" : matched_method ,
200+ "request" : request ,
201+ "response" : response ,
202+ },
203+ first_result = True ,
204+ )
205+ credential = (
206+ event_credential
207+ if isinstance (event_credential , Credential )
208+ else await matched_method .create_credential (challenge )
209+ )
210+ await self ._events .emit (
211+ CREDENTIAL_CREATED ,
212+ {
213+ "challenge" : challenge ,
214+ "credential" : credential ,
215+ "method" : matched_method ,
216+ "request" : request ,
217+ "response" : response ,
218+ },
219+ )
220+ auth_header = credential .to_authorization ()
221+ except Exception as error :
222+ await self ._events .emit (
223+ PAYMENT_FAILED ,
224+ _client_payment_failed_payload (
225+ challenge = challenge ,
226+ challenges = challenges ,
227+ credential = None ,
228+ error = error ,
229+ method = matched_method ,
230+ request = request ,
231+ response = response ,
232+ ),
233+ )
234+ raise
106235
107236 headers = httpx .Headers (request .headers )
108237 headers ["Authorization" ] = auth_header
@@ -115,7 +244,36 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
115244 extensions = request .extensions ,
116245 )
117246
118- return await self ._inner .handle_async_request (retry_request )
247+ try :
248+ payment_response = await self ._inner .handle_async_request (retry_request )
249+ except Exception as error :
250+ await self ._events .emit (
251+ PAYMENT_FAILED ,
252+ _client_payment_failed_payload (
253+ challenge = challenge ,
254+ challenges = challenges ,
255+ credential = credential ,
256+ error = error ,
257+ method = matched_method ,
258+ request = request ,
259+ response = response ,
260+ ),
261+ )
262+ raise
263+
264+ if payment_response .is_success :
265+ await self ._events .emit (
266+ PAYMENT_RESPONSE ,
267+ {
268+ "challenge" : challenge ,
269+ "credential" : credential ,
270+ "method" : matched_method ,
271+ "request" : request ,
272+ "response" : payment_response ,
273+ },
274+ )
275+
276+ return payment_response
119277
120278 async def aclose (self ) -> None :
121279 """Close the inner transport."""
@@ -134,6 +292,26 @@ def __init__(self, methods: Sequence[Method]) -> None:
134292 self ._transport = PaymentTransport (methods )
135293 self ._client = httpx .AsyncClient (transport = self ._transport )
136294
295+ def on (self , name : str , handler : EventHandler ) -> Unsubscribe :
296+ """Register a client payment event handler."""
297+ return self ._transport .on (name , handler )
298+
299+ def on_challenge_received (self , handler : EventHandler ) -> Unsubscribe :
300+ """Register a handler for selected payment challenges."""
301+ return self .on (CHALLENGE_RECEIVED , handler )
302+
303+ def on_credential_created (self , handler : EventHandler ) -> Unsubscribe :
304+ """Register a handler for created credentials."""
305+ return self .on (CREDENTIAL_CREATED , handler )
306+
307+ def on_payment_response (self , handler : EventHandler ) -> Unsubscribe :
308+ """Register a handler for successful paid retry responses."""
309+ return self .on (PAYMENT_RESPONSE , handler )
310+
311+ def on_payment_failed (self , handler : EventHandler ) -> Unsubscribe :
312+ """Register a handler for failed automatic payment handling."""
313+ return self .on (PAYMENT_FAILED , handler )
314+
137315 async def __aenter__ (self ) -> Client :
138316 await self ._client .__aenter__ ()
139317 return self
0 commit comments