Skip to content

Commit 0582be1

Browse files
feat: add payment lifecycle hooks (#140)
* feat: add payment lifecycle hooks * chore: add changelog * fix: strengthen payment event typing * test: keep payment hook tests focused * chore: keep single changelog entry * docs: clarify payment hook behavior --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent add1c6b commit 0582be1

12 files changed

Lines changed: 1081 additions & 44 deletions

File tree

.changelog/quick-cows-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pympp: minor
3+
---
4+
5+
Added client and server payment lifecycle hooks (`EventDispatcher`, `PaymentEvent`) for observing challenge selection, credential creation, payment responses, successes, and failures. Both `PaymentTransport`/`Client` and `Mpp`/`pay` now expose typed `on_*` registration methods with unsubscribe callbacks.

src/mpp/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@
3535
PaymentRequiredError,
3636
VerificationFailedError,
3737
)
38+
from mpp.events import (
39+
CHALLENGE_CREATED,
40+
CHALLENGE_RECEIVED,
41+
CREDENTIAL_CREATED,
42+
PAYMENT_FAILED,
43+
PAYMENT_RESPONSE,
44+
PAYMENT_SUCCESS,
45+
WILDCARD_EVENT,
46+
EventDispatcher,
47+
PaymentEvent,
48+
PaymentEventName,
49+
)
3850

3951

4052
def _b64url_encode(data: str) -> str:

src/mpp/client/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,13 @@
1818

1919
from mpp import _expires as Expires
2020
from mpp.client.transport import Client, PaymentTransport, get, post, request
21+
from mpp.events import (
22+
CHALLENGE_RECEIVED,
23+
CREDENTIAL_CREATED,
24+
PAYMENT_FAILED,
25+
PAYMENT_RESPONSE,
26+
WILDCARD_EVENT,
27+
EventDispatcher,
28+
PaymentEvent,
29+
PaymentEventName,
30+
)

src/mpp/client/transport.py

Lines changed: 188 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717

1818
from mpp import Challenge, Credential
1919
from 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

2131
logger = 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+
3869
class 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

Comments
 (0)