From 18895d1e9e1c5f0c0c84c5e4872e64b506441a37 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 16 Jun 2026 10:58:17 -0500 Subject: [PATCH 1/3] [adr] network handler behavior proposal --- .../17685-network-handler-behavior.md | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/decisions/17685-network-handler-behavior.md diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md new file mode 100644 index 0000000000000..64496e1874416 --- /dev/null +++ b/docs/decisions/17685-network-handler-behavior.md @@ -0,0 +1,230 @@ +# Network handler behavior + +- Status: Proposed +- Date: 2026-06-15 +- Discussion: [#17685](https://github.com/SeleniumHQ/selenium/pull/17685) + +## Context + +A user can register more than one handler for the same network phase, and matching handlers +can disagree: the company framework always adds a test header, the local suite stubs all calls +to a domain, and one test aborts a single call. Selenium must resolve that and provide a single +response to the browser in a consistent and obvious way. + +The TLC discussed these ideas in a design document (by @p0deje). That document included +prescribed implementation details that this ADR is avoiding, to focus on the user-facing +behaviors we want rather than what needs to be implemented to achieve them. + +## Decision + +This applies to request and response handlers, but not authentication handlers, since +authentication should not use a callable. + +Note that there are multiple ways to implement these behaviors; the code examples are one +option in one language, and represent user-facing code. + +1. **A handler can specify event disposition.** Allow the user to specify how the event is disposed of + by acting on the object provided to the callable. + * Playwright only intercepts requests and requires an explicit disposition: continue (stop + processing other handlers), fulfill (respond with a mock), abort (respond with an error), + fallback (process other handlers, if any). + * Selenium supports: + * Request: `fail` (Playwright's `abort`, BiDi's `FailRequest`), `respond` (Playwright's `fulfill`, BiDi's `ProvideResponse`), and `submit` (Playwright's `continue`, BiDi's `ContinueRequest`). + * Response: `fail` (BiDi's `FailRequest`), and `submit`: note that since we don't need to prevent a round trip from a request, whether this is a BiDi `ContinueResponse` or `ProvideResponse` can be an implementation detail based on whether a replacement body value is provided. + +```ruby +# Specifics of parameters and names can match spec details +network.add_request_handler { |r| r.fail if something } +network.add_request_handler { |r| r.respond(content: mocked_response) if something } +network.add_request_handler { |r| r.add_header("X-Test", true) && r.submit if something } + +network.add_response_handler { |r| r.fail if something } +network.add_response_handler { |r| r.submit(content: mocked_response) if something } +network.add_response_handler { |r| r.add_header("X-Test", true) && r.submit if something } +``` + +2. **Default disposition is to process other handlers.** If a handler does not specify the + disposition, the original event and any staged mutations pass to the next handler. If no + handler ever specifies one, the event proceeds with the staged mutations. + * In Playwright request interception there is no default; the user must specify fallback if + that is the intent. + +```ruby +# All of these stage a change and pass to the next handler +network.add_request_handler { |r| r.add_header("X-Test", true) } +network.add_request_handler { |r| r.remove_header("upgrade-insecure-requests") } +network.add_request_handler { |r| r.content = r.request.content.gsub("a", "b") } +``` + +3. **Later-registered handlers are consulted first.** Registering an additional handler can + mutate the state used by previously registered ones. + * Matches Playwright's Last-In-First-Out (LIFO) behavior. + * Allows users to locally override handlers set by a shared library or suite. + * The alternative is being stuck with the top-level behavior everywhere, or not being able to + set top-level defaults at all. + +```ruby +# Header will be there because removal is attempted before it is added +network.add_request_handler { |r| r.add_header("X-Test", true) } +network.add_request_handler { |r| r.remove_header("X-Test") } +``` + +4. **Handlers with uncaught exceptions are not processed.** Handling proceeds as if the handler + were never registered for that event; its staged changes are discarded and the error is + logged. + * A problem in a handler should not corrupt live traffic or prevent other handler + interactions. + * In Playwright, uncaught exceptions propagate to end the session, which causes problems when + something unrelated to the test's intent goes wrong. + * Selenium is more lenient and only logs the error to the console. + +```ruby +# Header addition will still be processed; the error with details gets logged +network.add_request_handler { |r| r.add_header("X-Test", true) } +network.add_request_handler { |r| raise Exception } +``` + +5. **Return values within the callables are ignored.** No meaning will ever be applied to + anything a user explicitly or implicitly returns within the callable. + * Playwright also does this, as does Selenium's current Python implementation. + +```ruby +# Ruby: this implicit return value is ignored +network.add_request_handler { |r| r.add_header("X-Test", true); "this value is ignored" } +``` + +```python +# Python: this explicit return value is ignored +def handler(r): + r.add_header("X-Test", True) + return "this value is ignored" + +driver.network.add_request_handler(handler) +``` + +6. **A handler has access to the original event value.** It may see the changes staged by + handlers already executed, but can also read the unmodified event value. + * Supports building observation-only handlers as interceptions until read-only observation is + decided separately. + * Playwright uses a completely separate mechanism to differentiate observation from mutation, + so it does not need to address this. + +```ruby +# Nothing gets raised +network.add_request_handler { |r| raise unless r.headers.include?("X-Test") } +network.add_request_handler { |r| raise if r.request.headers.include?("X-Test") } +network.add_request_handler { |r| r.add_header("X-Test", true) } +``` + +7. **A handler can set a complete status.** Allow the user to specify how the handler is disposed + of by acting on the object provided to the callable. Marking complete stores the value of the + event in the calling class and calls `submit` on the event and unregisters the handler. + * Playwright supports `page.unroute` within the route lambda, but getting the event's value at + that stage requires a lot more boilerplate. + * The user doesn't need to create external atomic/thread-safe data structures to obtain the + "final" value of the event. + * Selenium doesn't need to include additional methods for waiting or expectations as part of + the API. + +```ruby +handle = network.add_request_handler { |r| r.complete if condition } +do_the_thing_that_completes +completed_request = network.get_completed_request(handle) +``` + +8. **Data collection is the handler's responsibility.** Reading an event's body requires a data + collector; the handler registers it, retrieves the data, and tears it down as necessary. + The body is available on the event, and is included in the captured value of a completed event + (behavior 7). + * The user never calls `addDataCollector` / `getData` or manages a collector's lifecycle, size + cap, or browser-support quirks. + * There is no way to collect or read body data outside a handler; collection happens only + through `add_x_handler`. + * Playwright exposes bodies through its response object without a user-managed collector; + Selenium does the same, owning the collector behind the handler. + * Whether collection is always-on or opt-in can be a separate decision + +```ruby +# The response body is available on the event; the collector is managed for you +network.add_response_handler { |r| log(r.body) } +``` + +## Considered options + +- **Reconciliation (behaviors 1 & 2).** + - We could run every handler and reconcile by a fixed priority (fail > stub > continue). But + this prevents a user from exercising the `continueRequest` behavior from a specific + handler, so all mutations from all handlers would be applied by default. + - We could run every handler but have `continueRequest` override failures and stubs (current + Python behavior), but it is not obvious why that command should have precedence. +- **Verb names (behavior 1).** + - We could follow Playwright's (abort / fulfill / continue / fallback). + - We could follow BiDi's more explicitly (failRequest / provideResponse / continueRequest / continueResponse). +- **Explicit disposition (2).** + - We could require the user to specify fallback explicitly like Playwright does. +- **Ordering (behavior 3).** + - We could run in order of handler registration, but this prevents users from overriding global settings locally. +- **Failure (4).** + - We could propagate the uncaught exception to end the session like Playwright does, but this + puts a larger burden on the users to manage network issues and bugs that aren't part of a + test. This is likely to be a bigger issue if we intercept every event by default. +- **Return values (5).** + - We could have the return values set state for the event or handler rather than storing it in + the event wrapper object we provide, but this is not as straightforward in all languages + and adds additional complications. +- **Original access (6).** + - We could only expose the modified event, or only the original, instead of both. + - We could provide a separate observation API like Playwright, but even when mutating it could + make sense to evaluate a conditional from the original event rather than the mutated one. +- **Complete status (7).** + - We could require external thread-safe data structures for capture. + - We could require all handler management to go through the Network class and be managed + directly by the user, but this would require us to add significant additional methods and + boilerplate. +- **Data collection (8).** + - We could require the user to manage the data collector directly through the low-level + commands, but collection has no meaning outside a handler and would push lifecycle, + size-cap, and browser-support bookkeeping onto the user. + +## Consequences + +- Together, these let a test override shared handlers locally and resolve a request its own way, + keep a broken handler contained, and keep the original event readable. + +## Binding status + +| Binding | Status | Notes | +|------------|---------|-------| +| Java | pending | tbd | +| Python | pending | tbd | +| Ruby | pending | tbd | +| .NET | pending | tbd | +| JavaScript | pending | tbd | + +## Appendix + +### Possible Implementation + +The behaviors in this ADR explicitly do not specify an implementation. For illustrative +purposes, this code — with state stored in the request wrapper object and evaluated after +execution inside the loop — will satisfy the above behaviors: + +```ruby +def process_request(request) + @handlers.reverse_each do |h| + h.call(request) + if request.complete? + h.request = request + remove_handler(h) + end + if request.failed? + return fail_request(request) + elsif request.response? + return provide_response(request) + elsif request.submit? || request.complete? + return continue_request(request) + end + end + continue_request(request) +end +``` From 281e9bbc8e9b54f196aece2676a42589ae71db9c Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 23 Jun 2026 10:17:57 -0500 Subject: [PATCH 2/3] [docs] remove proposed complete disposition --- .../17685-network-handler-behavior.md | 108 +++++------------- 1 file changed, 31 insertions(+), 77 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 64496e1874416..c5dfe43a29782 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -1,7 +1,6 @@ -# Network handler behavior +# 17685. Network handlers dispose of events without waiting for other handlers - Status: Proposed -- Date: 2026-06-15 - Discussion: [#17685](https://github.com/SeleniumHQ/selenium/pull/17685) ## Context @@ -11,13 +10,24 @@ can disagree: the company framework always adds a test header, the local suite s to a domain, and one test aborts a single call. Selenium must resolve that and provide a single response to the browser in a consistent and obvious way. -The TLC discussed these ideas in a design document (by @p0deje). That document included -prescribed implementation details that this ADR is avoiding, to focus on the user-facing -behaviors we want rather than what needs to be implemented to achieve them. +The bindings diverge today: each grew its handler API independently, so dispatch order, +multi-handler resolution, error handling, and what an event exposes are all inconsistent. + +| Binding | Current behavior | +|------------|------------------| +| Java | Only the first matching handler runs; disposition is always continue; a throwing handler propagates and leaves the request blocked; return-value driven; no response handler or managed body collection. | +| Python | An explicit `continue` in a handler fires immediately and wins; otherwise staged outcomes reconcile by `fail` > `provide_response` > `continue`; response handlers have no `fail`; dispatch is FIFO; a throwing handler's staged mutations are still sent; only the mutated event is visible; body is not collected behind the handler. | +| Ruby | Handlers run in parallel threads, so multi-handler disposition races; exceptions are logged; dispatch is FIFO with no default-continue; only the mutated event is visible; body collection is user-managed. | +| .NET | No request or response handler API. | +| JavaScript | No request or response handler API. | ## Decision -This applies to request and response handlers, but not authentication handlers, since +Selenium consults network handlers one at a time and lets each dispose of the event as it runs: +the first handler to specify a disposition (fail, respond, or submit) resolves the event and +stops the chain, while a handler that only stages mutations passes the event to the next one. +Selenium does not gather every handler's outcome and reconcile it at the end. The behaviors +below apply to request and response handlers, but not authentication handlers, since authentication should not use a callable. Note that there are multiple ways to implement these behaviors; the code examples are one @@ -116,37 +126,20 @@ network.add_request_handler { |r| raise if r.request.headers.include?("X-Test") network.add_request_handler { |r| r.add_header("X-Test", true) } ``` -7. **A handler can set a complete status.** Allow the user to specify how the handler is disposed - of by acting on the object provided to the callable. Marking complete stores the value of the - event in the calling class and calls `submit` on the event and unregisters the handler. - * Playwright supports `page.unroute` within the route lambda, but getting the event's value at - that stage requires a lot more boilerplate. - * The user doesn't need to create external atomic/thread-safe data structures to obtain the - "final" value of the event. - * Selenium doesn't need to include additional methods for waiting or expectations as part of - the API. - -```ruby -handle = network.add_request_handler { |r| r.complete if condition } -do_the_thing_that_completes -completed_request = network.get_completed_request(handle) -``` - -8. **Data collection is the handler's responsibility.** Reading an event's body requires a data - collector; the handler registers it, retrieves the data, and tears it down as necessary. - The body is available on the event, and is included in the captured value of a completed event - (behavior 7). - * The user never calls `addDataCollector` / `getData` or manages a collector's lifecycle, size - cap, or browser-support quirks. +7. **Body data is collected only when the handler opts in at registration.** A body is not + available by default; the handler declares that it needs the body when it is registered — not + from inside the callback, since the collector must be in place before the event — and Selenium + then owns the collector's lifecycle, size cap, and browser-support quirks. The body is readable + on the event inside that handler. + * The user never calls `addDataCollector` / `getData` or tears a collector down. * There is no way to collect or read body data outside a handler; collection happens only through `add_x_handler`. * Playwright exposes bodies through its response object without a user-managed collector; Selenium does the same, owning the collector behind the handler. - * Whether collection is always-on or opt-in can be a separate decision ```ruby -# The response body is available on the event; the collector is managed for you -network.add_response_handler { |r| log(r.body) } +# Declare body collection at registration; the body is then available on the event +network.add_response_handler(collect_body: true) { |r| log(r.body) } ``` ## Considered options @@ -176,55 +169,16 @@ network.add_response_handler { |r| log(r.body) } - We could only expose the modified event, or only the original, instead of both. - We could provide a separate observation API like Playwright, but even when mutating it could make sense to evaluate a conditional from the original event rather than the mutated one. -- **Complete status (7).** - - We could require external thread-safe data structures for capture. - - We could require all handler management to go through the Network class and be managed - directly by the user, but this would require us to add significant additional methods and - boilerplate. -- **Data collection (8).** +- **Data collection (7).** + - We could collect every body always, but bodies are large and most handlers never read them, + so collection is opt-in at registration instead. - We could require the user to manage the data collector directly through the low-level commands, but collection has no meaning outside a handler and would push lifecycle, size-cap, and browser-support bookkeeping onto the user. ## Consequences -- Together, these let a test override shared handlers locally and resolve a request its own way, - keep a broken handler contained, and keep the original event readable. - -## Binding status - -| Binding | Status | Notes | -|------------|---------|-------| -| Java | pending | tbd | -| Python | pending | tbd | -| Ruby | pending | tbd | -| .NET | pending | tbd | -| JavaScript | pending | tbd | - -## Appendix - -### Possible Implementation - -The behaviors in this ADR explicitly do not specify an implementation. For illustrative -purposes, this code — with state stored in the request wrapper object and evaluated after -execution inside the loop — will satisfy the above behaviors: - -```ruby -def process_request(request) - @handlers.reverse_each do |h| - h.call(request) - if request.complete? - h.request = request - remove_handler(h) - end - if request.failed? - return fail_request(request) - elsif request.response? - return provide_response(request) - elsif request.submit? || request.complete? - return continue_request(request) - end - end - continue_request(request) -end -``` +- Client code can override shared handlers locally and resolve a request its own way, a broken + handler stays contained, and the original event remains readable. +- This changes handler behavior that several bindings already ship, so it is not backwards + compatible. From 895d5329c286aa75a17273d1329bffcacf199f13 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 23 Jun 2026 17:28:02 -0500 Subject: [PATCH 3/3] [docs] add observe vs intercept modes to network handler ADR --- .../17685-network-handler-behavior.md | 100 ++++++++++++------ 1 file changed, 68 insertions(+), 32 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index c5dfe43a29782..8bf190c784cf7 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -1,4 +1,4 @@ -# 17685. Network handlers dispose of events without waiting for other handlers +# 17685. The user directly controls network handler behavior and event disposition - Status: Proposed - Discussion: [#17685](https://github.com/SeleniumHQ/selenium/pull/17685) @@ -23,17 +23,39 @@ multi-handler resolution, error handling, and what an event exposes are all inco ## Decision -Selenium consults network handlers one at a time and lets each dispose of the event as it runs: -the first handler to specify a disposition (fail, respond, or submit) resolves the event and -stops the chain, while a handler that only stages mutations passes the event to the next one. -Selenium does not gather every handler's outcome and reconcile it at the end. The behaviors -below apply to request and response handlers, but not authentication handlers, since -authentication should not use a callable. +A network handler is registered either to **observe** or to **intercept**, and the event object it +receives enforces the difference (behavior 1). For intercept handlers, Selenium consults them one at +a time and lets each dispose of the event as it runs: the first handler to specify a disposition +(fail, respond, or submit) resolves the event and stops the chain, while a handler that only stages +mutations passes the event to the next one. Selenium does not gather every handler's outcome and +reconcile it at the end. The behaviors below apply to request and response handlers, but not +authentication handlers, since authentication should not use a callable. Note that there are multiple ways to implement these behaviors; the code examples are one option in one language, and represent user-facing code. -1. **A handler can specify event disposition.** Allow the user to specify how the event is disposed of +1. **A handler is added to observe or to intercept, and the event object it receives enforces + which.** There is one method to add handlers, and which mode the handler operates under is decided + at creation; intercepting is the default and observing is opt-in. An observing handler receives a + read-only event object: it can read the event but has no methods to mutate or settle it, and it + does not pause + network traffic. An intercepting handler receives a mutable event object: it can stage changes and + settle the event, and network traffic is paused until handling resolves it. Because the object's + type carries the difference — a read-only object simply has no mutate-or-settle methods — nothing + has to introspect the callable to tell the modes apart. + * How a binding lets the user pick the mode — a keyword argument, an options object, an overload — + is its own idiom; what is fixed is that it is the same method, not a separate observe one. + +```ruby +# Same method, two modes; the event object handed to the block differs +network.add_request_handler { |r| r.fail if something } # intercept: mutable, blocking +network.add_request_handler(observe: true) { |r| log(r.url) } # observe: read-only, non-blocking + +# An observed event object has no mutation methods, so trying to mutate raises +network.add_request_handler(observe: true) { |r| r.fail } # raises: observed events are read-only +``` + +2. **An intercept handler can specify event disposition.** Allow the user to specify how the event is disposed of by acting on the object provided to the callable. * Playwright only intercepts requests and requires an explicit disposition: continue (stop processing other handlers), fulfill (respond with a mock), abort (respond with an error), @@ -53,7 +75,7 @@ network.add_response_handler { |r| r.submit(content: mocked_response) if somethi network.add_response_handler { |r| r.add_header("X-Test", true) && r.submit if something } ``` -2. **Default disposition is to process other handlers.** If a handler does not specify the +3. **Default disposition is to process other handlers.** If a handler does not specify the disposition, the original event and any staged mutations pass to the next handler. If no handler ever specifies one, the event proceeds with the staged mutations. * In Playwright request interception there is no default; the user must specify fallback if @@ -66,7 +88,7 @@ network.add_request_handler { |r| r.remove_header("upgrade-insecure-requests") } network.add_request_handler { |r| r.content = r.request.content.gsub("a", "b") } ``` -3. **Later-registered handlers are consulted first.** Registering an additional handler can +4. **Later-registered handlers are consulted first.** Registering an additional handler can mutate the state used by previously registered ones. * Matches Playwright's Last-In-First-Out (LIFO) behavior. * Allows users to locally override handlers set by a shared library or suite. @@ -79,22 +101,25 @@ network.add_request_handler { |r| r.add_header("X-Test", true) } network.add_request_handler { |r| r.remove_header("X-Test") } ``` -4. **Handlers with uncaught exceptions are not processed.** Handling proceeds as if the handler - were never registered for that event; its staged changes are discarded and the error is - logged. - * A problem in a handler should not corrupt live traffic or prevent other handler - interactions. - * In Playwright, uncaught exceptions propagate to end the session, which causes problems when - something unrelated to the test's intent goes wrong. - * Selenium is more lenient and only logs the error to the console. +5. **An uncaught exception discards the handler's staged changes; it propagates for an intercept + handler and is logged for an observe handler.** Either way the event keeps flowing as if that + handler had not run, so one broken handler cannot corrupt live traffic or stall the page. The + difference is visibility: an intercept handler expresses the test's intent, so a failure in it + surfaces to the user; an observe handler is passive monitoring, so an incidental failure (a + third-party beacon, an analytics call) is logged and never fails the test. + * In Playwright every uncaught exception ends the session; routing by mode keeps interception + strict without coupling the test to errors from the open internet it never meant to assert on. ```ruby -# Header addition will still be processed; the error with details gets logged +# Intercept: the error surfaces; the header addition from the other handler still applies network.add_request_handler { |r| r.add_header("X-Test", true) } network.add_request_handler { |r| raise Exception } + +# Observe: the error is logged, the test is unaffected +network.add_request_handler(observe: true) { |r| raise Exception } ``` -5. **Return values within the callables are ignored.** No meaning will ever be applied to +6. **Return values within the callables are ignored.** No meaning will ever be applied to anything a user explicitly or implicitly returns within the callable. * Playwright also does this, as does Selenium's current Python implementation. @@ -112,10 +137,10 @@ def handler(r): driver.network.add_request_handler(handler) ``` -6. **A handler has access to the original event value.** It may see the changes staged by +7. **A handler has access to the original event value.** It may see the changes staged by handlers already executed, but can also read the unmodified event value. - * Supports building observation-only handlers as interceptions until read-only observation is - decided separately. + * Even when intercepting and mutating, a conditional can be evaluated against the original value + rather than the version a prior handler changed. * Playwright uses a completely separate mechanism to differentiate observation from mutation, so it does not need to address this. @@ -126,7 +151,7 @@ network.add_request_handler { |r| raise if r.request.headers.include?("X-Test") network.add_request_handler { |r| r.add_header("X-Test", true) } ``` -7. **Body data is collected only when the handler opts in at registration.** A body is not +8. **Body data is collected only when the handler opts in at registration.** A body is not available by default; the handler declares that it needs the body when it is registered — not from inside the callback, since the collector must be in place before the event — and Selenium then owns the collector's lifecycle, size cap, and browser-support quirks. The body is readable @@ -144,32 +169,43 @@ network.add_response_handler(collect_body: true) { |r| log(r.body) } ## Considered options -- **Reconciliation (behaviors 1 & 2).** +- **Modes (behavior 1).** + - We could give observation its own method, separate from interception, but both modes share the + same registration shape (URL patterns, body opt-in, the removal handle), so a separate method + duplicates the whole surface; and the read-only contract cannot be enforced by the method + anyway — we will not introspect the callable — so the event object's type carries it and the + method stays the same. + - We could make every handler an interception and add read-only observation later, but routing + observation through interception pauses traffic and perturbs what it records (cache behavior, + timing), so observation-only is worth providing as its own option now, not deferred. +- **Reconciliation (behaviors 2 & 3).** - We could run every handler and reconcile by a fixed priority (fail > stub > continue). But this prevents a user from exercising the `continueRequest` behavior from a specific handler, so all mutations from all handlers would be applied by default. - We could run every handler but have `continueRequest` override failures and stubs (current Python behavior), but it is not obvious why that command should have precedence. -- **Verb names (behavior 1).** +- **Verb names (behavior 2).** - We could follow Playwright's (abort / fulfill / continue / fallback). - We could follow BiDi's more explicitly (failRequest / provideResponse / continueRequest / continueResponse). -- **Explicit disposition (2).** +- **Explicit disposition (3).** - We could require the user to specify fallback explicitly like Playwright does. -- **Ordering (behavior 3).** +- **Ordering (behavior 4).** - We could run in order of handler registration, but this prevents users from overriding global settings locally. -- **Failure (4).** +- **Failure (5).** - We could propagate the uncaught exception to end the session like Playwright does, but this puts a larger burden on the users to manage network issues and bugs that aren't part of a test. This is likely to be a bigger issue if we intercept every event by default. -- **Return values (5).** + - We could log every handler's exception regardless of mode, but an intercept handler's failure + is the test's own bug and should not be swallowed. +- **Return values (6).** - We could have the return values set state for the event or handler rather than storing it in the event wrapper object we provide, but this is not as straightforward in all languages and adds additional complications. -- **Original access (6).** +- **Original access (7).** - We could only expose the modified event, or only the original, instead of both. - We could provide a separate observation API like Playwright, but even when mutating it could make sense to evaluate a conditional from the original event rather than the mutated one. -- **Data collection (7).** +- **Data collection (8).** - We could collect every body always, but bodies are large and most handlers never read them, so collection is opt-in at registration instead. - We could require the user to manage the data collector directly through the low-level