Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions examples/event_notification_handler_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand All @@ -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,
Expand All @@ -39,6 +45,22 @@ def fallback_callback(
)


@handler.pre_handle
Comment thread
xavdid marked this conversation as resolved.
@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.")
Comment thread
xavdid marked this conversation as resolved.
Dismissed
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
Expand Down
40 changes: 34 additions & 6 deletions stripe/_event_notification_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
200 changes: 196 additions & 4 deletions tests/test_event_notification_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand All @@ -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())

Expand All @@ -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)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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()
Loading