From 6d5a012d70320cb65f548f27926b6bd58022a994 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Mon, 24 Aug 2026 17:06:00 -0700 Subject: [PATCH 1/2] add async event handler methods --- ...ync_event_notification_handler_endpoint.py | 102 +++++ stripe/__init__.py | 10 + stripe/_event_notification_handler.py | 278 ++++++++++--- stripe/_stripe_client.py | 27 ++ tests/test_event_notification_handler.py | 375 +++++++++++++++++- 5 files changed, 726 insertions(+), 66 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..c43e33050 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,32 +130,61 @@ 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: """ @@ -140,7 +196,9 @@ def _assert_can_register(self) -> None: "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). @@ -153,38 +211,25 @@ def pre_handle(self, func: PreHandleCallback) -> PreHandleCallback: 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() if event_type in self._registered_handlers: @@ -204,7 +249,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 +262,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 +275,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 +288,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 +301,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 +314,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 +327,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 +340,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 +353,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 +366,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 +379,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 +392,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 +405,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 +418,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 +431,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 +444,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 +457,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 +470,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 +483,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 +496,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 +509,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 +522,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 +535,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 +548,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 +562,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 +648,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 +666,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 25abad28db2d7e5eb72b04d0c025a6122072eca3 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Tue, 25 Aug 2026 00:12:22 -0700 Subject: [PATCH 2/2] rename method & update docstring --- stripe/_event_notification_handler.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/stripe/_event_notification_handler.py b/stripe/_event_notification_handler.py index c43e33050..949da888f 100644 --- a/stripe/_event_notification_handler.py +++ b/stripe/_event_notification_handler.py @@ -186,10 +186,9 @@ def __init__( _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( @@ -204,7 +203,7 @@ def pre_handle( 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") @@ -231,7 +230,7 @@ def _register( event_type: str, 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'