From 255194058d59e1557fe157a55d60650d884db854 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Fri, 21 Aug 2026 17:56:28 -0700 Subject: [PATCH 1/2] add pre_handle method to event handler --- .../event_notification_handler_endpoint.py | 22 ++ stripe/_event_notification_handler.py | 38 +++- tests/test_event_notification_handler.py | 200 +++++++++++++++++- 3 files changed, 250 insertions(+), 10 deletions(-) diff --git a/examples/event_notification_handler_endpoint.py b/examples/event_notification_handler_endpoint.py index 2cafda15d..a9714b713 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 handler or fallback callback. Returning False + here skips handling entirely for this delivery, which is useful for + deduplicating retried 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..fa839e0d3 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,28 @@ 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: + """ + This function is called after `.handle()` has parsed the event notification but before any other callback has been called. Returning `True` allows handling to continue as normal; returning `False` stops handling immediately, so neither the registered handler nor the fallback callback will be 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 +159,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 +184,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 b91a2c4fc9cdfab6f0a9a20a761670d293a87c1c Mon Sep 17 00:00:00 2001 From: David Brownman Date: Mon, 24 Aug 2026 15:00:23 -0700 Subject: [PATCH 2/2] update docstrings --- examples/event_notification_handler_endpoint.py | 4 ++-- stripe/_event_notification_handler.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/event_notification_handler_endpoint.py b/examples/event_notification_handler_endpoint.py index a9714b713..17985c65e 100644 --- a/examples/event_notification_handler_endpoint.py +++ b/examples/event_notification_handler_endpoint.py @@ -49,9 +49,9 @@ def fallback_callback( @unverified_handler.pre_handle def deduplicate_events(notif: EventNotification, client: StripeClient) -> bool: """ - Runs before any registered handler or fallback callback. Returning False + Runs before any registered callback. Returning False here skips handling entirely for this delivery, which is useful for - deduplicating retried webhooks. + deduplicating webhooks. """ if notif.id in processed_event_ids: print(f"Already processed {notif.id}, skipping.") diff --git a/stripe/_event_notification_handler.py b/stripe/_event_notification_handler.py index fa839e0d3..259e81aa6 100644 --- a/stripe/_event_notification_handler.py +++ b/stripe/_event_notification_handler.py @@ -142,7 +142,9 @@ def _assert_can_register(self) -> None: def pre_handle(self, func: PreHandleCallback) -> PreHandleCallback: """ - This function is called after `.handle()` has parsed the event notification but before any other callback has been called. Returning `True` allows handling to continue as normal; returning `False` stops handling immediately, so neither the registered handler nor the fallback callback will be called. + 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: