From a87e544f21301f7599e924b7092a5bfc48733ba8 Mon Sep 17 00:00:00 2001 From: David Brownman <1231935+xavdid@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:49:19 -0700 Subject: [PATCH] Add `.preHandle` method to `EventNotificationHandler` (#2274) * add pre_handle method to event handler * update docstrings --- .../EventNotificationPreHandleCallback.java | 10 + .../StripeEventNotificationHandlerBase.java | 46 +++- .../EventNotificationHandlerEndpoint.java | 27 +++ .../StripeEventNotificationHandlerTest.java | 224 +++++++++++++++++- 4 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/stripe/EventNotificationPreHandleCallback.java diff --git a/src/main/java/com/stripe/EventNotificationPreHandleCallback.java b/src/main/java/com/stripe/EventNotificationPreHandleCallback.java new file mode 100644 index 00000000000..aa237d22abc --- /dev/null +++ b/src/main/java/com/stripe/EventNotificationPreHandleCallback.java @@ -0,0 +1,10 @@ +package com.stripe; + +import com.stripe.model.v2.core.EventNotification; + +/** Functional interface for a hook that runs before any event-specific callback. */ +@FunctionalInterface +public interface EventNotificationPreHandleCallback { + // this is an internal-facing method name that dictates how we call the stored method + boolean process(EventNotification event, StripeClient client); +} diff --git a/src/main/java/com/stripe/StripeEventNotificationHandlerBase.java b/src/main/java/com/stripe/StripeEventNotificationHandlerBase.java index 8ff51eb401e..0f98a35bd0e 100644 --- a/src/main/java/com/stripe/StripeEventNotificationHandlerBase.java +++ b/src/main/java/com/stripe/StripeEventNotificationHandlerBase.java @@ -56,6 +56,7 @@ abstract class StripeEventNotificationHandlerBase> registeredHandlers = new HashMap<>(); @@ -65,18 +66,50 @@ abstract class StripeEventNotificationHandlerBase void register( - String eventType, EventNotificationCallback handler) { + /** + * Callbacks are expected to be registered once on startup, so registering anything after handling + * has begun indicates a bug. + */ + private void assertCanRegister() { if (hasHandledEvent) { - throw new IllegalStateException("Cannot register handlers after handling an event"); + throw new IllegalStateException( + "Cannot register new callbacks after an event has been handled. This is indicative of a bug."); } + } + + private void register( + String eventType, EventNotificationCallback handler) { + assertCanRegister(); if (this.registeredHandlers.containsKey(eventType)) { - throw new IllegalArgumentException("Handler already registered for event type: " + eventType); + throw new IllegalArgumentException( + "Callback for event type \"" + eventType + "\" is already registered"); } this.registeredHandlers.put(eventType, handler); } + /** + * 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. + * + *

Returning {@code true} causes handling to continue as normal; returning {@code false} + * returns from {@code handle()} immediately, so neither the registered callback nor the fallback + * callback are called. + * + * @param callback the hook to run before handling continues + * @return this handler, for chaining + */ + public T preHandle(EventNotificationPreHandleCallback callback) { + assertCanRegister(); + + if (this.preHandleCallback != null) { + throw new IllegalArgumentException("A preHandle callback is already registered"); + } + this.preHandleCallback = callback; + return self(); + } + /** Lets the generated {@code on*} methods return the concrete handler type for chaining. */ @SuppressWarnings("unchecked") final T self() { @@ -91,6 +124,11 @@ void dispatch(EventNotification eventNotification) { // Create a new client with the event's context for thread-safe processing StripeClient eventClient = this.client.withStripeContext(eventNotification.context); + if (this.preHandleCallback != null + && !this.preHandleCallback.process(eventNotification, eventClient)) { + return; + } + if (handler == null) { boolean isKnownEventType = !(eventNotification instanceof com.stripe.events.UnknownEventNotification); diff --git a/src/main/java/com/stripe/examples/EventNotificationHandlerEndpoint.java b/src/main/java/com/stripe/examples/EventNotificationHandlerEndpoint.java index 24b5633d5f9..24d10e357ff 100644 --- a/src/main/java/com/stripe/examples/EventNotificationHandlerEndpoint.java +++ b/src/main/java/com/stripe/examples/EventNotificationHandlerEndpoint.java @@ -16,6 +16,9 @@ import java.io.InputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; /** * Receive and process event notifications (AKA thin events) like @@ -28,6 +31,7 @@ *

  • create a StripeClient called client *
  • Initialize an EventNotificationHandler with the client, webhook secret, and fallback * callback + *
  • register a preHandle 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 @@ -42,6 +46,12 @@ public class EventNotificationHandlerEndpoint { private static final String API_KEY = System.getenv("STRIPE_API_KEY"); private static final String WEBHOOK_SECRET = System.getenv("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. + private static final Set processedEventIds = + Collections.synchronizedSet(new LinkedHashSet<>()); + private static final StripeClient client = new StripeClient(API_KEY); private static final StripeEventNotificationHandler handler = client.notificationHandler( @@ -54,8 +64,13 @@ public class EventNotificationHandlerEndpoint { EventNotificationHandlerEndpoint::fallbackCallback); public static void main(String[] args) throws IOException { + handler.preHandle(EventNotificationHandlerEndpoint::deduplicate); + // can be anywhere in your codebase; registering on both handlers means either endpoint below + // will route this event type handler.onV1BillingMeterErrorReportTriggered( EventNotificationHandlerEndpoint::handleMeterErrors); + + unverifiedHandler.preHandle(EventNotificationHandlerEndpoint::deduplicate); unverifiedHandler.onV1BillingMeterErrorReportTriggered( EventNotificationHandlerEndpoint::handleMeterErrors); @@ -71,6 +86,18 @@ private static void fallbackCallback( System.out.println("Received unhandled event notification type: " + notif.getType()); } + /** + * Runs before any registered callback. Returning {@code false} here skips handling entirely for + * this delivery, which is useful for deduplicating webhooks. + */ + private static boolean deduplicate(EventNotification notif, StripeClient client) { + boolean isNewEvent = processedEventIds.add(notif.getId()); + if (!isNewEvent) { + System.out.println("Skipping already-processed event: " + notif.getId()); + } + return isNewEvent; + } + private static void handleMeterErrors( V1BillingMeterErrorReportTriggeredEventNotification notif, StripeClient client) { Meter meter; diff --git a/src/test/java/com/stripe/StripeEventNotificationHandlerTest.java b/src/test/java/com/stripe/StripeEventNotificationHandlerTest.java index d3b99ff6653..8c67471b0f5 100644 --- a/src/test/java/com/stripe/StripeEventNotificationHandlerTest.java +++ b/src/test/java/com/stripe/StripeEventNotificationHandlerTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.stripe.events.UnknownEventNotification; import com.stripe.events.V1BillingMeterErrorReportTriggeredEventNotification; @@ -19,6 +20,7 @@ import com.stripe.net.Webhook; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -231,7 +233,12 @@ public void testCannotRegisterHandlerAfterHandling() eventNotificationHandler.onV2CoreAccountCreated( mock(EventNotificationCallback.class))); - assertTrue(exception.getMessage().contains("Cannot register handlers after handling an event")); + assertTrue( + exception + .getMessage() + .contains( + "Cannot register new callbacks after an event has been handled. This is indicative of a" + + " bug.")); } @SuppressWarnings("unchecked") @@ -254,7 +261,8 @@ public void testCannotRegisterDuplicateHandler() { exception .getMessage() .contains( - "Handler already registered for event type: v1.billing.meter.error_report_triggered")); + "Callback for event type \"v1.billing.meter.error_report_triggered\" is already" + + " registered")); } @Test @@ -630,4 +638,216 @@ public void testRegisteredEventTypesMultipleAlphabetized() { List eventTypes = eventNotificationHandler.getRegisteredEventTypes(); assertEquals(expected, eventTypes); } + + @SuppressWarnings("unchecked") + @Test + public void testPreHandle_noHookRegistered_callbackStillRuns() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Regression: with no preHandle hook registered, behavior is unchanged. + EventNotificationCallback handler = + mock(EventNotificationCallback.class); + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + verify(handler, times(1)) + .process(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void testPreHandle_returnsTrue_runsFirstThenCallbackRuns() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + List callOrder = new ArrayList<>(); + + EventNotificationPreHandleCallback preHandleCallback = + (event, client) -> { + callOrder.add("preHandle"); + return true; + }; + + EventNotificationCallback handler = + (event, client) -> callOrder.add("callback"); + + eventNotificationHandler.preHandle(preHandleCallback); + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + assertEquals(Arrays.asList("preHandle", "callback"), callOrder); + } + + @SuppressWarnings("unchecked") + @Test + public void testPreHandle_returnsFalse_registeredCallbackDoesNotRun() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + EventNotificationPreHandleCallback preHandleCallback = + mock(EventNotificationPreHandleCallback.class); + when(preHandleCallback.process( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(false); + + EventNotificationCallback handler = + mock(EventNotificationCallback.class); + + eventNotificationHandler.preHandle(preHandleCallback); + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + verify(handler, never()) + .process(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + public void testPreHandle_returnsFalse_fallbackAlsoDoesNotRunForUnknownEvent() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Unregistered/unknown event types normally fall through to the fallback callback. A + // preHandle hook returning false should suppress that fallback too. + EventNotificationPreHandleCallback preHandleCallback = + mock(EventNotificationPreHandleCallback.class); + when(preHandleCallback.process( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(false); + + eventNotificationHandler.preHandle(preHandleCallback); + + String sigHeader = generateSigHeader(unknownEventPayload); + eventNotificationHandler.handle(unknownEventPayload, sigHeader); + + verify(fallbackCallback, never()) + .process( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + public void testPreHandle_receivesContextScopedClient() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // The preHandle hook should receive the same event-context-scoped client that callbacks do, + // and the handler's own client should be left unmutated. + AtomicReference receivedContext = new AtomicReference<>(); + + EventNotificationPreHandleCallback preHandleCallback = + (event, client) -> { + receivedContext.set(client.getContext()); + return true; + }; + + eventNotificationHandler.preHandle(preHandleCallback); + + assertEquals("original_context_123", stripeClient.getContext()); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + assertEquals("event_context_456", receivedContext.get()); + assertEquals("original_context_123", stripeClient.getContext()); + } + + @Test + public void testPreHandle_throwing_propagatesAndNoCallbackRuns() + throws NoSuchAlgorithmException, InvalidKeyException { + EventNotificationPreHandleCallback preHandleCallback = + (event, client) -> { + throw new RuntimeException("preHandle error!"); + }; + + AtomicReference callbackRan = new AtomicReference<>(false); + EventNotificationCallback handler = + (event, client) -> callbackRan.set(true); + + eventNotificationHandler.preHandle(preHandleCallback); + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader)); + assertEquals("preHandle error!", exception.getMessage()); + assertTrue(!callbackRan.get()); + } + + @SuppressWarnings("unchecked") + @Test + public void testPreHandle_cannotRegisterAfterHandling() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + eventNotificationHandler.onV1BillingMeterErrorReportTriggered( + mock(EventNotificationCallback.class)); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> + eventNotificationHandler.preHandle(mock(EventNotificationPreHandleCallback.class))); + + assertTrue( + exception + .getMessage() + .contains( + "Cannot register new callbacks after an event has been handled. This is indicative of a" + + " bug.")); + } + + @Test + public void testPreHandle_cannotRegisterTwice() { + eventNotificationHandler.preHandle(mock(EventNotificationPreHandleCallback.class)); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + eventNotificationHandler.preHandle(mock(EventNotificationPreHandleCallback.class))); + + assertTrue(exception.getMessage().contains("already registered")); + } + + @Test + public void testPreHandle_returnsConcreteHandlerType() { + // preHandle should return the concrete handler type for chaining, just like the generated + // on* methods. + StripeEventNotificationHandler returned = + eventNotificationHandler.preHandle((event, client) -> true); + + assertEquals(eventNotificationHandler, returned); + } + + @SuppressWarnings("unchecked") + @Test + public void testWithoutVerification_preHandleGatesCallback() { + // The preHandle hook should also gate StripeEventNotificationHandlerWithoutVerification. + StripeEventNotificationHandlerWithoutVerification handler = + StripeEventNotificationHandler.withoutVerification(stripeClient, fallbackCallback); + + EventNotificationPreHandleCallback preHandleCallback = + mock(EventNotificationPreHandleCallback.class); + when(preHandleCallback.process( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(false); + + EventNotificationCallback callback = + mock(EventNotificationCallback.class); + + handler.preHandle(preHandleCallback); + handler.onV1BillingMeterErrorReportTriggered(callback); + + handler.handle(v1BillingMeterPayload); + + verify(callback, never()) + .process(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + verify(fallbackCallback, never()) + .process( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } }