From a7f1f028771b015d61e2545766f2c29997917501 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Fri, 21 Aug 2026 17:55:56 -0700 Subject: [PATCH 1/2] add pre_handle method to event handler --- .../EventNotificationPreHandleCallback.java | 15 ++ .../StripeEventNotificationHandlerBase.java | 42 +++- .../EventNotificationHandlerEndpoint.java | 24 ++ .../StripeEventNotificationHandlerTest.java | 224 +++++++++++++++++- 4 files changed, 299 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..b4ede86dd5d --- /dev/null +++ b/src/main/java/com/stripe/EventNotificationPreHandleCallback.java @@ -0,0 +1,15 @@ +package com.stripe; + +import com.stripe.model.v2.core.EventNotification; + +/** + * Functional interface for a hook that runs after {@code handle()} parses the payload but before + * any {@link EventNotificationCallback} or {@link EventNotificationFallbackCallback} fires. + * Returning {@code false} stops handling for that event entirely: neither the registered callback + * nor the fallback will run. + */ +@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..563ad8c4c58 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,46 @@ 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 hook that runs after {@code handle()} parses the payload but before any callback + * fires. If the hook returns {@code false}, handling stops for that event and neither the + * registered callback nor the fallback runs. + * + * @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 +120,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..84e7497afe1 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,8 @@ *
  • create a StripeClient called client *
  • Initialize an EventNotificationHandler with the client, webhook secret, and fallback * callback + *
  • register a preHandle hook that deduplicates events we've already processed, so a + * redelivered webhook doesn't trigger the callback (or the fallback) a second time *
  • 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 +47,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"); + // A real deployment would track processed event IDs somewhere durable (e.g. a database or + // cache) rather than in memory, but the idea is the same: preHandle lets you make that check + // once, before any callback runs, instead of duplicating it in every callback. + 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 +65,11 @@ public class EventNotificationHandlerEndpoint { EventNotificationHandlerEndpoint::fallbackCallback); public static void main(String[] args) throws IOException { + handler.preHandle(EventNotificationHandlerEndpoint::deduplicate); handler.onV1BillingMeterErrorReportTriggered( EventNotificationHandlerEndpoint::handleMeterErrors); + + unverifiedHandler.preHandle(EventNotificationHandlerEndpoint::deduplicate); unverifiedHandler.onV1BillingMeterErrorReportTriggered( EventNotificationHandlerEndpoint::handleMeterErrors); @@ -71,6 +85,16 @@ private static void fallbackCallback( System.out.println("Received unhandled event notification type: " + notif.getType()); } + // Registered via preHandle() on both handlers below. Runs before any callback (or the + // fallback), so a redelivered webhook is skipped entirely instead of being handled twice. + 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()); + } } From 2cac6c54fbd75f42b696c90162fca79cde8bcb44 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Mon, 24 Aug 2026 15:00:14 -0700 Subject: [PATCH 2/2] update docstrings --- .../EventNotificationPreHandleCallback.java | 7 +------ .../StripeEventNotificationHandlerBase.java | 10 +++++++--- .../EventNotificationHandlerEndpoint.java | 17 ++++++++++------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/stripe/EventNotificationPreHandleCallback.java b/src/main/java/com/stripe/EventNotificationPreHandleCallback.java index b4ede86dd5d..aa237d22abc 100644 --- a/src/main/java/com/stripe/EventNotificationPreHandleCallback.java +++ b/src/main/java/com/stripe/EventNotificationPreHandleCallback.java @@ -2,12 +2,7 @@ import com.stripe.model.v2.core.EventNotification; -/** - * Functional interface for a hook that runs after {@code handle()} parses the payload but before - * any {@link EventNotificationCallback} or {@link EventNotificationFallbackCallback} fires. - * Returning {@code false} stops handling for that event entirely: neither the registered callback - * nor the fallback will run. - */ +/** 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 diff --git a/src/main/java/com/stripe/StripeEventNotificationHandlerBase.java b/src/main/java/com/stripe/StripeEventNotificationHandlerBase.java index 563ad8c4c58..0f98a35bd0e 100644 --- a/src/main/java/com/stripe/StripeEventNotificationHandlerBase.java +++ b/src/main/java/com/stripe/StripeEventNotificationHandlerBase.java @@ -89,9 +89,13 @@ private void register( } /** - * Registers a hook that runs after {@code handle()} parses the payload but before any callback - * fires. If the hook returns {@code false}, handling stops for that event and neither the - * registered callback nor the fallback runs. + * 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 diff --git a/src/main/java/com/stripe/examples/EventNotificationHandlerEndpoint.java b/src/main/java/com/stripe/examples/EventNotificationHandlerEndpoint.java index 84e7497afe1..24d10e357ff 100644 --- a/src/main/java/com/stripe/examples/EventNotificationHandlerEndpoint.java +++ b/src/main/java/com/stripe/examples/EventNotificationHandlerEndpoint.java @@ -31,8 +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 we've already processed, so a - * redelivered webhook doesn't trigger the callback (or the fallback) a second time + *
  • 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 @@ -47,9 +46,9 @@ public class EventNotificationHandlerEndpoint { private static final String API_KEY = System.getenv("STRIPE_API_KEY"); private static final String WEBHOOK_SECRET = System.getenv("WEBHOOK_SECRET"); - // A real deployment would track processed event IDs somewhere durable (e.g. a database or - // cache) rather than in memory, but the idea is the same: preHandle lets you make that check - // once, before any callback runs, instead of duplicating it in every callback. + // 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<>()); @@ -66,6 +65,8 @@ public class EventNotificationHandlerEndpoint { 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); @@ -85,8 +86,10 @@ private static void fallbackCallback( System.out.println("Received unhandled event notification type: " + notif.getType()); } - // Registered via preHandle() on both handlers below. Runs before any callback (or the - // fallback), so a redelivered webhook is skipped entirely instead of being handled twice. + /** + * 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) {