From 87fb85ba23ee8e1971947f5f8af8a3687190cfcb Mon Sep 17 00:00:00 2001 From: David Brownman <1231935+xavdid@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:08:20 -0700 Subject: [PATCH 1/3] Add `.pre_handle` method to `EventNotificationHandler` (#1885) * add pre_handle method to event handler * update docstrings --- .../event_notification_handler_endpoint.py | 22 ++ stripe/_event_notification_handler.py | 40 +++- tests/test_event_notification_handler.py | 200 +++++++++++++++++- 3 files changed, 252 insertions(+), 10 deletions(-) diff --git a/examples/event_notification_handler_endpoint.py b/examples/event_notification_handler_endpoint.py index 2cafda15d..17985c65e 100644 --- a/examples/event_notification_handler_endpoint.py +++ b/examples/event_notification_handler_endpoint.py @@ -5,6 +5,7 @@ - write a fallback callback to handle unrecognized event notifications - create a StripeClient called client - Initialize an EventNotificationHandler with the client, webhook secret, and fallback callback + - register a pre_handle hook that deduplicates events by id before any callback runs - register a specific handler for the "v1.billing.meter.error_report_triggered" event notification type - use handler.handle() to process the received notification webhook body """ @@ -20,6 +21,11 @@ api_key = os.environ.get("STRIPE_API_KEY", "") webhook_secret = os.environ.get("WEBHOOK_SECRET", "") +# Webhooks can be delivered more than once, so we track ids we've already +# processed. In production, back this with something durable and shared +# across processes (e.g. Redis or a database table) instead of an in-memory set. +processed_event_ids: set[str] = set() + def fallback_callback( notif: EventNotification, @@ -39,6 +45,22 @@ def fallback_callback( ) +@handler.pre_handle +@unverified_handler.pre_handle +def deduplicate_events(notif: EventNotification, client: StripeClient) -> bool: + """ + Runs before any registered callback. Returning False + here skips handling entirely for this delivery, which is useful for + deduplicating webhooks. + """ + if notif.id in processed_event_ids: + print(f"Already processed {notif.id}, skipping.") + return False + + processed_event_ids.add(notif.id) + return True + + # can be anywhere in your codebase; registering on both handlers means either # endpoint below will route this event type @handler.on_v1_billing_meter_error_report_triggered diff --git a/stripe/_event_notification_handler.py b/stripe/_event_notification_handler.py index 7793e92b1..259e81aa6 100644 --- a/stripe/_event_notification_handler.py +++ b/stripe/_event_notification_handler.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing_extensions import TYPE_CHECKING -from typing import TypeVar, Callable, List +from typing import TypeVar, Callable, List, Optional # Import at runtime for isinstance check and type annotations from stripe.v2.core._event import EventNotification, UnknownEventNotification @@ -110,6 +110,8 @@ class UnhandledNotificationDetails: This function is called when no other callback is registered for a given event notification type. """ +PreHandleCallback = Callable[[EventNotification, "StripeClient"], bool] + class _BaseEventNotificationHandler: """ @@ -126,6 +128,30 @@ def __init__( self.fallback_callback = fallback_callback # once this is true, adding additional handlers results in an error self._has_handled_events = False + self._pre_handle_callback: Optional[PreHandleCallback] = None + + def _assert_can_register(self) -> None: + """ + Callbacks are expected to be registered once at startup, so registering + anything after handling has begun indicates a bug. + """ + if self._has_handled_events: + raise RuntimeError( + "Cannot register new callbacks after an event has been handled. This is indicative of a bug." + ) + + def pre_handle(self, func: PreHandleCallback) -> PreHandleCallback: + """ + Registers a function that will be run before any event-specific callbacks. A useful place to store event-agnostic logic, such as logging or checking for [duplicate event deliveries](https://docs.stripe.com/webhooks#handle-duplicate-events). + + Returning `True` causes handling to continue as normal; returning `False` returns from `.handle()` immediately, so neither the registered callback nor the fallback callback are called. + """ + self._assert_can_register() + if self._pre_handle_callback: + raise ValueError("A pre_handle callback is already registered") + + self._pre_handle_callback = func + return func def _dispatch(self, event_notif: "EventNotification"): # Create a new client with the event's context. @@ -135,6 +161,11 @@ def _dispatch(self, event_notif: "EventNotification"): event_notif.context ) + if self._pre_handle_callback and not self._pre_handle_callback( + event_notif, client_with_event_context + ): + return + if event_notif.type in self._registered_handlers: self._registered_handlers[event_notif.type]( event_notif, client_with_event_context @@ -155,13 +186,10 @@ def _register( event_type: str, func: "Callable[[EventNotificationChild, StripeClient], None]", ) -> None: - if self._has_handled_events: - raise RuntimeError( - "Cannot register new event handlers after .handle() has been called. This is indicative of a bug." - ) + self._assert_can_register() if event_type in self._registered_handlers: raise ValueError( - f'Handler for event type "{event_type}" already registered.' + f'Callback for event type "{event_type}" is already registered' ) self._registered_handlers[event_type] = func diff --git a/tests/test_event_notification_handler.py b/tests/test_event_notification_handler.py index 9ad872843..8f4435783 100644 --- a/tests/test_event_notification_handler.py +++ b/tests/test_event_notification_handler.py @@ -204,7 +204,7 @@ def test_cannot_register_handler_after_handling( with pytest.raises( RuntimeError, - match="Cannot register new event handlers after .handle\\(\\) has been called", + match="Cannot register new callbacks after an event has been handled", ): event_handler.on_v2_core_account_created(Mock()) @@ -219,7 +219,7 @@ def test_failed_parse_still_prevents_registration( with pytest.raises( RuntimeError, - match="Cannot register new event handlers after .handle\\(\\) has been called", + match="Cannot register new callbacks after an event has been handled", ): event_handler.on_v2_core_account_created(Mock()) @@ -234,7 +234,7 @@ def test_cannot_register_duplicate_handler( with pytest.raises( ValueError, - match='Handler for event type "v1.billing.meter.error_report_triggered" already registered', + match='Callback for event type "v1.billing.meter.error_report_triggered" is already registered', ): event_handler.on_v1_billing_meter_error_report_triggered(handler2) @@ -596,6 +596,165 @@ def test_rejects_none_webhook_secret( fallback_callback=fallback_callback, ) + def test_no_pre_handle_hook_registered_handler_still_runs( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """Regression: with no pre_handle hook registered, behavior is unchanged""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + handler.assert_called_once() + fallback_callback.assert_not_called() + + def test_pre_handle_returning_true_runs_before_handler( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """A pre_handle hook that returns True runs first, then the handler runs""" + call_order: list[str] = [] + + @event_handler.pre_handle + def pre_handle(event, client) -> bool: + call_order.append("pre_handle") + return True + + @event_handler.on_v1_billing_meter_error_report_triggered + def handler(event, client) -> None: + call_order.append("handler") + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert call_order == ["pre_handle", "handler"] + + def test_pre_handle_returning_false_skips_registered_handler( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """A pre_handle hook that returns False prevents the registered handler from running""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + event_handler.pre_handle(lambda event, client: False) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + handler.assert_not_called() + fallback_callback.assert_not_called() + + def test_pre_handle_returning_false_skips_fallback_for_unregistered_event( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """A pre_handle hook that returns False also prevents the fallback callback + from running for an unregistered (or unknown) event type""" + event_handler.pre_handle(lambda event, client: False) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + fallback_callback.assert_not_called() + + def test_pre_handle_receives_context_scoped_client( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + """The client passed to pre_handle has the event's context, and the + handler's own client is left unmutated""" + received_context: Optional[StripeContext | str] = None + + @event_handler.pre_handle + def pre_handle(event, client) -> bool: + nonlocal received_context + received_context = client._requestor._options.stripe_context + return True + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert str(received_context) == "event_context_456" + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_pre_handle_raising_propagates_and_prevents_callbacks( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """An exception raised from pre_handle propagates out of handle() and + no callback runs""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + def pre_handle(event, client) -> bool: + raise RuntimeError("pre_handle blew up!") + + event_handler.pre_handle(pre_handle) + + sig_header = generate_header(payload=v1_billing_meter_payload) + with pytest.raises(RuntimeError, match="pre_handle blew up!"): + event_handler.handle(v1_billing_meter_payload, sig_header) + + handler.assert_not_called() + fallback_callback.assert_not_called() + + def test_cannot_register_pre_handle_after_handling( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """Registering pre_handle after .handle() has been called raises RuntimeError""" + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + with pytest.raises( + RuntimeError, + match="Cannot register new callbacks after an event has been handled", + ): + event_handler.pre_handle(lambda event, client: True) + + def test_cannot_register_duplicate_pre_handle( + self, event_handler: StripeEventNotificationHandler + ) -> None: + """Registering a second pre_handle hook raises ValueError""" + event_handler.pre_handle(lambda event, client: True) + + with pytest.raises( + ValueError, match="A pre_handle callback is already registered" + ): + event_handler.pre_handle(lambda event, client: True) + + def test_pre_handle_works_as_a_decorator( + self, event_handler: StripeEventNotificationHandler + ): + @event_handler.pre_handle # type: ignore + def rand_int(notif, client): + """cool docstring""" + return 4 + + assert rand_int(None, None) == 4 # type: ignore + class TestEventNotificationHandlerWithoutVerification: @pytest.fixture(scope="function") @@ -758,7 +917,7 @@ def test_failed_parse_still_prevents_registration( with pytest.raises( RuntimeError, - match="Cannot register new event handlers after .handle\\(\\) has been called", + match="Cannot register new callbacks after an event has been handled", ): handler_without_verification.on_v2_core_account_created(Mock()) @@ -825,3 +984,36 @@ def test_handles_cloud_provider_envelope( assert isinstance( call_args[0], V1BillingMeterErrorReportTriggeredEventNotification ) + + def test_pre_handle_gates_registered_handler( + self, + handler_without_verification: StripeEventNotificationHandlerWithoutVerification, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """A pre_handle hook returning False also gates the without-verification + handler, preventing the registered handler from running""" + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + handler_without_verification.pre_handle(lambda event, client: False) + + handler_without_verification.handle(v1_billing_meter_payload) + + handler.assert_not_called() + fallback_callback.assert_not_called() + + def test_pre_handle_gates_fallback( + self, + handler_without_verification: StripeEventNotificationHandlerWithoutVerification, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """A pre_handle hook returning False also prevents the fallback + callback from running on the without-verification handler""" + handler_without_verification.pre_handle(lambda event, client: False) + + handler_without_verification.handle(v1_billing_meter_payload) + + fallback_callback.assert_not_called() From 94282c1c93506eac5d8a4daa6b2ed0e373c675fb Mon Sep 17 00:00:00 2001 From: David Brownman <1231935+xavdid@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:23:27 -0700 Subject: [PATCH 2/3] add async event handler methods (#1887) * add async event handler methods * rename method & update docstring --- ...ync_event_notification_handler_endpoint.py | 102 +++++ stripe/__init__.py | 10 + stripe/_event_notification_handler.py | 287 ++++++++++---- stripe/_stripe_client.py | 27 ++ tests/test_event_notification_handler.py | 375 +++++++++++++++++- 5 files changed, 730 insertions(+), 71 deletions(-) create mode 100644 examples/async_event_notification_handler_endpoint.py diff --git a/examples/async_event_notification_handler_endpoint.py b/examples/async_event_notification_handler_endpoint.py new file mode 100644 index 000000000..db6a7f17c --- /dev/null +++ b/examples/async_event_notification_handler_endpoint.py @@ -0,0 +1,102 @@ +""" +async_event_notification_handler_endpoint.py - receive and process event notifications (AKA thin events) like "v1.billing.meter.error_report_triggered" using AsyncEventNotificationHandler. + +The async equivalent of event_notification_handler_endpoint.py. In this example, we: + - write an async fallback callback to handle unrecognized event notifications + - create a StripeClient called client + - Initialize an AsyncStripeEventNotificationHandler with the client, webhook secret, and fallback callback + - register a pre_handle hook that deduplicates events by id before any callback runs + - register a specific handler for the "v1.billing.meter.error_report_triggered" event notification type + - await handler.handle_async() to process the received notification webhook body + +Note that only your callbacks are awaited. Verifying the signature and parsing the +payload are pure CPU work, so they stay synchronous even here. +""" + +import os +from fastapi import FastAPI, Request, Response + +from stripe import StripeClient, UnhandledNotificationDetails +from stripe.v2.core import EventNotification +from stripe.events import V1BillingMeterErrorReportTriggeredEventNotification + +app = FastAPI() +api_key = os.environ.get("STRIPE_API_KEY", "") +webhook_secret = os.environ.get("WEBHOOK_SECRET", "") + +# Webhooks can be delivered more than once, so we track ids we've already +# processed. In production, back this with something durable and shared +# across processes (e.g. Redis or a database table) instead of an in-memory set. +processed_event_ids: set[str] = set() + + +async def fallback_callback( + notif: EventNotification, + client: StripeClient, + details: UnhandledNotificationDetails, +): + print(f"Got an unhandled event of type {notif.type}!") + + +client = StripeClient(api_key) +handler = client.async_notification_handler(webhook_secret, fallback_callback) + +# Handles events delivered through a channel that has already authenticated them, such as +# AWS EventBridge or Azure Event Grid. Those payloads carry no Stripe-Signature header. +unverified_handler = client.async_notification_handler_without_verification( + fallback_callback +) + + +@handler.pre_handle +@unverified_handler.pre_handle +async def deduplicate_events( + notif: EventNotification, client: StripeClient +) -> bool: + """ + Runs before any registered callback. Returning False + here skips handling entirely for this delivery, which is useful for + deduplicating webhooks. + """ + if notif.id in processed_event_ids: + print(f"Already processed {notif.id}, skipping.") + return False + + processed_event_ids.add(notif.id) + return True + + +# can be anywhere in your codebase; registering on both handlers means either +# endpoint below will route this event type +@handler.on_v1_billing_meter_error_report_triggered +@unverified_handler.on_v1_billing_meter_error_report_triggered +async def handle_meter_error( + notif: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, +): + # the async variants of the fetch methods keep the whole callback non-blocking + event = await notif.fetch_event_async() + print(f"Err! No meter found: {event.data.developer_message_summary}") + + +@app.post("/webhook") +async def webhook(request: Request): + webhook_body = await request.body() + sig_header = request.headers.get("Stripe-Signature", "") + + try: + await handler.handle_async(webhook_body.decode(), sig_header) + return Response(status_code=200) + except Exception as e: + return Response(content=str(e), status_code=500) + + +@app.post("/webhook-from-cloud-provider") +async def webhook_from_cloud_provider(request: Request): + # no signature header to pass along; the channel already authenticated this event + try: + body = await request.body() + await unverified_handler.handle_async(body.decode()) + return Response(status_code=200) + except Exception as e: + return Response(content=str(e), status_code=500) diff --git a/stripe/__init__.py b/stripe/__init__.py index 1bde68b9d..b1e4ffa26 100644 --- a/stripe/__init__.py +++ b/stripe/__init__.py @@ -302,6 +302,8 @@ def set_app_info( ) from stripe._event import Event as Event from stripe._event_notification_handler import ( + AsyncStripeEventNotificationHandler as AsyncStripeEventNotificationHandler, + AsyncStripeEventNotificationHandlerWithoutVerification as AsyncStripeEventNotificationHandlerWithoutVerification, StripeEventNotificationHandler as StripeEventNotificationHandler, StripeEventNotificationHandlerWithoutVerification as StripeEventNotificationHandlerWithoutVerification, UnhandledNotificationDetails as UnhandledNotificationDetails, @@ -701,6 +703,14 @@ def set_app_info( "ErrorObject": ("stripe._error_object", False), "OAuthErrorObject": ("stripe._error_object", False), "Event": ("stripe._event", False), + "AsyncStripeEventNotificationHandler": ( + "stripe._event_notification_handler", + False, + ), + "AsyncStripeEventNotificationHandlerWithoutVerification": ( + "stripe._event_notification_handler", + False, + ), "StripeEventNotificationHandler": ( "stripe._event_notification_handler", False, diff --git a/stripe/_event_notification_handler.py b/stripe/_event_notification_handler.py index 259e81aa6..949da888f 100644 --- a/stripe/_event_notification_handler.py +++ b/stripe/_event_notification_handler.py @@ -1,8 +1,35 @@ -# -*- coding: utf-8 -*- -from dataclasses import dataclass -from typing_extensions import TYPE_CHECKING +""" +We use a combination of generics and inheritance to construct 4 handler classes with perfect type information while reusing as much code as we can. +Each handler has the methods/signatures that will actually work: + +| | verified | unverified | +| ----- | ----------------------------------- | ------------------------------------------------------ | +| sync | StripeEventNotificationHandler | StripeEventNotificationHandlerWithoutVerification | +| async | AsyncStripeEventNotificationHandler | AsyncStripeEventNotificationHandlerWithoutVerification | + +A pair of generic variables gives us the following class hierarchy (names edited for brevity): -from typing import TypeVar, Callable, List, Optional +- _BaseHandler(Generic[CallbackReturn, PreHandleReturn]) + - _SyncHandler(_BaseHandler[None, bool]) + - StripeHandler(_SyncHandler) + - StripeHandlerWithoutVerification(_SyncHandler) + - _AsyncHandler(_BaseHandler[None, bool]) + - AsyncStripeHandler(_AsyncHandler) + - AsyncStripeHandlerWithoutVerification(_AsyncHandler) + +Each defines `handle` and `register` methods corresponding to the types it expects +""" + +from dataclasses import dataclass +from typing_extensions import TYPE_CHECKING, Awaitable + +from typing import ( + Callable, + Generic, + List, + Optional, + TypeVar, +) # Import at runtime for isinstance check and type annotations from stripe.v2.core._event import EventNotification, UnknownEventNotification @@ -103,90 +130,107 @@ class UnhandledNotificationDetails: """ -FallbackCallback = Callable[ - [EventNotification, "StripeClient", UnhandledNotificationDetails], None +# The handler base class is generic over what its callbacks return, which lets us reuse base classes for both the sync and async handlers. +CallbackReturn = TypeVar("CallbackReturn") +PreHandleReturn = TypeVar("PreHandleReturn") + +_FallbackCallback = Callable[ + [EventNotification, "StripeClient", UnhandledNotificationDetails], + CallbackReturn, ] + +_PreHandleCallback = Callable[ + [EventNotification, "StripeClient"], PreHandleReturn +] + +FallbackCallback = _FallbackCallback[None] """ -This function is called when no other callback is registered for a given event notification type. +Called when no other callback is registered for a given event notification type. """ -PreHandleCallback = Callable[[EventNotification, "StripeClient"], bool] +AsyncFallbackCallback = _FallbackCallback[Awaitable[None]] +""" +This async function is called when no other callback is registered for a given event notification type. +""" +PreHandleCallback = _PreHandleCallback[bool] +""" +Called before any of your callbacks are run. Useful for filtering. +""" + +AsyncPreHandleCallback = _PreHandleCallback[Awaitable[bool]] +""" +This async function is called before any of your callbacks are run. Useful for filtering. +""" -class _BaseEventNotificationHandler: + +class _BaseEventNotificationHandler(Generic[CallbackReturn, PreHandleReturn]): """ - Shared internal registration and dispatch machinery for the two user-facing event handlers. + Shared internal registration machinery for the user-facing event handlers. + + Holds everything that doesn't depend on whether callbacks are awaited; the + sync and async subclasses below add only the dispatch loop itself. """ def __init__( self, client: "StripeClient", - fallback_callback: FallbackCallback, + fallback_callback: _FallbackCallback[CallbackReturn], ) -> None: self._registered_handlers = {} self._client = client self.fallback_callback = fallback_callback # once this is true, adding additional handlers results in an error self._has_handled_events = False - self._pre_handle_callback: Optional[PreHandleCallback] = None + self._pre_handle_callback: Optional[ + _PreHandleCallback[PreHandleReturn] + ] = None - def _assert_can_register(self) -> None: + def _assert_hasnt_handled(self) -> None: """ - Callbacks are expected to be registered once at startup, so registering - anything after handling has begun indicates a bug. + Callbacks are expected to be registered on startup, so registering anything after handling an event indicates a bug. """ if self._has_handled_events: raise RuntimeError( "Cannot register new callbacks after an event has been handled. This is indicative of a bug." ) - def pre_handle(self, func: PreHandleCallback) -> PreHandleCallback: + def pre_handle( + self, func: _PreHandleCallback[PreHandleReturn] + ) -> _PreHandleCallback[PreHandleReturn]: """ Registers a function that will be run before any event-specific callbacks. A useful place to store event-agnostic logic, such as logging or checking for [duplicate event deliveries](https://docs.stripe.com/webhooks#handle-duplicate-events). Returning `True` causes handling to continue as normal; returning `False` returns from `.handle()` immediately, so neither the registered callback nor the fallback callback are called. """ - self._assert_can_register() + self._assert_hasnt_handled() if self._pre_handle_callback: raise ValueError("A pre_handle callback is already registered") self._pre_handle_callback = func return func - def _dispatch(self, event_notif: "EventNotification"): - # Create a new client with the event's context. - # This is thread-safe since we're not modifying the original client. - # The new client reuses the HTTP client to avoid TLS handshake overhead. - client_with_event_context = self._client.with_stripe_context( - event_notif.context - ) - - if self._pre_handle_callback and not self._pre_handle_callback( - event_notif, client_with_event_context - ): - return + def _callback_for(self, event_notif: "EventNotification"): + """ + Returns the callback registered for this event's type, if any. + """ + return self._registered_handlers.get(event_notif.type) - if event_notif.type in self._registered_handlers: - self._registered_handlers[event_notif.type]( - event_notif, client_with_event_context - ) - else: - self.fallback_callback( - event_notif, - client_with_event_context, - UnhandledNotificationDetails( - is_known_event_type=not isinstance( - event_notif, UnknownEventNotification - ) - ), + def _unhandled_details( + self, event_notif: "EventNotification" + ) -> UnhandledNotificationDetails: + return UnhandledNotificationDetails( + is_known_event_type=not isinstance( + event_notif, UnknownEventNotification ) + ) def _register( self, event_type: str, - func: "Callable[[EventNotificationChild, StripeClient], None]", + func: "Callable[[EventNotificationChild, StripeClient], CallbackReturn]", ) -> None: - self._assert_can_register() + self._assert_hasnt_handled() if event_type in self._registered_handlers: raise ValueError( f'Callback for event type "{event_type}" is already registered' @@ -204,7 +248,7 @@ def registered_event_types(self) -> List[str]: # event-notification-registration-methods: The beginning of the section generated from our OpenAPI spec def on_v1_billing_meter_error_report_triggered( self, - func: "Callable[[V1BillingMeterErrorReportTriggeredEventNotification, StripeClient], None]", + func: "Callable[[V1BillingMeterErrorReportTriggeredEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V1BillingMeterErrorReportTriggeredEvent` (`v1.billing.meter.error_report_triggered`) event notification. @@ -217,7 +261,7 @@ def on_v1_billing_meter_error_report_triggered( def on_v1_billing_meter_no_meter_found( self, - func: "Callable[[V1BillingMeterNoMeterFoundEventNotification, StripeClient], None]", + func: "Callable[[V1BillingMeterNoMeterFoundEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V1BillingMeterNoMeterFoundEvent` (`v1.billing.meter.no_meter_found`) event notification. @@ -230,7 +274,7 @@ def on_v1_billing_meter_no_meter_found( def on_v2_commerce_product_catalog_imports_failed( self, - func: "Callable[[V2CommerceProductCatalogImportsFailedEventNotification, StripeClient], None]", + func: "Callable[[V2CommerceProductCatalogImportsFailedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CommerceProductCatalogImportsFailedEvent` (`v2.commerce.product_catalog.imports.failed`) event notification. @@ -243,7 +287,7 @@ def on_v2_commerce_product_catalog_imports_failed( def on_v2_commerce_product_catalog_imports_processing( self, - func: "Callable[[V2CommerceProductCatalogImportsProcessingEventNotification, StripeClient], None]", + func: "Callable[[V2CommerceProductCatalogImportsProcessingEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CommerceProductCatalogImportsProcessingEvent` (`v2.commerce.product_catalog.imports.processing`) event notification. @@ -256,7 +300,7 @@ def on_v2_commerce_product_catalog_imports_processing( def on_v2_commerce_product_catalog_imports_succeeded( self, - func: "Callable[[V2CommerceProductCatalogImportsSucceededEventNotification, StripeClient], None]", + func: "Callable[[V2CommerceProductCatalogImportsSucceededEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CommerceProductCatalogImportsSucceededEvent` (`v2.commerce.product_catalog.imports.succeeded`) event notification. @@ -269,7 +313,7 @@ def on_v2_commerce_product_catalog_imports_succeeded( def on_v2_commerce_product_catalog_imports_succeeded_with_errors( self, - func: "Callable[[V2CommerceProductCatalogImportsSucceededWithErrorsEventNotification, StripeClient], None]", + func: "Callable[[V2CommerceProductCatalogImportsSucceededWithErrorsEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CommerceProductCatalogImportsSucceededWithErrorsEvent` (`v2.commerce.product_catalog.imports.succeeded_with_errors`) event notification. @@ -282,7 +326,7 @@ def on_v2_commerce_product_catalog_imports_succeeded_with_errors( def on_v2_core_account_closed( self, - func: "Callable[[V2CoreAccountClosedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountClosedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountClosedEvent` (`v2.core.account.closed`) event notification. @@ -295,7 +339,7 @@ def on_v2_core_account_closed( def on_v2_core_account_created( self, - func: "Callable[[V2CoreAccountCreatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountCreatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountCreatedEvent` (`v2.core.account.created`) event notification. @@ -308,7 +352,7 @@ def on_v2_core_account_created( def on_v2_core_account_including_configuration_customer_capability_status_updated( self, - func: "Callable[[V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEvent` (`v2.core.account[configuration.customer].capability_status_updated`) event notification. @@ -321,7 +365,7 @@ def on_v2_core_account_including_configuration_customer_capability_status_update def on_v2_core_account_including_configuration_customer_updated( self, - func: "Callable[[V2CoreAccountIncludingConfigurationCustomerUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingConfigurationCustomerUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingConfigurationCustomerUpdatedEvent` (`v2.core.account[configuration.customer].updated`) event notification. @@ -334,7 +378,7 @@ def on_v2_core_account_including_configuration_customer_updated( def on_v2_core_account_including_configuration_merchant_capability_status_updated( self, - func: "Callable[[V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEvent` (`v2.core.account[configuration.merchant].capability_status_updated`) event notification. @@ -347,7 +391,7 @@ def on_v2_core_account_including_configuration_merchant_capability_status_update def on_v2_core_account_including_configuration_merchant_updated( self, - func: "Callable[[V2CoreAccountIncludingConfigurationMerchantUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingConfigurationMerchantUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingConfigurationMerchantUpdatedEvent` (`v2.core.account[configuration.merchant].updated`) event notification. @@ -360,7 +404,7 @@ def on_v2_core_account_including_configuration_merchant_updated( def on_v2_core_account_including_configuration_recipient_capability_status_updated( self, - func: "Callable[[V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEvent` (`v2.core.account[configuration.recipient].capability_status_updated`) event notification. @@ -373,7 +417,7 @@ def on_v2_core_account_including_configuration_recipient_capability_status_updat def on_v2_core_account_including_configuration_recipient_updated( self, - func: "Callable[[V2CoreAccountIncludingConfigurationRecipientUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingConfigurationRecipientUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingConfigurationRecipientUpdatedEvent` (`v2.core.account[configuration.recipient].updated`) event notification. @@ -386,7 +430,7 @@ def on_v2_core_account_including_configuration_recipient_updated( def on_v2_core_account_including_defaults_updated( self, - func: "Callable[[V2CoreAccountIncludingDefaultsUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingDefaultsUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingDefaultsUpdatedEvent` (`v2.core.account[defaults].updated`) event notification. @@ -399,7 +443,7 @@ def on_v2_core_account_including_defaults_updated( def on_v2_core_account_including_future_requirements_updated( self, - func: "Callable[[V2CoreAccountIncludingFutureRequirementsUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingFutureRequirementsUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingFutureRequirementsUpdatedEvent` (`v2.core.account[future_requirements].updated`) event notification. @@ -412,7 +456,7 @@ def on_v2_core_account_including_future_requirements_updated( def on_v2_core_account_including_identity_updated( self, - func: "Callable[[V2CoreAccountIncludingIdentityUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingIdentityUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingIdentityUpdatedEvent` (`v2.core.account[identity].updated`) event notification. @@ -425,7 +469,7 @@ def on_v2_core_account_including_identity_updated( def on_v2_core_account_including_requirements_updated( self, - func: "Callable[[V2CoreAccountIncludingRequirementsUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountIncludingRequirementsUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountIncludingRequirementsUpdatedEvent` (`v2.core.account[requirements].updated`) event notification. @@ -438,7 +482,7 @@ def on_v2_core_account_including_requirements_updated( def on_v2_core_account_link_returned( self, - func: "Callable[[V2CoreAccountLinkReturnedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountLinkReturnedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountLinkReturnedEvent` (`v2.core.account_link.returned`) event notification. @@ -451,7 +495,7 @@ def on_v2_core_account_link_returned( def on_v2_core_account_person_created( self, - func: "Callable[[V2CoreAccountPersonCreatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountPersonCreatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountPersonCreatedEvent` (`v2.core.account_person.created`) event notification. @@ -464,7 +508,7 @@ def on_v2_core_account_person_created( def on_v2_core_account_person_deleted( self, - func: "Callable[[V2CoreAccountPersonDeletedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountPersonDeletedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountPersonDeletedEvent` (`v2.core.account_person.deleted`) event notification. @@ -477,7 +521,7 @@ def on_v2_core_account_person_deleted( def on_v2_core_account_person_updated( self, - func: "Callable[[V2CoreAccountPersonUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountPersonUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountPersonUpdatedEvent` (`v2.core.account_person.updated`) event notification. @@ -490,7 +534,7 @@ def on_v2_core_account_person_updated( def on_v2_core_account_updated( self, - func: "Callable[[V2CoreAccountUpdatedEventNotification, StripeClient], None]", + func: "Callable[[V2CoreAccountUpdatedEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreAccountUpdatedEvent` (`v2.core.account.updated`) event notification. @@ -503,7 +547,7 @@ def on_v2_core_account_updated( def on_v2_core_event_destination_ping( self, - func: "Callable[[V2CoreEventDestinationPingEventNotification, StripeClient], None]", + func: "Callable[[V2CoreEventDestinationPingEventNotification, StripeClient], CallbackReturn]", ): """ Registers a callback for the `V2CoreEventDestinationPingEvent` (`v2.core.event_destination.ping`) event notification. @@ -517,7 +561,54 @@ def on_v2_core_event_destination_ping( # event-notification-registration-methods: The end of the section generated from our OpenAPI spec -class StripeEventNotificationHandler(_BaseEventNotificationHandler): +class _SyncEventNotificationHandler(_BaseEventNotificationHandler[None, bool]): + """ + Adds synchronous dispatch. Shared by the verifying and non-verifying sync + handlers, which differ only in how they parse the incoming payload. + """ + + def _dispatch(self, event_notif: "EventNotification") -> None: + client = self._client.with_stripe_context(event_notif.context) + + if self._pre_handle_callback and not self._pre_handle_callback( + event_notif, client + ): + return + + if callback := self._callback_for(event_notif): + callback(event_notif, client) + else: + self.fallback_callback( + event_notif, client, self._unhandled_details(event_notif) + ) + + +class _AsyncEventNotificationHandler( + _BaseEventNotificationHandler[Awaitable[None], Awaitable[bool]] +): + """ + Adds asynchronous dispatch. Only the callbacks are awaited: verifying a + signature and parsing the payload are pure CPU work, so they stay + synchronous even here. + """ + + async def _dispatch_async(self, event_notif: "EventNotification") -> None: + client = self._client.with_stripe_context(event_notif.context) + + if self._pre_handle_callback and not await self._pre_handle_callback( + event_notif, client + ): + return + + if callback := self._callback_for(event_notif): + await callback(event_notif, client) + else: + await self.fallback_callback( + event_notif, client, self._unhandled_details(event_notif) + ) + + +class StripeEventNotificationHandler(_SyncEventNotificationHandler): """ An on-rails experience for handling Stripe event notifications. Define callbacks for individual event types and an instance of this class will be responsible for verifying and routing the event. """ @@ -556,7 +647,7 @@ def without_verification( class StripeEventNotificationHandlerWithoutVerification( - _BaseEventNotificationHandler + _SyncEventNotificationHandler ): """ A variant of StripeEventNotificationHandler that parses events without verifying webhook signatures. Intended for pre-authenticated channels like AWS EventBridge, Azure Event Grid, or your own pre-authenticated queuing system. @@ -574,3 +665,59 @@ def handle(self, webhook_body: str): ) self._dispatch(event_notif) + + +class AsyncStripeEventNotificationHandler(_AsyncEventNotificationHandler): + """ + The async equivalent of `StripeEventNotificationHandler`, for use from async web frameworks. Register `async def` callbacks and await `.handle_async()`. + """ + + def __init__( + self, + client: "StripeClient", + webhook_secret: str, + fallback_callback: AsyncFallbackCallback, + ) -> None: + super().__init__(client, fallback_callback) + if not webhook_secret: + raise ValueError("webhook_secret must be a non-empty string") + self._webhook_secret = webhook_secret + + async def handle_async(self, webhook_body: str, sig_header: str): + self._has_handled_events = True + + event_notif = self._client.parse_event_notification( + webhook_body, sig_header, self._webhook_secret + ) + + await self._dispatch_async(event_notif) + + @staticmethod + def without_verification( + client: "StripeClient", + fallback_callback: AsyncFallbackCallback, + ) -> "AsyncStripeEventNotificationHandlerWithoutVerification": + return AsyncStripeEventNotificationHandlerWithoutVerification( + client, fallback_callback + ) + + +class AsyncStripeEventNotificationHandlerWithoutVerification( + _AsyncEventNotificationHandler +): + """ + A variant of AsyncStripeEventNotificationHandler that parses events without verifying webhook signatures. Intended for pre-authenticated channels like AWS EventBridge, Azure Event Grid, or your own pre-authenticated queuing system. + + Prefer `AsyncStripeEventNotificationHandler.without_verification()` or `client.async_notification_handler_without_verification()` instead of constructing it directly. + """ + + async def handle_async(self, webhook_body: str): + self._has_handled_events = True + + event_notif = ( + self._client.parse_event_notification_without_verification( + webhook_body + ) + ) + + await self._dispatch_async(event_notif) diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index a810170e4..2e089534e 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -10,6 +10,9 @@ from stripe._api_mode import ApiMode from stripe._error import AuthenticationError from stripe._event_notification_handler import ( + AsyncFallbackCallback, + AsyncStripeEventNotificationHandler, + AsyncStripeEventNotificationHandlerWithoutVerification, StripeEventNotificationHandler, StripeEventNotificationHandlerWithoutVerification, FallbackCallback, @@ -383,6 +386,30 @@ def notification_handler_without_verification( self, fallback_callback ) + def async_notification_handler( + self, webhook_secret: str, fallback_callback: AsyncFallbackCallback + ) -> AsyncStripeEventNotificationHandler: + """ + Returns an AsyncStripeEventNotificationHandler instance tied to this client. + Register `async def` callbacks on it and run them using `await handler.handle_async()`. + """ + return AsyncStripeEventNotificationHandler( + self, webhook_secret, fallback_callback + ) + + def async_notification_handler_without_verification( + self, fallback_callback: AsyncFallbackCallback + ) -> AsyncStripeEventNotificationHandlerWithoutVerification: + """ + A variant of AsyncStripeEventNotificationHandler that parses events without + verifying webhook signatures. Intended for pre-authenticated channels + like AWS EventBridge, Azure Event Grid, or your own queue system that + verifies payloads before storage. + """ + return AsyncStripeEventNotificationHandler.without_verification( + self, fallback_callback + ) + # deprecated v1 services: The beginning of the section generated from our OpenAPI spec @property @deprecated( diff --git a/tests/test_event_notification_handler.py b/tests/test_event_notification_handler.py index 8f4435783..f2c452163 100644 --- a/tests/test_event_notification_handler.py +++ b/tests/test_event_notification_handler.py @@ -1,10 +1,13 @@ +import anyio import json import pytest from typing import Optional -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock from stripe import SignatureVerificationError, StripeClient from stripe._event_notification_handler import ( + AsyncStripeEventNotificationHandler, + AsyncStripeEventNotificationHandlerWithoutVerification, StripeEventNotificationHandler, StripeEventNotificationHandlerWithoutVerification, UnhandledNotificationDetails, @@ -1017,3 +1020,373 @@ def test_pre_handle_gates_fallback( handler_without_verification.handle(v1_billing_meter_payload) fallback_callback.assert_not_called() + + +class TestAsyncEventNotificationHandler: + @pytest.fixture(scope="function") + def stripe_client(self, http_client_mock: HTTPClientMock) -> StripeClient: + return StripeClient( + api_key="sk_test_1234", + stripe_context=StripeContext.parse("original_context_123"), + http_client=http_client_mock.get_mock_http_client(), + ) + + @pytest.fixture(scope="function") + def fallback_callback(self) -> AsyncMock: + return AsyncMock() + + @pytest.fixture(scope="function") + def event_handler( + self, stripe_client: StripeClient, fallback_callback: AsyncMock + ) -> AsyncStripeEventNotificationHandler: + return AsyncStripeEventNotificationHandler( + client=stripe_client, + webhook_secret=DUMMY_WEBHOOK_SECRET, + fallback_callback=fallback_callback, + ) + + @pytest.fixture(scope="function") + def v1_billing_meter_payload(self) -> str: + return json.dumps( + { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + ) + + @pytest.fixture(scope="function") + def unknown_event_payload(self) -> str: + return json.dumps( + { + "id": "evt_unknown", + "object": "v2.core.event", + "type": "llama.created", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_unknown", + "related_object": { + "id": "llama_123", + "type": "llama", + "url": "/v1/llamas/llama_123", + }, + } + ) + + @pytest.mark.anyio + async def test_routes_event_to_registered_async_callback( + self, + event_handler: AsyncStripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: AsyncMock, + ) -> None: + """An `async def` callback is actually awaited, not discarded""" + received: Optional[EventNotification] = None + + async def callback( + notif: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + nonlocal received + received = notif + + event_handler.on_v1_billing_meter_error_report_triggered(callback) + + sig_header = generate_header(payload=v1_billing_meter_payload) + await event_handler.handle_async(v1_billing_meter_payload, sig_header) + + assert isinstance( + received, V1BillingMeterErrorReportTriggeredEventNotification + ) + assert received.id == "evt_123" + fallback_callback.assert_not_called() + + @pytest.mark.anyio + async def test_handle_async_awaits_callback_across_a_yield( + self, + event_handler: AsyncStripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """A callback that yields to the event loop still completes before handle_async returns""" + finished = False + + async def callback(notif, client) -> None: + nonlocal finished + await anyio.sleep(0) + finished = True + + event_handler.on_v1_billing_meter_error_report_triggered(callback) + + sig_header = generate_header(payload=v1_billing_meter_payload) + await event_handler.handle_async(v1_billing_meter_payload, sig_header) + + assert finished + + @pytest.mark.anyio + async def test_async_pre_handle_runs_before_callback( + self, + event_handler: AsyncStripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + order = [] + + async def hook(notif, client) -> bool: + await anyio.sleep(0) + order.append("pre_handle") + return True + + async def callback(notif, client) -> None: + order.append("callback") + + event_handler.pre_handle(hook) + event_handler.on_v1_billing_meter_error_report_triggered(callback) + + sig_header = generate_header(payload=v1_billing_meter_payload) + await event_handler.handle_async(v1_billing_meter_payload, sig_header) + + assert order == ["pre_handle", "callback"] + + @pytest.mark.anyio + async def test_async_pre_handle_returning_false_stops_callback( + self, + event_handler: AsyncStripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + callback = AsyncMock() + + async def hook(notif, client) -> bool: + await anyio.sleep(0) + return False + + event_handler.pre_handle(hook) + event_handler.on_v1_billing_meter_error_report_triggered(callback) + + sig_header = generate_header(payload=v1_billing_meter_payload) + await event_handler.handle_async(v1_billing_meter_payload, sig_header) + + callback.assert_not_called() + + @pytest.mark.anyio + async def test_async_pre_handle_returning_false_stops_fallback( + self, + event_handler: AsyncStripeEventNotificationHandler, + unknown_event_payload: str, + fallback_callback: AsyncMock, + ) -> None: + """Returning False gates the fallback too, not just registered callbacks""" + + async def hook(notif, client) -> bool: + return False + + event_handler.pre_handle(hook) + + sig_header = generate_header(payload=unknown_event_payload) + await event_handler.handle_async(unknown_event_payload, sig_header) + + fallback_callback.assert_not_called() + + @pytest.mark.anyio + async def test_fallback_is_awaited_for_unknown_event( + self, + event_handler: AsyncStripeEventNotificationHandler, + unknown_event_payload: str, + fallback_callback: AsyncMock, + ) -> None: + sig_header = generate_header(payload=unknown_event_payload) + await event_handler.handle_async(unknown_event_payload, sig_header) + + fallback_callback.assert_awaited_once() + notif, _client, details = fallback_callback.call_args[0] + assert isinstance(notif, UnknownEventNotification) + assert isinstance(details, UnhandledNotificationDetails) + assert details.is_known_event_type is False + + @pytest.mark.anyio + async def test_callback_receives_event_scoped_client( + self, + event_handler: AsyncStripeEventNotificationHandler, + stripe_client: StripeClient, + v1_billing_meter_payload: str, + ) -> None: + received_context = None + + async def callback(notif, client: StripeClient) -> None: + nonlocal received_context + received_context = client._requestor._options.stripe_context + + event_handler.on_v1_billing_meter_error_report_triggered(callback) + + sig_header = generate_header(payload=v1_billing_meter_payload) + await event_handler.handle_async(v1_billing_meter_payload, sig_header) + + assert str(received_context) == "event_context_456" + # the handler's own client is untouched + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + @pytest.mark.anyio + async def test_raising_callback_propagates( + self, + event_handler: AsyncStripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + async def callback(notif, client) -> None: + raise RuntimeError("boom") + + event_handler.on_v1_billing_meter_error_report_triggered(callback) + + sig_header = generate_header(payload=v1_billing_meter_payload) + with pytest.raises(RuntimeError, match="boom"): + await event_handler.handle_async( + v1_billing_meter_payload, sig_header + ) + + @pytest.mark.anyio + async def test_cannot_register_after_handling( + self, + event_handler: AsyncStripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + sig_header = generate_header(payload=v1_billing_meter_payload) + await event_handler.handle_async(v1_billing_meter_payload, sig_header) + + with pytest.raises( + RuntimeError, + match="Cannot register new callbacks after an event has been handled", + ): + event_handler.on_v2_core_account_created(AsyncMock()) + + def test_cannot_register_duplicate_callback( + self, event_handler: AsyncStripeEventNotificationHandler + ) -> None: + event_handler.on_v1_billing_meter_error_report_triggered(AsyncMock()) + + with pytest.raises( + ValueError, + match='Callback for event type "v1.billing.meter.error_report_triggered" is already registered', + ): + event_handler.on_v1_billing_meter_error_report_triggered( + AsyncMock() + ) + + def test_rejects_empty_webhook_secret( + self, stripe_client: StripeClient, fallback_callback: AsyncMock + ) -> None: + with pytest.raises( + ValueError, match="webhook_secret must be a non-empty string" + ): + AsyncStripeEventNotificationHandler( + client=stripe_client, + webhook_secret="", + fallback_callback=fallback_callback, + ) + + @pytest.mark.anyio + async def test_validates_webhook_signature( + self, + event_handler: AsyncStripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + with pytest.raises(SignatureVerificationError): + await event_handler.handle_async( + v1_billing_meter_payload, "t=1,v1=not-a-sig" + ) + + +class TestAsyncEventNotificationHandlerWithoutVerification: + @pytest.fixture(scope="function") + def stripe_client(self, http_client_mock: HTTPClientMock) -> StripeClient: + return StripeClient( + api_key="sk_test_1234", + stripe_context=StripeContext.parse("original_context_123"), + http_client=http_client_mock.get_mock_http_client(), + ) + + @pytest.fixture(scope="function") + def fallback_callback(self) -> AsyncMock: + return AsyncMock() + + @pytest.fixture(scope="function") + def handler( + self, stripe_client: StripeClient, fallback_callback: AsyncMock + ) -> AsyncStripeEventNotificationHandlerWithoutVerification: + return AsyncStripeEventNotificationHandler.without_verification( + client=stripe_client, + fallback_callback=fallback_callback, + ) + + @pytest.fixture(scope="function") + def v1_billing_meter_payload(self) -> str: + return json.dumps( + { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + ) + + def test_is_not_a_subclass_of_the_verifying_handler(self) -> None: + """The two are siblings, so neither exposes the other's handle signature""" + assert not issubclass( + AsyncStripeEventNotificationHandlerWithoutVerification, + AsyncStripeEventNotificationHandler, + ) + + @pytest.mark.anyio + async def test_handles_without_a_signature( + self, + handler: AsyncStripeEventNotificationHandlerWithoutVerification, + v1_billing_meter_payload: str, + ) -> None: + received = None + + async def callback(notif, client) -> None: + nonlocal received + await anyio.sleep(0) + received = notif + + handler.on_v1_billing_meter_error_report_triggered(callback) + + await handler.handle_async(v1_billing_meter_payload) + + assert isinstance( + received, V1BillingMeterErrorReportTriggeredEventNotification + ) + + @pytest.mark.anyio + async def test_pre_handle_gates_handling( + self, + handler: AsyncStripeEventNotificationHandlerWithoutVerification, + v1_billing_meter_payload: str, + fallback_callback: AsyncMock, + ) -> None: + callback = AsyncMock() + + async def hook(notif, client) -> bool: + return False + + handler.pre_handle(hook) + handler.on_v1_billing_meter_error_report_triggered(callback) + + await handler.handle_async(v1_billing_meter_payload) + + callback.assert_not_called() + fallback_callback.assert_not_called() From 8725fe6e736c7f833e165527ae4c09332386e3a7 Mon Sep 17 00:00:00 2001 From: David Brownman <1231935+xavdid@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:27:28 -0700 Subject: [PATCH 3/3] widen some webhook types (#1888) * widen some webhook types * update more examples * update examples, widen more types --- ...ync_event_notification_handler_endpoint.py | 2 +- .../event_notification_handler_endpoint.py | 5 +- .../event_notification_webhook_handler.py | 6 +- examples/webhooks.py | 2 +- stripe/_event_notification_handler.py | 33 +++- stripe/_stripe_client.py | 45 +++-- stripe/_webhook.py | 54 ++++-- stripe/v2/core/_event.py | 15 +- tests/test_cloud_provider.py | 41 +++++ tests/test_event_notification_handler.py | 162 ++++++++++++++++-- tests/test_v2_event.py | 77 ++++++++- tests/test_webhook.py | 41 +++++ 12 files changed, 413 insertions(+), 70 deletions(-) diff --git a/examples/async_event_notification_handler_endpoint.py b/examples/async_event_notification_handler_endpoint.py index db6a7f17c..2cad998ed 100644 --- a/examples/async_event_notification_handler_endpoint.py +++ b/examples/async_event_notification_handler_endpoint.py @@ -96,7 +96,7 @@ async def webhook_from_cloud_provider(request: Request): # no signature header to pass along; the channel already authenticated this event try: body = await request.body() - await unverified_handler.handle_async(body.decode()) + await unverified_handler.handle_async(body) return Response(status_code=200) except Exception as e: return Response(content=str(e), status_code=500) diff --git a/examples/event_notification_handler_endpoint.py b/examples/event_notification_handler_endpoint.py index 17985c65e..c1db46f13 100644 --- a/examples/event_notification_handler_endpoint.py +++ b/examples/event_notification_handler_endpoint.py @@ -75,10 +75,9 @@ def handle_meter_error( @app.route("/webhook", methods=["POST"]) def webhook(): - webhook_body = request.data - sig_header = request.headers.get("Stripe-Signature") - try: + webhook_body = request.data + sig_header = request.headers.get("Stripe-Signature") handler.handle(webhook_body, sig_header) return jsonify(success=True), 200 except Exception as e: diff --git a/examples/event_notification_webhook_handler.py b/examples/event_notification_webhook_handler.py index f167f138f..d05117c8c 100644 --- a/examples/event_notification_webhook_handler.py +++ b/examples/event_notification_webhook_handler.py @@ -32,10 +32,10 @@ @app.route("/webhook", methods=["POST"]) def webhook(): - webhook_body = request.data - sig_header = request.headers.get("Stripe-Signature") - try: + webhook_body = request.data + sig_header = request.headers.get("Stripe-Signature") + event_notif = client.parse_event_notification( webhook_body, sig_header, webhook_secret ) diff --git a/examples/webhooks.py b/examples/webhooks.py index ca2a35d20..79352b1a6 100644 --- a/examples/webhooks.py +++ b/examples/webhooks.py @@ -13,7 +13,7 @@ @app.route("/webhooks", methods=["POST"]) def webhooks(): - payload = request.data.decode("utf-8") + payload = request.data received_sig = request.headers.get("Stripe-Signature", None) try: diff --git a/stripe/_event_notification_handler.py b/stripe/_event_notification_handler.py index 949da888f..d47602588 100644 --- a/stripe/_event_notification_handler.py +++ b/stripe/_event_notification_handler.py @@ -33,6 +33,7 @@ # Import at runtime for isinstance check and type annotations from stripe.v2.core._event import EventNotification, UnknownEventNotification +from stripe._webhook import WebhookPayload if TYPE_CHECKING: from stripe._stripe_client import StripeClient @@ -616,15 +617,21 @@ class StripeEventNotificationHandler(_SyncEventNotificationHandler): def __init__( self, client: "StripeClient", - webhook_secret: str, + webhook_secret: Optional[str], fallback_callback: FallbackCallback, ) -> None: + """`webhook_secret` is only marked as `Optional` so it plays nicely with the types commonly returned from web frameworks. This raises a `ValueError` if a secret is not provided.""" super().__init__(client, fallback_callback) if not webhook_secret: raise ValueError("webhook_secret must be a non-empty string") self._webhook_secret = webhook_secret - def handle(self, webhook_body: str, sig_header: str): + def handle(self, webhook_body: WebhookPayload, sig_header: Optional[str]): + """ + Process an incoming webhook, routing it to the correct registered callback (or your fallback). + + `sig_header` is only marked as `Optional` so it plays nicely with the types commonly returned from web frameworks. This raises a `SignatureVerificationError` if a signature is not provided. + """ # set before parsing, so that even a failed parse locks out registration. # modification isn't thread-safe, but we expect callbacks to get registered synchronously at startup # making a race condition here unlikely @@ -655,7 +662,10 @@ class StripeEventNotificationHandlerWithoutVerification( Prefer `StripeEventNotificationHandler.without_verification()` or `client.notification_handler_without_verification()` instead of constructing it directly. """ - def handle(self, webhook_body: str): + def handle(self, webhook_body: WebhookPayload): + """ + Process an incoming webhook, routing it to the correct registered callback (or your fallback) without signature verification. + """ self._has_handled_events = True event_notif = ( @@ -675,15 +685,23 @@ class AsyncStripeEventNotificationHandler(_AsyncEventNotificationHandler): def __init__( self, client: "StripeClient", - webhook_secret: str, + webhook_secret: Optional[str], fallback_callback: AsyncFallbackCallback, ) -> None: + """`webhook_secret` is only marked as `Optional` so it plays nicely with the types commonly returned from web frameworks. This raises a `ValueError` if a secret is not provided.""" super().__init__(client, fallback_callback) if not webhook_secret: raise ValueError("webhook_secret must be a non-empty string") self._webhook_secret = webhook_secret - async def handle_async(self, webhook_body: str, sig_header: str): + async def handle_async( + self, webhook_body: WebhookPayload, sig_header: Optional[str] + ): + """ + Process an incoming webhook, routing it to the correct registered callback (or your fallback). + + `sig_header` is only marked as `Optional` so it plays nicely with the types commonly returned from web frameworks. This raises a `SignatureVerificationError` if a signature is not provided. + """ self._has_handled_events = True event_notif = self._client.parse_event_notification( @@ -711,7 +729,10 @@ class AsyncStripeEventNotificationHandlerWithoutVerification( Prefer `AsyncStripeEventNotificationHandler.without_verification()` or `client.async_notification_handler_without_verification()` instead of constructing it directly. """ - async def handle_async(self, webhook_body: str): + async def handle_async(self, webhook_body: WebhookPayload): + """ + Process an incoming webhook, routing it to the correct registered callback (or your fallback) without signature verification. + """ self._has_handled_events = True event_notif = ( diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index 2e089534e..f2c01b076 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -30,6 +30,7 @@ from stripe._util import _convert_to_stripe_object, get_api_mode from stripe._webhook import ( Webhook, + WebhookPayload, WebhookSignature, maybe_extract_from_cloud_provider_envelope, ) @@ -224,12 +225,14 @@ def __init__( def construct_event( self, - payload: Union[bytes, str], - sig_header: str, - secret: str, + payload: WebhookPayload, + sig_header: Optional[str], + secret: Optional[str], tolerance: int = Webhook.DEFAULT_TOLERANCE, ) -> Event: - """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook after verifying its authenticity. To work with a webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or during testing), see `construct_event_without_verification`.""" + """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook after verifying its authenticity. To work with a webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or during testing), see `construct_event_without_verification`. + + `sig_header` and `secret` are only marked as `Optional` so they play nicely with the types commonly returned from web frameworks. This raises a `SignatureVerificationError` if either is missing.""" return Webhook.construct_event( payload, sig_header, @@ -240,7 +243,7 @@ def construct_event( def construct_event_without_verification( self, - payload: Union[bytes, str], + payload: WebhookPayload, ) -> Event: """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook without first verifying its authenticity. Should be used after calling `Webhook.verify_header(...)` or with input from a trusted source (such as [AWS EventBridge](https://docs.stripe.com/event-destinations/eventbridge), or [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) payload). Or, to verify & construct in a single call, use `Webhook.construct_event(...)` instead.""" return Webhook.construct_event_without_verification( @@ -249,28 +252,24 @@ def construct_event_without_verification( def parse_event_notification( self, - raw: Union[bytes, str, bytearray], - sig_header: str, - secret: str, + raw: WebhookPayload, + sig_header: Optional[str], + secret: Optional[str], tolerance: int = Webhook.DEFAULT_TOLERANCE, ) -> "ALL_EVENT_NOTIFICATIONS": - """Constructs a [thin event notification](https://docs.stripe.com/event-destinations#thin-payload) from an incoming webhook after verifying its authenticity. To work with a webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or during testing), see `parse_event_notification_without_verification`.""" - payload = ( - cast(Union[bytes, bytearray], raw).decode("utf-8") - if hasattr(raw, "decode") - else cast(str, raw) - ) + """Constructs a [thin event notification](https://docs.stripe.com/event-destinations#thin-payload) from an incoming webhook after verifying its authenticity. To work with a webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or during testing), see `parse_event_notification_without_verification`. - WebhookSignature.verify_header(payload, sig_header, secret, tolerance) + `sig_header` and `secret` are only marked as `Optional` so they play nicely with the types commonly returned from web frameworks. This raises a `SignatureVerificationError` if either is missing.""" + WebhookSignature.verify_header(raw, sig_header, secret, tolerance) return cast( "ALL_EVENT_NOTIFICATIONS", - EventNotification.from_json(payload, self), + EventNotification.from_json(raw, self), ) def parse_event_notification_without_verification( self, - payload: Union[bytes, str], + payload: WebhookPayload, ) -> "ALL_EVENT_NOTIFICATIONS": """Constructs a [thin event notification](https://docs.stripe.com/event-destinations#thin-payload) from an incoming webhook without first verifying its authenticity. Should be used after calling `Webhook.verify_header(...)` or with input from a trusted source (such as [AWS EventBridge](https://docs.stripe.com/event-destinations/eventbridge), or [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) payload). Or, to verify & parse in a single call, use `parse_event_notification(...)` instead.""" @@ -364,10 +363,14 @@ def with_stripe_context( ) def notification_handler( - self, webhook_secret: str, fallback_callback: FallbackCallback + self, + webhook_secret: Optional[str], + fallback_callback: FallbackCallback, ) -> StripeEventNotificationHandler: """ Returns an StripeEventNotificationHandler instance tied to this client. + + `webhook_secret` is only marked as `Optional` so it plays nicely with the types commonly returned from web frameworks. This raises a `ValueError` if a secret is not provided. """ return StripeEventNotificationHandler( self, webhook_secret, fallback_callback @@ -387,11 +390,15 @@ def notification_handler_without_verification( ) def async_notification_handler( - self, webhook_secret: str, fallback_callback: AsyncFallbackCallback + self, + webhook_secret: Optional[str], + fallback_callback: AsyncFallbackCallback, ) -> AsyncStripeEventNotificationHandler: """ Returns an AsyncStripeEventNotificationHandler instance tied to this client. Register `async def` callbacks on it and run them using `await handler.handle_async()`. + + `webhook_secret` is only marked as `Optional` so it plays nicely with the types commonly returned from web frameworks. This raises a `ValueError` if a secret is not provided. """ return AsyncStripeEventNotificationHandler( self, webhook_secret, fallback_callback diff --git a/stripe/_webhook.py b/stripe/_webhook.py index 592ee512e..9fc269e69 100644 --- a/stripe/_webhook.py +++ b/stripe/_webhook.py @@ -12,6 +12,12 @@ from stripe._error import SignatureVerificationError from stripe._api_requestor import _APIRequestor +WebhookPayload = Union[str, bytes, bytearray] +""" +The raw body of an incoming webhook, as read off the request body. +Intentionally wide to work nicely with existing types from Django/Flask/FastAPI . +""" + def build_v1_event(values: Dict[str, Any], requestor: _APIRequestor) -> Event: """ @@ -26,14 +32,12 @@ def build_v1_event(values: Dict[str, Any], requestor: _APIRequestor) -> Event: ) -def maybe_extract_from_cloud_provider_envelope( - payload: Union[bytes, str], -): +def maybe_extract_from_cloud_provider_envelope(payload: WebhookPayload): """ Internal helper to extract the inner type from a cloud provider envelope (regardless of what's in there). If the payload is already a raw Stripe event (object is 'event' or 'v2.core.event'), returns the parsed dict as-is. """ - if isinstance(payload, bytes): + if isinstance(payload, (bytes, bytearray)): payload = payload.decode("utf-8") data = json.loads(payload, object_pairs_hook=OrderedDict) @@ -61,17 +65,16 @@ class Webhook(object): @staticmethod def construct_event( - payload: Union[bytes, str], - sig_header: str, - secret: str, + payload: WebhookPayload, + sig_header: Optional[str], + secret: Optional[str], tolerance: int = DEFAULT_TOLERANCE, api_key: Optional[str] = None, api_requestor: Optional[_APIRequestor] = None, ): - """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook after verifying its authenticity. To work with a webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or during testing), see `construct_event_without_verification`.""" - if isinstance(payload, (bytes, bytearray)): - payload = payload.decode("utf-8") + """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook after verifying its authenticity. To work with a webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or during testing), see `construct_event_without_verification`. + `sig_header` is only marked as `Optional` so it plays nicely with the types commonly returned from web frameworks. This raises a `SignatureVerificationError` if a signature is not provided.""" WebhookSignature.verify_header(payload, sig_header, secret, tolerance) return build_v1_event( @@ -84,8 +87,7 @@ def construct_event( @staticmethod def construct_event_without_verification( - payload: Union[bytes, str], - api_requestor: Optional[_APIRequestor] = None, + payload: WebhookPayload, api_requestor: Optional[_APIRequestor] = None ): """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook without first verifying its authenticity. Should be used after calling `WebhookSignature.verify_header(...)` or with input from a trusted source (such as [AWS EventBridge](https://docs.stripe.com/event-destinations/eventbridge), or [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) payload). Or, to verify & construct in a single call, use `Webhook.construct_event(...)` instead.""" return build_v1_event( @@ -129,12 +131,32 @@ def generate_signature_header( @classmethod def verify_header( cls, - payload: Union[bytes, str], - header: str, - secret: str, + payload: WebhookPayload, + header: Optional[str], + secret: Optional[str], tolerance=None, ): - """Verifies the authenticity (and recency) of a webhook, throwing a `SignatureVerificationError` if there's a mismatch. Useful for quickly validating incoming webhooks before storing them for later processing (at which time you can use the `*_without_verification` methods for parsing).""" + """Verifies the authenticity (and recency) of a webhook, throwing a `SignatureVerificationError` if there's a mismatch. Useful for quickly validating incoming webhooks before storing them for later processing (at which time you can use the `*_without_verification` methods for parsing). + + `sig_header` and `secret` are only marked as `Optional` so they play nicely with the types commonly returned from web frameworks. This raises a `SignatureVerificationError` if either is missing.""" + # the signature is computed over the string form of the body, so binary + # input has to be decoded before it's interpolated below + if isinstance(payload, (bytes, bytearray)): + payload = payload.decode("utf-8") + + if not header: + raise SignatureVerificationError( + "No Stripe-Signature header value was provided. Read it from the incoming request's headers and pass it in; if this webhook has already been verified (or came from a trusted source), use one of the `*_without_verification` methods instead.", + header, + payload, + ) + if not secret: + raise SignatureVerificationError( + "No webhook secret value was provided. It should start with `whsec_`", + header, + payload, + ) + try: timestamp, signatures = cls._get_timestamp_and_signatures( header, cls.EXPECTED_SCHEME diff --git a/stripe/v2/core/_event.py b/stripe/v2/core/_event.py index 1d630d541..9f054f2fc 100644 --- a/stripe/v2/core/_event.py +++ b/stripe/v2/core/_event.py @@ -10,6 +10,7 @@ from stripe._stripe_object import StripeObject, UntypedStripeObject from stripe._util import get_api_mode from stripe._stripe_context import StripeContext +from stripe._webhook import WebhookPayload if TYPE_CHECKING: from stripe._stripe_client import StripeClient @@ -175,16 +176,22 @@ def __init__( @staticmethod def from_json( - payload: Union[str, Dict[str, Any]], client: "StripeClient" + payload: Union[WebhookPayload, Dict[str, Any]], client: "StripeClient" ) -> "EventNotification": """ Helper for constructing an Event Notification. Doesn't perform signature validation, so you should use StripeClient.parse_event_notification() instead for initial handling. This is useful in unit tests and working with EventNotifications whose authenticity you've already validated. """ - parsed_body = ( - json.loads(payload) if isinstance(payload, str) else payload - ) + if isinstance(payload, dict): + parsed_body = payload + else: + parsed_body = json.loads( + payload.decode("utf-8") + if isinstance(payload, (bytes, bytearray)) + else payload + ) + if parsed_body.get("object") == "event": raise ValueError( "You passed a webhook payload to StripeClient.parse_event_notification, which expects a thin event notification. Use StripeClient.construct_event instead." diff --git a/tests/test_cloud_provider.py b/tests/test_cloud_provider.py index 858cdd006..3ef98c910 100644 --- a/tests/test_cloud_provider.py +++ b/tests/test_cloud_provider.py @@ -6,6 +6,11 @@ from stripe._webhook import Webhook from stripe.v2.core._event import EventNotification +BINARY_ENCODERS = [ + lambda p: p.encode("utf-8"), + lambda p: bytearray(p, "utf-8"), +] + @pytest.fixture def client(): @@ -178,6 +183,21 @@ def test_azure_envelope_missing_data_field(self, client): with pytest.raises(ValueError, match="Unrecognized event format"): client.construct_event_without_verification(payload) + @pytest.mark.parametrize( + "payload_fixture", ["eventbridge_payload", "eventgrid_payload"] + ) + @pytest.mark.parametrize( + "encode", BINARY_ENCODERS, ids=["bytes", "bytearray"] + ) + def test_binary_envelope(self, request, client, payload_fixture, encode): + """An envelope can arrive as bytes or a bytearray, which is how many frameworks expose it""" + payload = request.getfixturevalue(payload_fixture) + + result = client.construct_event_without_verification(encode(payload)) + + assert isinstance(result, stripe.Event) + assert result.type == "customer.created" + def test_webhook_static_method_eventbridge(self, eventbridge_payload): result = Webhook.construct_event_without_verification( eventbridge_payload @@ -202,6 +222,27 @@ def test_eventgrid(self, client, eventgrid_notification_payload): assert result.id == "evt_test_790" assert result.type == "v2.core.event_destination.ping" + @pytest.mark.parametrize( + "payload_fixture", + [ + "eventbridge_notification_payload", + "eventgrid_notification_payload", + ], + ) + @pytest.mark.parametrize( + "encode", BINARY_ENCODERS, ids=["bytes", "bytearray"] + ) + def test_binary_envelope(self, request, client, payload_fixture, encode): + """An envelope can arrive as bytes or a bytearray, which is how many frameworks expose it""" + payload = request.getfixturevalue(payload_fixture) + + result = client.parse_event_notification_without_verification( + encode(payload) + ) + + assert isinstance(result, EventNotification) + assert result.type == "v2.core.event_destination.ping" + def test_v1_event_suggests_construct_event_without_verification( self, client, eventbridge_payload ): diff --git a/tests/test_event_notification_handler.py b/tests/test_event_notification_handler.py index f2c452163..c47460589 100644 --- a/tests/test_event_notification_handler.py +++ b/tests/test_event_notification_handler.py @@ -527,6 +527,40 @@ def test_validates_webhook_signature( with pytest.raises(SignatureVerificationError): event_handler.handle(v1_billing_meter_payload, "invalid_signature") + @pytest.mark.parametrize( + "encode", + [lambda p: p.encode("utf-8"), lambda p: bytearray(p, "utf-8")], + ids=["bytes", "bytearray"], + ) + def test_handles_binary_webhook_body( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + encode, + ) -> None: + """The body can arrive as bytes or a bytearray, which is how many frameworks expose it""" + callback = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(callback) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(encode(v1_billing_meter_payload), sig_header) + + callback.assert_called_once() + + @pytest.mark.parametrize("sig_header", [None, ""]) + def test_rejects_missing_sig_header( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + sig_header: Optional[str], + ) -> None: + """A missing header (a common integration mistake) gets its own error message""" + with pytest.raises( + SignatureVerificationError, + match="No Stripe-Signature header value was provided", + ): + event_handler.handle(v1_billing_meter_payload, sig_header) + def test_registered_event_types_empty( self, event_handler: StripeEventNotificationHandler ) -> None: @@ -573,30 +607,35 @@ def rand_int(notif, client): assert rand_int(None, None) == 4 # type: ignore - def test_rejects_empty_webhook_secret( - self, stripe_client: StripeClient, fallback_callback: Mock + @pytest.mark.parametrize("webhook_secret", [None, ""]) + def test_rejects_missing_webhook_secret( + self, + stripe_client: StripeClient, + fallback_callback: Mock, + webhook_secret: Optional[str], ) -> None: - """Test that the constructor rejects an empty webhook secret""" + """`webhook_secret` is typed as optional for web framework ergonomics, but a missing one is still an error""" with pytest.raises( ValueError, match="webhook_secret must be a non-empty string" ): StripeEventNotificationHandler( client=stripe_client, - webhook_secret="", + webhook_secret=webhook_secret, fallback_callback=fallback_callback, ) - def test_rejects_none_webhook_secret( - self, stripe_client: StripeClient, fallback_callback: Mock + @pytest.mark.parametrize("webhook_secret", [None, ""]) + def test_client_factory_rejects_missing_webhook_secret( + self, + stripe_client: StripeClient, + fallback_callback: Mock, + webhook_secret: Optional[str], ) -> None: - """Test that the constructor rejects a None webhook secret""" with pytest.raises( ValueError, match="webhook_secret must be a non-empty string" ): - StripeEventNotificationHandler( - client=stripe_client, - webhook_secret=None, # type: ignore - fallback_callback=fallback_callback, + stripe_client.notification_handler( + webhook_secret, fallback_callback ) def test_no_pre_handle_hook_registered_handler_still_runs( @@ -852,6 +891,26 @@ def test_handle_takes_single_argument( handler.assert_called_once() + @pytest.mark.parametrize( + "encode", + [lambda p: p.encode("utf-8"), lambda p: bytearray(p, "utf-8")], + ids=["bytes", "bytearray"], + ) + def test_handles_binary_webhook_body( + self, + handler_without_verification, + v1_billing_meter_payload: str, + encode, + ) -> None: + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + + handler_without_verification.handle(encode(v1_billing_meter_payload)) + + handler.assert_called_once() + def test_fallback_receives_unregistered_events( self, handler_without_verification, @@ -1279,18 +1338,36 @@ def test_cannot_register_duplicate_callback( AsyncMock() ) - def test_rejects_empty_webhook_secret( - self, stripe_client: StripeClient, fallback_callback: AsyncMock + @pytest.mark.parametrize("webhook_secret", [None, ""]) + def test_rejects_missing_webhook_secret( + self, + stripe_client: StripeClient, + fallback_callback: AsyncMock, + webhook_secret: Optional[str], ) -> None: with pytest.raises( ValueError, match="webhook_secret must be a non-empty string" ): AsyncStripeEventNotificationHandler( client=stripe_client, - webhook_secret="", + webhook_secret=webhook_secret, fallback_callback=fallback_callback, ) + @pytest.mark.parametrize("webhook_secret", [None, ""]) + def test_client_factory_rejects_missing_webhook_secret( + self, + stripe_client: StripeClient, + fallback_callback: AsyncMock, + webhook_secret: Optional[str], + ) -> None: + with pytest.raises( + ValueError, match="webhook_secret must be a non-empty string" + ): + stripe_client.async_notification_handler( + webhook_secret, fallback_callback + ) + @pytest.mark.anyio async def test_validates_webhook_signature( self, @@ -1302,6 +1379,44 @@ async def test_validates_webhook_signature( v1_billing_meter_payload, "t=1,v1=not-a-sig" ) + @pytest.mark.anyio + @pytest.mark.parametrize( + "encode", + [lambda p: p.encode("utf-8"), lambda p: bytearray(p, "utf-8")], + ids=["bytes", "bytearray"], + ) + async def test_handles_binary_webhook_body( + self, + event_handler: AsyncStripeEventNotificationHandler, + v1_billing_meter_payload: str, + encode, + ) -> None: + callback = AsyncMock() + event_handler.on_v1_billing_meter_error_report_triggered(callback) + + sig_header = generate_header(payload=v1_billing_meter_payload) + await event_handler.handle_async( + encode(v1_billing_meter_payload), sig_header + ) + + callback.assert_called_once() + + @pytest.mark.anyio + @pytest.mark.parametrize("sig_header", [None, ""]) + async def test_rejects_missing_sig_header( + self, + event_handler: AsyncStripeEventNotificationHandler, + v1_billing_meter_payload: str, + sig_header: Optional[str], + ) -> None: + with pytest.raises( + SignatureVerificationError, + match="No Stripe-Signature header value was provided", + ): + await event_handler.handle_async( + v1_billing_meter_payload, sig_header + ) + class TestAsyncEventNotificationHandlerWithoutVerification: @pytest.fixture(scope="function") @@ -1371,6 +1486,25 @@ async def callback(notif, client) -> None: received, V1BillingMeterErrorReportTriggeredEventNotification ) + @pytest.mark.anyio + @pytest.mark.parametrize( + "encode", + [lambda p: p.encode("utf-8"), lambda p: bytearray(p, "utf-8")], + ids=["bytes", "bytearray"], + ) + async def test_handles_binary_webhook_body( + self, + handler: AsyncStripeEventNotificationHandlerWithoutVerification, + v1_billing_meter_payload: str, + encode, + ) -> None: + callback = AsyncMock() + handler.on_v1_billing_meter_error_report_triggered(callback) + + await handler.handle_async(encode(v1_billing_meter_payload)) + + callback.assert_called_once() + @pytest.mark.anyio async def test_pre_handle_gates_handling( self, diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index 76a969846..694b591af 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -1,5 +1,5 @@ import json -from typing import Callable +from typing import Any, Callable, Dict, Optional, Union from typing_extensions import assert_type import pytest @@ -14,13 +14,18 @@ V1BillingMeterErrorReportTriggeredEventNotification, V1BillingMeterErrorReportTriggeredEvent, ) -from stripe.v2.core._event import UnknownEventNotification +from stripe.v2.core._event import EventNotification, UnknownEventNotification from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS -from stripe._webhook import WebhookSignature +from stripe._webhook import WebhookPayload, WebhookSignature from tests.test_webhook import DUMMY_WEBHOOK_SECRET EventParser = Callable[[str], ALL_EVENT_NOTIFICATIONS] +BINARY_ENCODERS = [ + lambda p: p.encode("utf-8"), + lambda p: bytearray(p, "utf-8"), +] + class TestV2Event(object): @pytest.fixture(scope="function") @@ -125,6 +130,52 @@ def test_parses_event_notif_with_data( assert not hasattr(notif, "data") assert notif.reason is None + @pytest.mark.parametrize( + "encode", BINARY_ENCODERS, ids=["bytes", "bytearray"] + ) + def test_parses_binary_event_notif( + self, + stripe_client: StripeClient, + v2_payload_no_data: str, + encode: Callable[[str], WebhookPayload], + ): + """The body can arrive as bytes or a bytearray, which is how many frameworks expose it""" + notif = stripe_client.parse_event_notification( + encode(v2_payload_no_data), + WebhookSignature.generate_signature_header( + v2_payload_no_data, DUMMY_WEBHOOK_SECRET + ), + DUMMY_WEBHOOK_SECRET, + ) + + assert isinstance( + notif, V1BillingMeterErrorReportTriggeredEventNotification + ) + assert notif.id == "evt_234" + + @pytest.mark.parametrize( + "to_payload", + [lambda p: p, *BINARY_ENCODERS, json.loads], + ids=["str", "bytes", "bytearray", "dict"], + ) + def test_from_json_accepts_every_payload_shape( + self, + stripe_client: StripeClient, + v2_payload_no_data: str, + to_payload: Callable[[str], Union[WebhookPayload, Dict[str, Any]]], + ): + """`from_json` is public (for pre-verified payloads & tests), so it takes the same shapes the parse methods do""" + notif = EventNotification.from_json( + to_payload(v2_payload_no_data), stripe_client + ) + + assert isinstance( + notif, V1BillingMeterErrorReportTriggeredEventNotification + ) + assert notif.id == "evt_234" + assert notif.related_object + assert notif.related_object.id == "mtr_123" + def test_parses_unknown_event_notif(self, parse_event_notif: EventParser): event = parse_event_notif( json.dumps( @@ -169,6 +220,26 @@ def test_validates_signature( v2_payload_no_data, "bad header", DUMMY_WEBHOOK_SECRET ) + @pytest.mark.parametrize("secret", [None, ""]) + def test_rejects_missing_secret( + self, + stripe_client: StripeClient, + v2_payload_no_data: str, + secret: Optional[str], + ): + """`secret` is typed as optional for web framework ergonomics, but a missing one is still an error""" + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe_client.parse_event_notification( + v2_payload_no_data, + WebhookSignature.generate_signature_header( + v2_payload_no_data, DUMMY_WEBHOOK_SECRET + ), + secret, + ) + def test_v2_events_data_type(self, http_client_mock, v2_payload_with_data): method = "get" path = "/v2/core/events/evt_123" diff --git a/tests/test_webhook.py b/tests/test_webhook.py index fade53a01..9b1420a8d 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -99,6 +99,16 @@ def test_construct_event_from_bytes(self): ) assert isinstance(event, stripe.Event) + @pytest.mark.parametrize("secret", [None, ""]) + def test_raise_on_missing_secret(self, secret): + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe.Webhook.construct_event( + DUMMY_WEBHOOK_PAYLOAD, generate_header(), secret + ) + def test_raise_on_v2_payload(self): header = generate_header(payload=DUMMY_V2_WEBHOOK_PAYLOAD) with pytest.raises(ValueError) as e: @@ -109,6 +119,37 @@ def test_raise_on_v2_payload(self): class TestWebhookSignature(object): + @pytest.mark.parametrize("header", [None, ""]) + def test_raise_on_missing_header(self, header): + with pytest.raises( + SignatureVerificationError, + match="No Stripe-Signature header value was provided", + ): + stripe.WebhookSignature.verify_header( + DUMMY_WEBHOOK_PAYLOAD, header, DUMMY_WEBHOOK_SECRET + ) + + @pytest.mark.parametrize("secret", [None, ""]) + def test_raise_on_missing_secret(self, secret): + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe.WebhookSignature.verify_header( + DUMMY_WEBHOOK_PAYLOAD, generate_header(), secret + ) + + @pytest.mark.parametrize( + "encode", + [lambda p: p.encode("utf-8"), lambda p: bytearray(p, "utf-8")], + ids=["bytes", "bytearray"], + ) + def test_verifies_binary_payload(self, encode): + header = generate_header() + assert stripe.WebhookSignature.verify_header( + encode(DUMMY_WEBHOOK_PAYLOAD), header, DUMMY_WEBHOOK_SECRET + ) + def test_raise_on_malformed_header(self): header = "i'm not even a real signature header" with pytest.raises(