From 55c779f311f7c7b84aedecc0ae7a68391e01b51f Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 16 Jun 2026 10:58:17 -0500 Subject: [PATCH 01/20] [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 f0d8d878dff969447e630a230e23a562fe77e827 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 23 Jun 2026 10:17:57 -0500 Subject: [PATCH 02/20] [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 afcacbb7c965d519fcef0ab1c7e552b0318b4678 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 23 Jun 2026 17:28:02 -0500 Subject: [PATCH 03/20] [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 From 2d7b847a6b764930eb72053ea3231b0d9782ed9c Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 8 Jul 2026 11:25:48 -0500 Subject: [PATCH 04/20] [docs] add handler registration surface to ADR 17685 --- .../17685-network-handler-behavior.md | 229 +++++++++--------- 1 file changed, 117 insertions(+), 112 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 8bf190c784cf7..af0391b2424ef 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -1,17 +1,21 @@ -# 17685. The user directly controls network handler behavior and event disposition +# 17685. Network handler registration and event disposition - Status: Proposed - 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. +Selenium's network API lets a user observe and rewrite traffic by registering handlers for +requests, responses, and authentication challenges. This record settles two things together: how +handlers are registered, removed, and cleared, and how a handler behaves — including how several +handlers registered for the same phase reconcile to the single response the browser needs. -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. +A user can register more than one handler for the same phase, and matching handlers can disagree: a +shared framework always adds a test header, the local suite stubs a domain, and one test aborts a +single call. Selenium must reconcile that into one response, consistently and obviously. + +The behavior is unsettled and the bindings diverge — each grew its dispatch independently, so +ordering, multi-handler resolution, error handling, and what an event exposes are all inconsistent: | Binding | Current behavior | |------------|------------------| @@ -21,28 +25,52 @@ multi-handler resolution, error handling, and what an event exposes are all inco | .NET | No request or response handler API. | | JavaScript | No request or response handler API. | +Handlers are reached through `driver.network`, the supported protocol-neutral API established by the +BiDi implementation boundaries decision ([#17670](https://github.com/SeleniumHQ/selenium/pull/17670)); +nothing here exposes a protocol type. + ## Decision -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. +Request and response handlers observe or intercept and reconcile to one disposition; authentication +handlers supply credentials. Selenium consults intercept handlers one at a time and lets each +dispose of the event as it runs — it does not gather every handler's outcome and reconcile at the +end. There are multiple ways to implement the decisions below; the code examples are one option in +one language, and represent user-facing code. + +1. **Handlers are added, removed, and cleared.** Each family — request, response, authentication — + has an add, a remove, and a clear. `add` returns a handle; `remove` takes that handle and + unregisters exactly that handler; `clear` removes every handler in the family. Removing a handler + stops it being consulted for later events but does not disturb an event already in flight. + * A binding maps this to its idiom: .NET adds and removes with `+=` / `-=` on an event, where the + delegate reference is the handle that `-=` needs. + * Returning a handle — rather than requiring the user to hold the original callable — lets a + handler registered inline still be removed. + +```ruby +handle = network.add_request_handler { |r| r.fail if blocked?(r.url) } +network.remove_request_handler(handle) +network.clear_request_handlers +``` + +2. **A handler is a callable, including authentication.** A request or response callable receives the + event object and acts on it. An authentication callable receives the challenge and returns + credentials for it; it does not fail, respond, or submit, and is not part of the disposition + chain. A callable lets credentials be computed per challenge; a static username and password for a + URL pattern is also accepted directly, without 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. +```ruby +network.add_authentication_handler { |c| c.respond(vault.credentials_for(c.url)) } +network.add_authentication_handler(username: "user", password: "pass", uri: "https://secure.example.com/*") +``` -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 +3. **A request or response handler observes or intercepts, and the event object 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. + 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. @@ -55,8 +83,9 @@ network.add_request_handler(observe: true) { |r| log(r.url) } # observe: read- 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. +4. **An intercept handler can specify event disposition, and the first to do so resolves the event.** + The user disposes of the event by acting on the object provided to the callable; the first handler + to specify a disposition resolves the event and stops the chain. * 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). @@ -65,35 +94,30 @@ network.add_request_handler(observe: true) { |r| r.fail } # raises: observ * 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 +# Names and params can match spec detail; response verbs mirror request (fail, submit) 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 } ``` -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 - that is the intent. +5. **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 +# Stages a change and passes to the next handler; no disposition specified 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") } ``` -4. **Later-registered handlers are consulted first.** Registering an additional handler can - mutate the state used by previously registered ones. +6. **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. + * 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 @@ -101,14 +125,12 @@ network.add_request_handler { |r| r.add_header("X-Test", true) } network.add_request_handler { |r| r.remove_header("X-Test") } ``` -5. **An uncaught exception discards the handler's staged changes; it propagates for an intercept +7. **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 # Intercept: the error surfaces; the header addition from the other handler still applies @@ -119,8 +141,8 @@ network.add_request_handler { |r| raise Exception } network.add_request_handler(observe: true) { |r| raise Exception } ``` -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. +8. **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 @@ -128,21 +150,10 @@ network.add_request_handler(observe: true) { |r| raise Exception } 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) -``` - -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. +9. **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. * 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. ```ruby # Nothing gets raised @@ -151,16 +162,14 @@ network.add_request_handler { |r| raise if r.request.headers.include?("X-Test") network.add_request_handler { |r| r.add_header("X-Test", true) } ``` -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 - 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. +10. **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`. ```ruby # Declare body collection at registration; the body is then available on the event @@ -169,52 +178,48 @@ network.add_response_handler(collect_body: true) { |r| log(r.body) } ## Considered options -- **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 2).** - - We could follow Playwright's (abort / fulfill / continue / fallback). - - We could follow BiDi's more explicitly (failRequest / provideResponse / continueRequest / continueResponse). -- **Explicit disposition (3).** - - We could require the user to specify fallback explicitly like Playwright does. -- **Ordering (behavior 4).** - - We could run in order of handler registration, but this prevents users from overriding global settings locally. -- **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. - - 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 (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 (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 - commands, but collection has no meaning outside a handler and would push lifecycle, - size-cap, and browser-support bookkeeping onto the user. +- **Registration surface (decision 1).** Separate top-level driver methods or a handler-collection + object instead of `driver.network` with three symmetric families — rejected: the boundaries + decision fixes `driver.network` as the neutral accessor and one shape keeps the families + consistent. `add` only, no `remove` / `clear` — rejected: a handler installed by a shared suite + could not be retracted for one test, which the LIFO override (decision 6) relies on. Remove by + passing the original callable rather than a returned handle — rejected: an inline block has no + stable identity, so `add` returns a handle. +- **Authentication as a callable (decision 2).** Exclude auth from the callable model and expose only + static credentials, as an earlier draft did — rejected: a callable returning credentials lets them + be computed per challenge and reuses the one registration surface; the disposition verbs do not + apply. The static case is kept as an explicit form, not the whole surface. +- **Modes (decision 3).** Give observation its own method — rejected: it shares the whole + registration shape, and the read-only contract can only be carried by the event object's type (we + will not introspect the callable), not the method. Make everything interception and add observation + later — rejected: routing observation through interception pauses traffic and perturbs what it + records (cache, timing). +- **Reconciliation (decisions 4 & 5).** Run every handler and reconcile by fixed priority + (fail > stub > continue), or let `continueRequest` override (current Python) — rejected: both take + disposition away from the individual handler, and neither precedence is obvious. +- **Verb names (decision 4).** Playwright's (abort / fulfill / continue / fallback) or BiDi's + (failRequest / provideResponse / continueRequest / continueResponse) — either can be matched to + spec detail per binding. +- **Ordering (decision 6).** Registration order instead of LIFO — rejected: it prevents overriding + global settings locally. +- **Failure (decision 7).** End the session on any uncaught exception (Playwright) — rejected: + burdens users with unrelated network errors, worse when intercepting by default. Log every + exception regardless of mode — rejected: an intercept handler's failure is the test's own bug. +- **Return values (decision 8).** Let a return value set event or handler state instead of acting on + the wrapper — rejected: not straightforward across all languages. +- **Original access (decision 9).** Expose only the modified or only the original event — rejected: a + conditional may need the original even while mutating. +- **Data collection (decision 10).** Always collect bodies — rejected: bodies are large and rarely + read. Make the user manage the collector — rejected: it has no meaning outside a handler and pushes + lifecycle and size-cap bookkeeping onto them. ## Consequences +- Every binding implements one add / remove / clear surface for request, response, and authentication + handlers rather than diverging, with .NET expressing it through `+=` / `-=` events. - 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. +- Authentication handlers gain a callable form in addition to static credentials, so credentials can + be produced per challenge. - This changes handler behavior that several bindings already ship, so it is not backwards compatible. From a69cb61d0224ccecfe8ffd6ed1ca7779ef2670c2 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Mon, 13 Jul 2026 12:26:14 -0500 Subject: [PATCH 05/20] [docs] refine ADR 17685: add filter surface, retitle, tidy auth and wording --- .../17685-network-handler-behavior.md | 229 ++++++++++++------ 1 file changed, 159 insertions(+), 70 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index af0391b2424ef..ddf7f7a6a42ab 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -1,4 +1,4 @@ -# 17685. Network handler registration and event disposition +# 17685. The network async/event API - Status: Proposed - Discussion: [#17685](https://github.com/SeleniumHQ/selenium/pull/17685) @@ -19,7 +19,7 @@ ordering, multi-handler resolution, error handling, and what an event exposes ar | 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. | +| Java | Only one matching handler runs, chosen in no defined order (handlers are held in a `ConcurrentHashMap`); 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. | @@ -34,17 +34,13 @@ nothing here exposes a protocol type. Request and response handlers observe or intercept and reconcile to one disposition; authentication handlers supply credentials. Selenium consults intercept handlers one at a time and lets each dispose of the event as it runs — it does not gather every handler's outcome and reconcile at the -end. There are multiple ways to implement the decisions below; the code examples are one option in -one language, and represent user-facing code. +end. There are multiple ways to implement the decisions below; the examples are illustrative +user-facing code in Ruby and Java. 1. **Handlers are added, removed, and cleared.** Each family — request, response, authentication — - has an add, a remove, and a clear. `add` returns a handle; `remove` takes that handle and + has an add, a remove, and a clear. `add` returns a handle object; `remove` takes that handle and unregisters exactly that handler; `clear` removes every handler in the family. Removing a handler stops it being consulted for later events but does not disturb an event already in flight. - * A binding maps this to its idiom: .NET adds and removes with `+=` / `-=` on an event, where the - delegate reference is the handle that `-=` needs. - * Returning a handle — rather than requiring the user to hold the original callable — lets a - handler registered inline still be removed. ```ruby handle = network.add_request_handler { |r| r.fail if blocked?(r.url) } @@ -52,18 +48,45 @@ network.remove_request_handler(handle) network.clear_request_handlers ``` -2. **A handler is a callable, including authentication.** A request or response callable receives the - event object and acts on it. An authentication callable receives the challenge and returns - credentials for it; it does not fail, respond, or submit, and is not part of the disposition - chain. A callable lets credentials be computed per challenge; a static username and password for a - URL pattern is also accepted directly, without a callable. +```java +RequestHandler handle = network.addRequestHandler(r -> { if (blocked(r.url())) r.fail(); }); +network.removeRequestHandler(handle); +network.clearRequestHandlers(); +``` + +2. **A handler is scoped by an optional list of URL patterns given as structured objects.** Each + pattern is an object of URL components — protocol, host, port, path, query — each optional: a + component that is set matches exactly, one left unset matches any value. A handler matches a + request against any pattern in its list; with no list it matches every request. A pattern is an + object, not a URL or glob string. + +```ruby +# One or more component objects; a request matches any of them +network.add_request_handler(url_patterns: [{host: "api.example.com"}, {host: "cdn.example.com"}]) { |r| r.fail } +``` + +```java +network.addRequestHandler( + List.of(UrlPattern.host("api.example.com"), UrlPattern.host("cdn.example.com")), + r -> r.fail()); +``` + +3. **A handler is a callable that acts on the event object.** A request or response handler may + observe it, change it, or settle its disposition (decisions 4–6); an authentication handler + supplies credentials for it, computed in the callable or given as a static username and password. ```ruby -network.add_authentication_handler { |c| c.respond(vault.credentials_for(c.url)) } -network.add_authentication_handler(username: "user", password: "pass", uri: "https://secure.example.com/*") +# Credentials computed in the callable, or supplied statically +network.add_authentication_handler { |e| e.authenticate(vault.credentials_for(e.url)) } +network.add_authentication_handler(username: "user", password: "pass", url_patterns: [{host: "secure.example.com"}]) +``` + +```java +network.addAuthenticationHandler(e -> e.authenticate(vault.credentialsFor(e.url()))); +network.addAuthenticationHandler("user", "pass", List.of(UrlPattern.host("secure.example.com"))); ``` -3. **A request or response handler observes or intercepts, and the event object enforces which.** +4. **A request or response handler observes or intercepts, and the event object 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 @@ -83,9 +106,15 @@ network.add_request_handler(observe: true) { |r| log(r.url) } # observe: read- network.add_request_handler(observe: true) { |r| r.fail } # raises: observed events are read-only ``` -4. **An intercept handler can specify event disposition, and the first to do so resolves the event.** - The user disposes of the event by acting on the object provided to the callable; the first handler - to specify a disposition resolves the event and stops the chain. +```java +network.addRequestHandler(r -> { if (something) r.fail(); }); // intercept: mutable, blocking +network.addRequestHandler(new ObservationOptions(), r -> log(r.url())); // observe: read-only, non-blocking + +// The observe callback's event type has no fail()/mutate methods — this would not compile +``` + +5. **When a handler settles a disposition, the first to do so resolves the event and stops the + chain.** The user settles the event by acting on the object the callable receives. * 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). @@ -101,7 +130,14 @@ network.add_request_handler { |r| r.add_header("X-Test", true) && r.submit if so network.add_response_handler { |r| r.submit(content: mocked_response) if something } ``` -5. **Default disposition is to process other handlers.** If a handler does not specify the +```java +network.addRequestHandler(r -> { if (something) r.fail(); }); +network.addRequestHandler(r -> { if (something) r.respond(mockedResponse); }); +network.addRequestHandler(r -> { if (something) { r.addHeader("X-Test", "true"); r.submit(); } }); +network.addResponseHandler(r -> { if (something) r.submit(mockedResponse); }); +``` + +6. **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 @@ -112,8 +148,13 @@ network.add_response_handler { |r| r.submit(content: mocked_response) if somethi network.add_request_handler { |r| r.add_header("X-Test", true) } ``` -6. **Later-registered handlers are consulted first.** Registering an additional handler can mutate - the state used by previously registered ones. +```java +network.addRequestHandler(r -> r.addHeader("X-Test", "true")); +``` + +7. **Later-registered handlers are consulted first.** This applies to every family — request, + response, and authentication. 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 @@ -125,9 +166,14 @@ network.add_request_handler { |r| r.add_header("X-Test", true) } network.add_request_handler { |r| r.remove_header("X-Test") } ``` -7. **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 +```java +network.addRequestHandler(r -> r.addHeader("X-Test", "true")); +network.addRequestHandler(r -> r.removeHeader("X-Test")); +``` + +8. **An uncaught exception discards the handler's staged changes; it surfaces to the user 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. @@ -141,7 +187,14 @@ network.add_request_handler { |r| raise Exception } network.add_request_handler(observe: true) { |r| raise Exception } ``` -8. **Return values within the callables are ignored.** No meaning will ever be applied to anything a +```java +network.addRequestHandler(r -> r.addHeader("X-Test", "true")); +network.addRequestHandler(r -> { throw new RuntimeException(); }); // intercept: surfaces to the user + +network.addRequestHandler(new ObservationOptions(), r -> { throw new RuntimeException(); }); // observe: logged, test unaffected +``` + +9. **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. @@ -150,10 +203,15 @@ network.add_request_handler(observe: true) { |r| raise Exception } network.add_request_handler { |r| r.add_header("X-Test", true); "this value is ignored" } ``` -9. **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. - * Even when intercepting and mutating, a conditional can be evaluated against the original value - rather than the version a prior handler changed. +```java +// Java: the handler is a void Consumer, so there is no return value to ignore +network.addRequestHandler(r -> r.addHeader("X-Test", "true")); +``` + +10. **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. + * Even when intercepting and mutating, a conditional can be evaluated against the original value + rather than the version a prior handler changed. ```ruby # Nothing gets raised @@ -162,61 +220,92 @@ network.add_request_handler { |r| raise if r.request.headers.include?("X-Test") network.add_request_handler { |r| r.add_header("X-Test", true) } ``` -10. **Body data is collected only when the handler opts in at registration.** A body is not available +```java +network.addRequestHandler(r -> { if (!r.headers().containsKey("X-Test")) throw new AssertionError(); }); +network.addRequestHandler(r -> { if (r.request().headers().containsKey("X-Test")) throw new AssertionError(); }); +network.addRequestHandler(r -> r.addHeader("X-Test", "true")); +``` + +11. **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`. + * 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 + the `add_request_handler` / `add_response_handler` registration. ```ruby # Declare body collection at registration; the body is then available on the event network.add_response_handler(collect_body: true) { |r| log(r.body) } ``` +```java +network.addResponseHandler(new BodyCollection(), r -> log(r.body())); +``` + ## Considered options -- **Registration surface (decision 1).** Separate top-level driver methods or a handler-collection - object instead of `driver.network` with three symmetric families — rejected: the boundaries - decision fixes `driver.network` as the neutral accessor and one shape keeps the families - consistent. `add` only, no `remove` / `clear` — rejected: a handler installed by a shared suite - could not be retracted for one test, which the LIFO override (decision 6) relies on. Remove by - passing the original callable rather than a returned handle — rejected: an inline block has no - stable identity, so `add` returns a handle. -- **Authentication as a callable (decision 2).** Exclude auth from the callable model and expose only - static credentials, as an earlier draft did — rejected: a callable returning credentials lets them - be computed per challenge and reuses the one registration surface; the disposition verbs do not - apply. The static case is kept as an explicit form, not the whole surface. -- **Modes (decision 3).** Give observation its own method — rejected: it shares the whole - registration shape, and the read-only contract can only be carried by the event object's type (we - will not introspect the callable), not the method. Make everything interception and add observation - later — rejected: routing observation through interception pauses traffic and perturbs what it - records (cache, timing). -- **Reconciliation (decisions 4 & 5).** Run every handler and reconcile by fixed priority - (fail > stub > continue), or let `continueRequest` override (current Python) — rejected: both take - disposition away from the individual handler, and neither precedence is obvious. -- **Verb names (decision 4).** Playwright's (abort / fulfill / continue / fallback) or BiDi's - (failRequest / provideResponse / continueRequest / continueResponse) — either can be matched to - spec detail per binding. -- **Ordering (decision 6).** Registration order instead of LIFO — rejected: it prevents overriding - global settings locally. -- **Failure (decision 7).** End the session on any uncaught exception (Playwright) — rejected: - burdens users with unrelated network errors, worse when intercepting by default. Log every - exception regardless of mode — rejected: an intercept handler's failure is the test's own bug. -- **Return values (decision 8).** Let a return value set event or handler state instead of acting on - the wrapper — rejected: not straightforward across all languages. -- **Original access (decision 9).** Expose only the modified or only the original event — rejected: a - conditional may need the original even while mutating. -- **Data collection (decision 10).** Always collect bodies — rejected: bodies are large and rarely - read. Make the user manage the collector — rejected: it has no meaning outside a handler and pushes - lifecycle and size-cap bookkeeping onto them. +- **Registration surface (decision 1).** + - Separate top-level driver methods or a handler-collection object — the boundaries decision fixes + `driver.network` as the neutral accessor, and one shape keeps the families consistent. + - `add` only, no `remove` / `clear` — a handler installed by a shared suite could not be retracted + for one test, which the LIFO override (decision 7) relies on. + - Remove by passing the original callable rather than a returned handle — an inline block has no + stable identity to pass back. + - A bare numeric id as the handle — an object is type-safe and cannot be confused with an unrelated + id. +- **Filtering (decision 2).** + - No filter, matching only in the callback — no declarative scope a binding could hand to the + remote, so interception cannot be narrowed to the URLs of interest. + - A client-side predicate — adds nothing over a conditional in the callback and can never be handed + to the remote. + - A URL or glob string — the protocol matches each component by exact equality (an unset component + matches any), so a string reads as though `*` wildcards work when they do not, and its parsing is + ambiguous about which component a wildcard belongs to. + - The structured object is the shape the standard pattern syntax itself uses, so when the protocol + adopts the wildcard characters it reserves today, the same fields carry them — no structural + change, and existing exact patterns keep working. +- **Authentication as a callable (decision 3).** + - Exclude auth from the callable model and expose only static credentials (an earlier draft) — a + callable can compute credentials per challenge and reuses the one registration surface; the static + form is kept as sugar, not the whole surface. +- **Modes (decision 4).** + - Give observation its own method — it shares the whole registration shape, and the read-only + contract can only be carried by the event object's type (we will not introspect the callable), + not the method. + - Make everything interception and add observation later — routing observation through interception + pauses traffic and perturbs what it records (cache, timing). +- **Reconciliation (decisions 5 & 6).** + - Run every handler and reconcile by fixed priority (fail > stub > continue) — takes disposition + away from the individual handler. + - Let `continueRequest` override failures and stubs (current Python) — no obvious reason that + command should win. +- **Verb names (decision 5).** + - Playwright's (abort / fulfill / continue / fallback) or BiDi's (failRequest / provideResponse / + continueRequest / continueResponse) — either can be matched to spec detail per binding. +- **Ordering (decision 7).** + - Registration order instead of LIFO — prevents overriding global settings locally. +- **Failure (decision 8).** + - End the session on any uncaught exception (Playwright) — burdens users with unrelated network + errors, worse when intercepting by default. + - Log every exception regardless of mode — an intercept handler's failure is the test's own bug and + should surface. +- **Return values (decision 9).** + - Let a return value set event or handler state instead of acting on the wrapper — not + straightforward across all languages. +- **Original access (decision 10).** + - Expose only the modified or only the original event — a conditional may need the original even + while mutating. +- **Data collection (decision 11).** + - Always collect bodies — bodies are large and most handlers never read them. + - Make the user manage the collector — it has no meaning outside a handler and pushes lifecycle and + size-cap bookkeeping onto them. ## Consequences - Every binding implements one add / remove / clear surface for request, response, and authentication - handlers rather than diverging, with .NET expressing it through `+=` / `-=` events. + handlers rather than diverging. - 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. - Authentication handlers gain a callable form in addition to static credentials, so credentials can From f32b5866bbe344f524a9170c1fd70eaf95e1ee94 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Mon, 13 Jul 2026 16:42:59 -0500 Subject: [PATCH 06/20] [docs] align ADR 17685 URL-pattern names with existing UrlPattern class --- docs/decisions/17685-network-handler-behavior.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index ddf7f7a6a42ab..e05dd91763bd3 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -55,19 +55,19 @@ network.clearRequestHandlers(); ``` 2. **A handler is scoped by an optional list of URL patterns given as structured objects.** Each - pattern is an object of URL components — protocol, host, port, path, query — each optional: a - component that is set matches exactly, one left unset matches any value. A handler matches a - request against any pattern in its list; with no list it matches every request. A pattern is an - object, not a URL or glob string. + pattern is an object of URL components — protocol, hostname, port, pathname, search — each + optional: a component that is set matches exactly, one left unset matches any value. A handler + matches a request against any pattern in its list; with no list it matches every request. A pattern + is an object, not a URL or glob string. ```ruby # One or more component objects; a request matches any of them -network.add_request_handler(url_patterns: [{host: "api.example.com"}, {host: "cdn.example.com"}]) { |r| r.fail } +network.add_request_handler(url_patterns: [{hostname: "api.example.com"}, {hostname: "cdn.example.com"}]) { |r| r.fail } ``` ```java network.addRequestHandler( - List.of(UrlPattern.host("api.example.com"), UrlPattern.host("cdn.example.com")), + List.of(new UrlPattern().hostname("api.example.com"), new UrlPattern().hostname("cdn.example.com")), r -> r.fail()); ``` @@ -78,12 +78,12 @@ network.addRequestHandler( ```ruby # Credentials computed in the callable, or supplied statically network.add_authentication_handler { |e| e.authenticate(vault.credentials_for(e.url)) } -network.add_authentication_handler(username: "user", password: "pass", url_patterns: [{host: "secure.example.com"}]) +network.add_authentication_handler(username: "user", password: "pass", url_patterns: [{hostname: "secure.example.com"}]) ``` ```java network.addAuthenticationHandler(e -> e.authenticate(vault.credentialsFor(e.url()))); -network.addAuthenticationHandler("user", "pass", List.of(UrlPattern.host("secure.example.com"))); +network.addAuthenticationHandler("user", "pass", List.of(new UrlPattern().hostname("secure.example.com"))); ``` 4. **A request or response handler observes or intercepts, and the event object enforces which.** From 98053aeb65334288e9b06106d8b2c5bf195ef116 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 23 Jul 2026 11:05:14 -0500 Subject: [PATCH 07/20] [docs] settle url pattern inputs and split auth credentials in ADR 17685 --- .../17685-network-handler-behavior.md | 172 ++++++++++++------ 1 file changed, 121 insertions(+), 51 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index e05dd91763bd3..a6c1fc1064712 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -37,10 +37,16 @@ dispose of the event as it runs — it does not gather every handler's outcome a end. There are multiple ways to implement the decisions below; the examples are illustrative user-facing code in Ruby and Java. -1. **Handlers are added, removed, and cleared.** Each family — request, response, authentication — - has an add, a remove, and a clear. `add` returns a handle object; `remove` takes that handle and - unregisters exactly that handler; `clear` removes every handler in the family. Removing a handler - stops it being consulted for later events but does not disturb an event already in flight. +1. **Handlers are added, removed, and cleared.** Each family — request, response, + authentication — has an add, a remove, and a clear. `add` returns a handle + object; `remove` takes that handle and unregisters exactly that handler; + `clear` removes every handler in the family. Removing a handler stops it + being consulted for later events but does not disturb an event already in + flight. + + Additionally, a convenience method named `addAuthentication` wraps + `addAuthenticationHandler`, taking credentials without a callable for the + primary use case. ```ruby handle = network.add_request_handler { |r| r.fail if blocked?(r.url) } @@ -49,46 +55,83 @@ network.clear_request_handlers ``` ```java -RequestHandler handle = network.addRequestHandler(r -> { if (blocked(r.url())) r.fail(); }); +RequestHandler handle = network.addRequestHandler( + r -> { if (blocked(r.url())) r.fail(); }); network.removeRequestHandler(handle); network.clearRequestHandlers(); ``` -2. **A handler is scoped by an optional list of URL patterns given as structured objects.** Each - pattern is an object of URL components — protocol, hostname, port, pathname, search — each - optional: a component that is set matches exactly, one left unset matches any value. A handler - matches a request against any pattern in its list; with no list it matches every request. A pattern - is an object, not a URL or glob string. +2. **URL filtering is declared when a handler is registered.** By default a + handler matches every event; patterns narrow it. What they cannot express, + the user may filter in the callable. + + The argument name is the equivalent of `urlPatterns`. Its values must + support, in a language idiomatic way, one or more strings and/or objects, + where the object types are limited to what the BiDi spec directly supports + and each component takes an optional string value. A binding may also take + its language's native URL object, passing it on as a pattern string rather + than deconstructing it to an object. Predicates are not accepted; a user who + wants one may write it inside the callable. + + Everything specified by an url pattern argument must be resolvable by the + remote end; no additional filtering is done client-side. Everything else + errors, including unsupported wildcard matching — the protocol matches + literally, so a glob is not refused by the remote, it quietly matches + nothing. ```ruby -# One or more component objects; a request matches any of them -network.add_request_handler(url_patterns: [{hostname: "api.example.com"}, {hostname: "cdn.example.com"}]) { |r| r.fail } +# A pattern string or components — an event matches any of them +network.add_request_handler( + url_patterns: ["https://api.example.com/orders", + {hostname: "cdn.example.com"}] +) { |r| r.fail } + +# Wildcards are rejected; the matching goes in the callable instead +network.add_request_handler(url_patterns: ["https://*.example.com/"]) # raises +network.add_request_handler(url_patterns: [{hostname: "api.example.com"}]) do |r| + r.fail if r.url.end_with?(".json") +end ``` ```java network.addRequestHandler( - List.of(new UrlPattern().hostname("api.example.com"), new UrlPattern().hostname("cdn.example.com")), + List.of(UrlPattern.of("https://api.example.com/orders"), + UrlPattern.builder().hostname("cdn.example.com").build()), r -> r.fail()); + +network.addRequestHandler( + UrlPattern.builder().hostname("api.example.com").build(), + r -> { if (r.url().endsWith(".json")) r.fail(); }); ``` -3. **A handler is a callable that acts on the event object.** A request or response handler may - observe it, change it, or settle its disposition (decisions 4–6); an authentication handler - supplies credentials for it, computed in the callable or given as a static username and password. +3. **A handler is a callable that acts on the event object.** A request or + response handler may observe it, change it, or settle its disposition + (decisions 4–6); an authentication handler settles a challenge by supplying + credentials or cancelling. ```ruby -# Credentials computed in the callable, or supplied statically -network.add_authentication_handler { |e| e.authenticate(vault.credentials_for(e.url)) } -network.add_authentication_handler(username: "user", password: "pass", url_patterns: [{hostname: "secure.example.com"}]) +network.add_authentication_handler do |e| + (c = vault.credentials_for(e.url)) ? e.authenticate(c) : e.cancel +end + +network.add_authentication(username: "user", password: "pass", + url_patterns: [{hostname: "secure.example.com"}]) ``` ```java -network.addAuthenticationHandler(e -> e.authenticate(vault.credentialsFor(e.url()))); -network.addAuthenticationHandler("user", "pass", List.of(new UrlPattern().hostname("secure.example.com"))); +network.addAuthenticationHandler(e -> { + Credentials c = vault.credentialsFor(e.url()); + if (c != null) e.authenticate(c); else e.cancel(); +}); + +network.addAuthentication(UsernameAndPassword.of("user", "pass"), + List.of(UrlPattern.builder().hostname("secure.example.com").build())); ``` 4. **A request or response handler observes or intercepts, and the event object 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 + creation; intercepting is the default and observing is opt-in — a default that cannot change later + without breaking existing handlers. 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. @@ -114,27 +157,31 @@ network.addRequestHandler(new ObservationOptions(), r -> log(r.url())); ``` 5. **When a handler settles a disposition, the first to do so resolves the event and stops the - chain.** The user settles the event by acting on the object the callable receives. - * 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. + chain.** The user settles the event by acting on the object the callable receives. A handler that + only stages mutations does not settle; it passes the event to the next handler (decision 6). + * A request has three: `fail` (BiDi's `FailRequest`) ends it with an error; + `respond` (`ProvideResponse`) replies with a mock, so nothing reaches the server; `submit` + (`ContinueRequest`) sends it on, with any staged mutations, and consults no further handler. + * `submit` is never required — a handler that settles nothing lets the event continue anyway + (decision 6) — and because it short-circuits the chain it can override what a shared handler + installed. That is occasionally necessary and easy to invoke by accident, so its name should read + as a deliberate, terminal override. + * A response has `fail` and `submit`. It has already round-tripped, so whether `submit` maps to + `ContinueResponse` or `ProvideResponse` follows from whether a replacement body was given. ```ruby -# Names and params can match spec detail; response verbs mirror request (fail, submit) -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.submit(content: mocked_response) if something } +# fail: error out; respond: mock, no round trip; submit: send (mutated) to the server and stop the chain +network.add_request_handler { |r| r.fail if blocked?(r.url) } +network.add_request_handler { |r| r.respond(content: mocked_response) if stubbed?(r.url) } # not sent to the server +network.add_request_handler { |r| r.add_header("X-Test", true); r.submit if override?(r.url) } # sent to the server, chain stops +network.add_response_handler { |r| r.submit(content: mocked_response) if rewrite?(r.url) } ``` ```java -network.addRequestHandler(r -> { if (something) r.fail(); }); -network.addRequestHandler(r -> { if (something) r.respond(mockedResponse); }); -network.addRequestHandler(r -> { if (something) { r.addHeader("X-Test", "true"); r.submit(); } }); -network.addResponseHandler(r -> { if (something) r.submit(mockedResponse); }); +network.addRequestHandler(r -> { if (blocked(r.url())) r.fail(); }); +network.addRequestHandler(r -> { if (stubbed(r.url())) r.respond(mockedResponse); }); // not sent to the server +network.addRequestHandler(r -> { if (override(r.url())) { r.addHeader("X-Test", "true"); r.submit(); } }); // sent, chain stops +network.addResponseHandler(r -> { if (rewrite(r.url())) r.submit(mockedResponse); }); ``` 6. **Default disposition is to process other handlers.** If a handler does not specify the @@ -256,20 +303,35 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); - A bare numeric id as the handle — an object is type-safe and cannot be confused with an unrelated id. - **Filtering (decision 2).** - - No filter, matching only in the callback — no declarative scope a binding could hand to the - remote, so interception cannot be narrowed to the URLs of interest. - - A client-side predicate — adds nothing over a conditional in the callback and can never be handed - to the remote. - - A URL or glob string — the protocol matches each component by exact equality (an unset component - matches any), so a string reads as though `*` wildcards work when they do not, and its parsing is - ambiguous about which component a wildcard belongs to. - - The structured object is the shape the standard pattern syntax itself uses, so when the protocol - adopts the wildcard characters it reserves today, the same fields carry them — no structural - change, and existing exact patterns keep working. + - No patterns, matching only in the callback — nothing to hand the remote, so every event must be + intercepted to answer any question about it. + - A predicate, as one binding ships today — cannot cross the wire, so it has the same cost, and adds + nothing over a conditional in the callback. + - Reject URL strings and require the object form — the spec accepts a pattern string itself, so + refusing one buys no safety. + - Take a native URL apart into components, erroring on what no component represents — URLs are + complicated enough that parsing them is work we would own and get wrong; passing one on as a + pattern string leaves that to the spec. + - Accept globs, matching them client-side until the spec catches up — widely understood, and another + framework ships exactly this. But it means intercepting every event, and no two glob dialects agree + with each other or with the URL pattern syntax the spec is adopting: `/orders/*` is valid in both + and matches one segment or any depth. The same string would quietly mean different things. Users + can do this in the callable, and if enough do, we can revisit with evidence. + - Let each binding choose which forms it accepts — five capability sets, so what a user can express + would depend on their language rather than the spec. - **Authentication as a callable (decision 3).** - Exclude auth from the callable model and expose only static credentials (an earlier draft) — a - callable can compute credentials per challenge and reuses the one registration surface; the static - form is kept as sugar, not the whole surface. + callable can compute credentials per challenge, and only a callable can cancel one. + - Overload the handler method so it takes either a callable or a username and password — one method + per family is tidier, but the two forms do not do the same thing (static credentials can only ever + supply, never cancel), and `add_*_handler` would promise a handler the user never wrote. + - Give the credentials method its own registry, separate from the handlers — then `remove` and + `clear` would silently miss it, and the two would not have a defined order relative to each other. + - Ship only the callable form and add the credentials method later if it is asked for — the callable + can express everything the credentials method can, so the second method is convenience rather than + capability. It is included because supplying a username and password is the overwhelmingly common + case, and a signature a user can read without understanding callbacks serves them better than the + one general form. - **Modes (decision 4).** - Give observation its own method — it shares the whole registration shape, and the read-only contract can only be carried by the event object's type (we will not introspect the callable), @@ -284,6 +346,14 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); - **Verb names (decision 5).** - Playwright's (abort / fulfill / continue / fallback) or BiDi's (failRequest / provideResponse / continueRequest / continueResponse) — either can be matched to spec detail per binding. + - Name the pass-through `continue` — reads ambiguously as "continue this request" versus "continue to + the next handler"; `submit` names the intent of sending this request now. + - Omit a pass-through disposition entirely and only continue after gathering every handler's + mutations at the end — safest against an accidental short-circuit, but leaves no way for one handler + to override a default a shared handler set, so it is kept as a deliberately named override instead. + - `submit` may not be the best name for that override, and a better one is worth settling before + this ships: `finish`, `complete`, `send`, or a form each binding marks as terminal in its own way, + such as a Ruby `submit!`. - **Ordering (decision 7).** - Registration order instead of LIFO — prevents overriding global settings locally. - **Failure (decision 8).** @@ -309,6 +379,6 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); - 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. - Authentication handlers gain a callable form in addition to static credentials, so credentials can - be produced per challenge. + be produced — or the challenge cancelled — per challenge. - This changes handler behavior that several bindings already ship, so it is not backwards compatible. From 0f9f79c3bb7818abaea62e0acc7ce4604d3c3e9b Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 23 Jul 2026 12:43:30 -0500 Subject: [PATCH 08/20] [docs] clarify auth handle removal and qualify body readability in ADR 17685 --- docs/decisions/17685-network-handler-behavior.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index a6c1fc1064712..045ff920a2f93 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -46,7 +46,8 @@ user-facing code in Ruby and Java. Additionally, a convenience method named `addAuthentication` wraps `addAuthenticationHandler`, taking credentials without a callable for the - primary use case. + primary use case. It returns the same handle as the rest of the family and is + removed and cleared the same way. ```ruby handle = network.add_request_handler { |r| r.fail if blocked?(r.url) } @@ -277,7 +278,7 @@ network.addRequestHandler(r -> r.addHeader("X-Test", "true")); 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. + inside that handler, where applicable. * 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 the `add_request_handler` / `add_response_handler` registration. From 887ad3c149fc638769ae3108c3db2d4bc4b9534d Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 23 Jul 2026 21:04:24 -0500 Subject: [PATCH 09/20] [docs] separate observation from interception in ADR 17685 --- .../17685-network-handler-behavior.md | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 045ff920a2f93..46d15931dd1be 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -31,8 +31,8 @@ nothing here exposes a protocol type. ## Decision -Request and response handlers observe or intercept and reconcile to one disposition; authentication -handlers supply credentials. Selenium consults intercept handlers one at a time and lets each +Request and response handlers observe the event, or intercept it and reconcile to one disposition; +authentication handlers supply credentials. Selenium consults intercept handlers one at a time and lets each dispose of the event as it runs — it does not gather every handler's outcome and reconcile at the end. There are multiple ways to implement the decisions below; the examples are illustrative user-facing code in Ruby and Java. @@ -62,9 +62,9 @@ network.removeRequestHandler(handle); network.clearRequestHandlers(); ``` -2. **URL filtering is declared when a handler is registered.** By default a - handler matches every event; patterns narrow it. What they cannot express, - the user may filter in the callable. +2. **URL filtering is declared when an intercepting handler is registered.** By + default it intercepts every event; patterns narrow it. What they cannot + express, the user may filter in the callable. The argument name is the equivalent of `urlPatterns`. Its values must support, in a language idiomatic way, one or more strings and/or objects, @@ -130,36 +130,30 @@ network.addAuthentication(UsernameAndPassword.of("user", "pass"), ``` 4. **A request or response handler observes or intercepts, and the event object 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 — a default that cannot change later - without breaking existing handlers. 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. + The mode is decided at registration; intercepting is the default and observing is opt-in. An + observing handler receives a read-only event object, takes no part in the disposition chain + (decisions 5–7), and takes no patterns. An intercepting handler receives a mutable + event object: it can stage changes and settle the event, and traffic is paused until handling + resolves it. * 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. + is its own idiom; what is fixed is that it is the same method name, not an + `add_request_observer` alongside an `add_request_intercept`. ```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 +network.add_request_handler { |r| r.fail if something } # intercept: mutable, in the chain +network.add_request_handler(observe: true) { |r| log(r.url) } # observe: read-only, outside the chain ``` ```java -network.addRequestHandler(r -> { if (something) r.fail(); }); // intercept: mutable, blocking -network.addRequestHandler(new ObservationOptions(), r -> log(r.url())); // observe: read-only, non-blocking - -// The observe callback's event type has no fail()/mutate methods — this would not compile +network.addRequestHandler(r -> { if (something) r.fail(); }); // intercept: mutable, in the chain +network.addRequestHandler(new ObservationOptions(), r -> log(r.url())); // observe: read-only, outside the chain ``` -5. **When a handler settles a disposition, the first to do so resolves the event and stops the - chain.** The user settles the event by acting on the object the callable receives. A handler that - only stages mutations does not settle; it passes the event to the next handler (decision 6). +5. **When an intercepting handler settles a disposition, the first to do so resolves the event and + stops the chain.** The user settles the event by acting on the object the callable receives. A + handler that only stages mutations does not settle; it passes the event to the next handler + (decision 6). * A request has three: `fail` (BiDi's `FailRequest`) ends it with an error; `respond` (`ProvideResponse`) replies with a mock, so nothing reaches the server; `submit` (`ContinueRequest`) sends it on, with any staged mutations, and consults no further handler. @@ -185,9 +179,9 @@ network.addRequestHandler(r -> { if (override(r.url())) { r.addHeader("X-Test", network.addResponseHandler(r -> { if (rewrite(r.url())) r.submit(mockedResponse); }); ``` -6. **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. +6. **Default disposition is to process other intercepting 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. @@ -203,6 +197,8 @@ network.addRequestHandler(r -> r.addHeader("X-Test", "true")); 7. **Later-registered handlers are consulted first.** This applies to every family — request, response, and authentication. Registering an additional handler can mutate the state used by previously registered ones. + * Ordering is a property of the chain, so it covers intercepting handlers; observing handlers + have no defined order, including relative to one another. * 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 @@ -278,7 +274,7 @@ network.addRequestHandler(r -> r.addHeader("X-Test", "true")); 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, where applicable. + inside that handler, in either mode — the collector holds it independently of the chain. * 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 the `add_request_handler` / `add_response_handler` registration. @@ -320,6 +316,8 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); can do this in the callable, and if enough do, we can revisit with evidence. - Let each binding choose which forms it accepts — five capability sets, so what a user can express would depend on their language rather than the spec. + - Give observing handlers patterns too, matched client-side — the same argument would be enforced + in two different places, and a conditional in the callable already does it. - **Authentication as a callable (decision 3).** - Exclude auth from the callable model and expose only static credentials (an earlier draft) — a callable can compute credentials per challenge, and only a callable can cancel one. @@ -334,9 +332,11 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); case, and a signature a user can read without understanding callbacks serves them better than the one general form. - **Modes (decision 4).** - - Give observation its own method — it shares the whole registration shape, and the read-only - contract can only be carried by the event object's type (we will not introspect the callable), - not the method. + - Give observation its own method — the modes read one event stream and share the whole + registration shape, so a second name duplicates add, remove, and clear for both request and + response to carry what is one flag. + - Hand an observing handler the resolved event and its final disposition — it would have to be + sequenced behind the chain, and the response phase already reports the request as actually sent. - Make everything interception and add observation later — routing observation through interception pauses traffic and perturbs what it records (cache, timing). - **Reconciliation (decisions 5 & 6).** @@ -381,5 +381,7 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); handler stays contained, and the original event remains readable. - Authentication handlers gain a callable form in addition to static credentials, so credentials can be produced — or the challenge cancelled — per challenge. +- Observing does not make traffic non-blocking: both modes read the same event, so it is paused + whenever an intercepting handler's patterns match it, whatever else is observing. - This changes handler behavior that several bindings already ship, so it is not backwards compatible. From 7fd4917097474e9fd2d5195a34e79c9b01886ec5 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 23 Jul 2026 21:38:35 -0500 Subject: [PATCH 10/20] [docs] make patterns on an observing handler an error in ADR 17685 --- docs/decisions/17685-network-handler-behavior.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 46d15931dd1be..d180ee36ac844 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -132,7 +132,8 @@ network.addAuthentication(UsernameAndPassword.of("user", "pass"), 4. **A request or response handler observes or intercepts, and the event object enforces which.** The mode is decided at registration; intercepting is the default and observing is opt-in. An observing handler receives a read-only event object, takes no part in the disposition chain - (decisions 5–7), and takes no patterns. An intercepting handler receives a mutable + (decisions 5–7), and takes no patterns — supplying them is an error. An intercepting + handler receives a mutable event object: it can stage changes and settle the event, and traffic is paused until handling resolves it. * How a binding lets the user pick the mode — a keyword argument, an options object, an overload — From 1d78efeea2f86684998b1653680e57a835152e19 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 24 Jul 2026 11:22:28 -0500 Subject: [PATCH 11/20] [docs] scope ADR 17685 to interception, deferring observation --- .../17685-network-handler-behavior.md | 135 ++++++------------ 1 file changed, 45 insertions(+), 90 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index d180ee36ac844..423cfee6c1337 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -31,18 +31,16 @@ nothing here exposes a protocol type. ## Decision -Request and response handlers observe the event, or intercept it and reconcile to one disposition; -authentication handlers supply credentials. Selenium consults intercept handlers one at a time and lets each -dispose of the event as it runs — it does not gather every handler's outcome and reconcile at the -end. There are multiple ways to implement the decisions below; the examples are illustrative -user-facing code in Ruby and Java. - -1. **Handlers are added, removed, and cleared.** Each family — request, response, - authentication — has an add, a remove, and a clear. `add` returns a handle +By default, a handler blocks the event until it has run. The decisions below can be implemented in +more than one way; the Ruby and Java examples show the user-facing shape, not a prescribed API. + +1. **Handlers can be added, removed, and cleared.** Each family — request, + response, and authentication — has an add, a remove, and a clear: + `addRequestHandler`, `removeRequestHandler`, and `clearRequestHandlers`, with + the equivalents for response and authentication. `add` returns a handle object; `remove` takes that handle and unregisters exactly that handler; - `clear` removes every handler in the family. Removing a handler stops it - being consulted for later events but does not disturb an event already in - flight. + `clear` removes every handler in the family. Removing a handler stops it being + consulted for later events but does not disturb an event already in flight. Additionally, a convenience method named `addAuthentication` wraps `addAuthenticationHandler`, taking credentials without a callable for the @@ -62,9 +60,9 @@ network.removeRequestHandler(handle); network.clearRequestHandlers(); ``` -2. **URL filtering is declared when an intercepting handler is registered.** By - default it intercepts every event; patterns narrow it. What they cannot - express, the user may filter in the callable. +2. **URL filtering is declared when a handler is registered.** By default a + handler matches every event; patterns narrow it. What they cannot express, + the user may filter in the callable. The argument name is the equivalent of `urlPatterns`. Its values must support, in a language idiomatic way, one or more strings and/or objects, @@ -106,8 +104,8 @@ network.addRequestHandler( ``` 3. **A handler is a callable that acts on the event object.** A request or - response handler may observe it, change it, or settle its disposition - (decisions 4–6); an authentication handler settles a challenge by supplying + response handler may read it, change it, or settle its disposition + (decisions 4–5); an authentication handler settles a challenge by supplying credentials or cancelling. ```ruby @@ -129,37 +127,15 @@ network.addAuthentication(UsernameAndPassword.of("user", "pass"), List.of(UrlPattern.builder().hostname("secure.example.com").build())); ``` -4. **A request or response handler observes or intercepts, and the event object enforces which.** - The mode is decided at registration; intercepting is the default and observing is opt-in. An - observing handler receives a read-only event object, takes no part in the disposition chain - (decisions 5–7), and takes no patterns — supplying them is an error. An intercepting - handler receives a mutable - event object: it can stage changes and settle the event, and traffic is paused until handling - resolves it. - * 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 name, not an - `add_request_observer` alongside an `add_request_intercept`. - -```ruby -# Same method, two modes; the event object handed to the block differs -network.add_request_handler { |r| r.fail if something } # intercept: mutable, in the chain -network.add_request_handler(observe: true) { |r| log(r.url) } # observe: read-only, outside the chain -``` - -```java -network.addRequestHandler(r -> { if (something) r.fail(); }); // intercept: mutable, in the chain -network.addRequestHandler(new ObservationOptions(), r -> log(r.url())); // observe: read-only, outside the chain -``` - -5. **When an intercepting handler settles a disposition, the first to do so resolves the event and +4. **When a handler settles a disposition, the first to do so resolves the event and stops the chain.** The user settles the event by acting on the object the callable receives. A handler that only stages mutations does not settle; it passes the event to the next handler - (decision 6). + (decision 5). * A request has three: `fail` (BiDi's `FailRequest`) ends it with an error; `respond` (`ProvideResponse`) replies with a mock, so nothing reaches the server; `submit` (`ContinueRequest`) sends it on, with any staged mutations, and consults no further handler. * `submit` is never required — a handler that settles nothing lets the event continue anyway - (decision 6) — and because it short-circuits the chain it can override what a shared handler + (decision 5) — and because it short-circuits the chain it can override what a shared handler installed. That is occasionally necessary and easy to invoke by accident, so its name should read as a deliberate, terminal override. * A response has `fail` and `submit`. It has already round-tripped, so whether `submit` maps to @@ -180,9 +156,9 @@ network.addRequestHandler(r -> { if (override(r.url())) { r.addHeader("X-Test", network.addResponseHandler(r -> { if (rewrite(r.url())) r.submit(mockedResponse); }); ``` -6. **Default disposition is to process other intercepting 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. +5. **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. @@ -195,11 +171,9 @@ network.add_request_handler { |r| r.add_header("X-Test", true) } network.addRequestHandler(r -> r.addHeader("X-Test", "true")); ``` -7. **Later-registered handlers are consulted first.** This applies to every family — request, +6. **Later-registered handlers are consulted first.** This applies to every family — request, response, and authentication. Registering an additional handler can mutate the state used by previously registered ones. - * Ordering is a property of the chain, so it covers intercepting handlers; observing handlers - have no defined order, including relative to one another. * 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 @@ -216,30 +190,23 @@ network.addRequestHandler(r -> r.addHeader("X-Test", "true")); network.addRequestHandler(r -> r.removeHeader("X-Test")); ``` -8. **An uncaught exception discards the handler's staged changes; it surfaces to the user 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. +7. **An uncaught exception discards the handler's staged changes and surfaces to the user.** The + event keeps flowing as if that handler had not run, so one broken handler cannot corrupt live + traffic or stall the page; but the failure is not swallowed — the handler expresses the test's + intent, so a failure in it reaches the user rather than passing silently. ```ruby -# Intercept: the error surfaces; the header addition from the other handler still applies +# 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 } ``` ```java network.addRequestHandler(r -> r.addHeader("X-Test", "true")); -network.addRequestHandler(r -> { throw new RuntimeException(); }); // intercept: surfaces to the user - -network.addRequestHandler(new ObservationOptions(), r -> { throw new RuntimeException(); }); // observe: logged, test unaffected +network.addRequestHandler(r -> { throw new RuntimeException(); }); // surfaces to the user ``` -9. **Return values within the callables are ignored.** No meaning will ever be applied to anything a +8. **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. @@ -253,10 +220,10 @@ network.add_request_handler { |r| r.add_header("X-Test", true); "this value is i network.addRequestHandler(r -> r.addHeader("X-Test", "true")); ``` -10. **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. - * Even when intercepting and mutating, a conditional can be evaluated against the original value - rather than the version a prior handler changed. +9. **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. + * Even when intercepting and mutating, a conditional can be evaluated against the original value + rather than the version a prior handler changed. ```ruby # Nothing gets raised @@ -271,14 +238,14 @@ network.addRequestHandler(r -> { if (r.request().headers().containsKey("X-Test") network.addRequestHandler(r -> r.addHeader("X-Test", "true")); ``` -11. **Body data is collected only when the handler opts in at registration.** A body is not available +10. **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, in either mode — the collector holds it independently of the chain. + 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 - the `add_request_handler` / `add_response_handler` registration. + the `addRequestHandler` / `addResponseHandler` registration. ```ruby # Declare body collection at registration; the body is then available on the event @@ -295,7 +262,7 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); - Separate top-level driver methods or a handler-collection object — the boundaries decision fixes `driver.network` as the neutral accessor, and one shape keeps the families consistent. - `add` only, no `remove` / `clear` — a handler installed by a shared suite could not be retracted - for one test, which the LIFO override (decision 7) relies on. + for one test, which the LIFO override (decision 6) relies on. - Remove by passing the original callable rather than a returned handle — an inline block has no stable identity to pass back. - A bare numeric id as the handle — an object is type-safe and cannot be confused with an unrelated @@ -317,8 +284,6 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); can do this in the callable, and if enough do, we can revisit with evidence. - Let each binding choose which forms it accepts — five capability sets, so what a user can express would depend on their language rather than the spec. - - Give observing handlers patterns too, matched client-side — the same argument would be enforced - in two different places, and a conditional in the callable already does it. - **Authentication as a callable (decision 3).** - Exclude auth from the callable model and expose only static credentials (an earlier draft) — a callable can compute credentials per challenge, and only a callable can cancel one. @@ -332,20 +297,12 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); capability. It is included because supplying a username and password is the overwhelmingly common case, and a signature a user can read without understanding callbacks serves them better than the one general form. -- **Modes (decision 4).** - - Give observation its own method — the modes read one event stream and share the whole - registration shape, so a second name duplicates add, remove, and clear for both request and - response to carry what is one flag. - - Hand an observing handler the resolved event and its final disposition — it would have to be - sequenced behind the chain, and the response phase already reports the request as actually sent. - - Make everything interception and add observation later — routing observation through interception - pauses traffic and perturbs what it records (cache, timing). -- **Reconciliation (decisions 5 & 6).** +- **Reconciliation (decisions 4 & 5).** - Run every handler and reconcile by fixed priority (fail > stub > continue) — takes disposition away from the individual handler. - Let `continueRequest` override failures and stubs (current Python) — no obvious reason that command should win. -- **Verb names (decision 5).** +- **Verb names (decision 4).** - Playwright's (abort / fulfill / continue / fallback) or BiDi's (failRequest / provideResponse / continueRequest / continueResponse) — either can be matched to spec detail per binding. - Name the pass-through `continue` — reads ambiguously as "continue this request" versus "continue to @@ -356,20 +313,20 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); - `submit` may not be the best name for that override, and a better one is worth settling before this ships: `finish`, `complete`, `send`, or a form each binding marks as terminal in its own way, such as a Ruby `submit!`. -- **Ordering (decision 7).** +- **Ordering (decision 6).** - Registration order instead of LIFO — prevents overriding global settings locally. -- **Failure (decision 8).** +- **Failure (decision 7).** - End the session on any uncaught exception (Playwright) — burdens users with unrelated network errors, worse when intercepting by default. - - Log every exception regardless of mode — an intercept handler's failure is the test's own bug and - should surface. -- **Return values (decision 9).** + - Log the exception rather than surface it — the failure is the test's own bug, so it should reach + the user rather than pass silently. +- **Return values (decision 8).** - Let a return value set event or handler state instead of acting on the wrapper — not straightforward across all languages. -- **Original access (decision 10).** +- **Original access (decision 9).** - Expose only the modified or only the original event — a conditional may need the original even while mutating. -- **Data collection (decision 11).** +- **Data collection (decision 10).** - Always collect bodies — bodies are large and most handlers never read them. - Make the user manage the collector — it has no meaning outside a handler and pushes lifecycle and size-cap bookkeeping onto them. @@ -382,7 +339,5 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); handler stays contained, and the original event remains readable. - Authentication handlers gain a callable form in addition to static credentials, so credentials can be produced — or the challenge cancelled — per challenge. -- Observing does not make traffic non-blocking: both modes read the same event, so it is paused - whenever an intercepting handler's patterns match it, whatever else is observing. - This changes handler behavior that several bindings already ship, so it is not backwards compatible. From 0e068c90f8892938c0a32cfd80adb89fdc919f6f Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 24 Jul 2026 12:10:14 -0500 Subject: [PATCH 12/20] [docs] refine ADR 17685: error on double disposition, leave wire outcome unspecified on handler throw --- .../17685-network-handler-behavior.md | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 423cfee6c1337..3524308215e67 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -140,6 +140,8 @@ network.addAuthentication(UsernameAndPassword.of("user", "pass"), as a deliberate, terminal override. * A response has `fail` and `submit`. It has already round-tripped, so whether `submit` maps to `ContinueResponse` or `ProvideResponse` follows from whether a replacement body was given. + * Within one handler, settling more than once is an error — after it settles, a further + disposition call raises rather than overriding the first. ```ruby # fail: error out; respond: mock, no round trip; submit: send (mutated) to the server and stop the chain @@ -190,20 +192,20 @@ network.addRequestHandler(r -> r.addHeader("X-Test", "true")); network.addRequestHandler(r -> r.removeHeader("X-Test")); ``` -7. **An uncaught exception discards the handler's staged changes and surfaces to the user.** The - event keeps flowing as if that handler had not run, so one broken handler cannot corrupt live - traffic or stall the page; but the failure is not swallowed — the handler expresses the test's - intent, so a failure in it reaches the user rather than passing silently. +7. **An uncaught exception is raised to the user, not logged.** The handler callable is responsible + for its own error handling; an exception it does not catch surfaces rather than being swallowed. + The event still continues (decision 5) so the browser is not left waiting; what a throwing handler + contributed to it before the exception is unspecified. ```ruby -# The error surfaces; the header addition from the other handler still applies +# The exception is raised; 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 } ``` ```java network.addRequestHandler(r -> r.addHeader("X-Test", "true")); -network.addRequestHandler(r -> { throw new RuntimeException(); }); // surfaces to the user +network.addRequestHandler(r -> { throw new RuntimeException(); }); // raised to the user ``` 8. **Return values within the callables are ignored.** No meaning will ever be applied to anything a @@ -316,10 +318,16 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); - **Ordering (decision 6).** - Registration order instead of LIFO — prevents overriding global settings locally. - **Failure (decision 7).** - - End the session on any uncaught exception (Playwright) — burdens users with unrelated network - errors, worse when intercepting by default. - - Log the exception rather than surface it — the failure is the test's own bug, so it should reach - the user rather than pass silently. + - Log the exception instead of raising it — but an uncaught exception is the handler's own bug, so + it should error, not disappear into a log. + - End the whole session on any uncaught exception, as an unhandled rejection effectively does in + Playwright — disproportionate to one handler's bug: it closes the browser and drops all other + handlers, whereas decision 7 surfaces the error and leaves the session running. + - Shape the wire outcome on a throw — snapshot each handler so only prior handlers' changes survive, + or revert to the browser's original request — but the event resolves before the exception + surfaces, so the outcome is moot either way. + - Abort or mock-respond on any handler error — deterministic, but turns a handler bug into a failed + or empty request instead of letting it proceed. - **Return values (decision 8).** - Let a return value set event or handler state instead of acting on the wrapper — not straightforward across all languages. From c769c6f9fffabc2f87ea8d9936b1f7b5ca194773 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Mon, 10 Aug 2026 07:44:25 -0700 Subject: [PATCH 13/20] [docs] align ADR 17685 with 08-06 TLC: intercept-by-default, URL pattern passthrough, context scoping, request-only body collection --- .../17685-network-handler-behavior.md | 75 +++++++++++++------ 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 3524308215e67..acde07a8e0829 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -31,8 +31,9 @@ nothing here exposes a protocol type. ## Decision -By default, a handler blocks the event until it has run. The decisions below can be implemented in -more than one way; the Ruby and Java examples show the user-facing shape, not a prescribed API. +By default, a handler intercepts the event, blocking it until the handler has run, wherever blocking +interception is available at that stage. The decisions below can be implemented in more than one way; +the Ruby and Java examples show the user-facing shape, not a prescribed API. 1. **Handlers can be added, removed, and cleared.** Each family — request, response, and authentication — has an add, a remove, and a clear: @@ -72,11 +73,12 @@ network.clearRequestHandlers(); than deconstructing it to an object. Predicates are not accepted; a user who wants one may write it inside the callable. - Everything specified by an url pattern argument must be resolvable by the - remote end; no additional filtering is done client-side. Everything else - errors, including unsupported wildcard matching — the protocol matches - literally, so a glob is not refused by the remote, it quietly matches - nothing. + Everything specified by a url pattern argument must be resolvable by the + remote end; no filtering is done client-side. Patterns are passed to the + remote as given, and input that is not a valid pattern errors. A binding may + log a warning when a value looks like a glob, to flag that Selenium passes it + through rather than expanding it; that detection is optional and left to the + binding rather than specified here. ```ruby # A pattern string or components — an event matches any of them @@ -85,8 +87,9 @@ network.add_request_handler( {hostname: "cdn.example.com"}] ) { |r| r.fail } -# Wildcards are rejected; the matching goes in the callable instead -network.add_request_handler(url_patterns: ["https://*.example.com/"]) # raises +# A glob-looking pattern is passed to the remote as-is +network.add_request_handler(url_patterns: ["https://*.example.com/"]) +# Finer matching goes in the callable instead network.add_request_handler(url_patterns: [{hostname: "api.example.com"}]) do |r| r.fail if r.url.end_with?(".json") end @@ -240,22 +243,38 @@ network.addRequestHandler(r -> { if (r.request().headers().containsKey("X-Test") network.addRequestHandler(r -> r.addHeader("X-Test", "true")); ``` -10. **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 +10. **Body data is collected only when a request 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 - the `addRequestHandler` / `addResponseHandler` registration. + the `addRequestHandler` registration. + * Only request bodies are collected. Intercepting a response holds it in a blocked state before its + body is collected, so a response body is not available while intercepting. ```ruby # Declare body collection at registration; the body is then available on the event -network.add_response_handler(collect_body: true) { |r| log(r.body) } +network.add_request_handler(collect_body: true) { |r| log(r.body) } ``` ```java -network.addResponseHandler(new BodyCollection(), r -> log(r.body())); +network.addRequestHandler(new BodyCollection(), r -> log(r.body())); +``` + +11. **Handlers are scoped to the current browsing context by default.** A handler applies to the + browsing context that is active when it is registered, resolved from the current window; the user + may pass a browsing context, a user context, or both to scope it elsewhere. This lets a handler + apply to a context that is not the active one, such as a background tab that does not currently + have focus. + +```ruby +network.add_request_handler(context: other_tab) { |r| r.fail if blocked?(r.url) } +``` + +```java +network.addRequestHandler(otherTab, r -> { if (blocked(r.url())) r.fail(); }); ``` ## Considered options @@ -279,11 +298,11 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); - Take a native URL apart into components, erroring on what no component represents — URLs are complicated enough that parsing them is work we would own and get wrong; passing one on as a pattern string leaves that to the spec. - - Accept globs, matching them client-side until the spec catches up — widely understood, and another - framework ships exactly this. But it means intercepting every event, and no two glob dialects agree - with each other or with the URL pattern syntax the spec is adopting: `/orders/*` is valid in both - and matches one segment or any depth. The same string would quietly mean different things. Users - can do this in the callable, and if enough do, we can revisit with evidence. + - Detect glob-looking patterns and reject them, translate them, or match them client-side. All three + make Selenium own matching logic that belongs on the remote, and glob dialects are ambiguous + (`/orders/*` matches one segment or any depth depending on the dialect), so doing it ourselves would + make the same string quietly mean different things. Input is passed through as given, and users can + express anything finer in the callable. - Let each binding choose which forms it accepts — five capability sets, so what a user can express would depend on their language rather than the spec. - **Authentication as a callable (decision 3).** @@ -312,9 +331,8 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); - Omit a pass-through disposition entirely and only continue after gathering every handler's mutations at the end — safest against an accidental short-circuit, but leaves no way for one handler to override a default a shared handler set, so it is kept as a deliberately named override instead. - - `submit` may not be the best name for that override, and a better one is worth settling before - this ships: `finish`, `complete`, `send`, or a form each binding marks as terminal in its own way, - such as a Ruby `submit!`. + - Name the override `finish`, `complete`, or `send` instead of `submit`. `submit` was chosen as the + clearest terminal "send exactly this now" verb; the alternatives were considered and set aside. - **Ordering (decision 6).** - Registration order instead of LIFO — prevents overriding global settings locally. - **Failure (decision 7).** @@ -338,6 +356,13 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); - Always collect bodies — bodies are large and most handlers never read them. - Make the user manage the collector — it has no meaning outside a handler and pushes lifecycle and size-cap bookkeeping onto them. + - Collect response bodies too — intercepting a response holds it blocked before its body is + collected, so it is not available while intercepting. +- **Context scoping (decision 11).** + - No scoping, so every handler applies globally — cannot target a specific tab, a background context, + or a user context, which network work spanning several contexts needs. + - Scope only by browsing context — a user context is the natural unit for some interception, so both + are accepted. ## Consequences @@ -347,5 +372,7 @@ network.addResponseHandler(new BodyCollection(), r -> log(r.body())); handler stays contained, and the original event remains readable. - Authentication handlers gain a callable form in addition to static credentials, so credentials can be produced — or the challenge cancelled — per challenge. +- Handlers can be scoped to a specific browsing or user context, so interception can target a + background tab or an isolated context rather than only the active one. - This changes handler behavior that several bindings already ship, so it is not backwards compatible. From d87ed066b1cb528717bda383d287bbdb8d95f289 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 12 Aug 2026 09:01:38 -0700 Subject: [PATCH 14/20] [docs] ADR 17685: stop handler chain and submit staged mutations on uncaught exception --- .../17685-network-handler-behavior.md | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index acde07a8e0829..a88b8efe094ca 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -195,20 +195,24 @@ network.addRequestHandler(r -> r.addHeader("X-Test", "true")); network.addRequestHandler(r -> r.removeHeader("X-Test")); ``` -7. **An uncaught exception is raised to the user, not logged.** The handler callable is responsible - for its own error handling; an exception it does not catch surfaces rather than being swallowed. - The event still continues (decision 5) so the browser is not left waiting; what a throwing handler - contributed to it before the exception is unspecified. +7. **An uncaught exception surfaces to the user and stops the chain.** The handler callable is + responsible for its own error handling. An exception it does not catch is not swallowed or merely + logged: it surfaces to the user so it can be caught, and it does not on its own end the session. + When a handler raises, no further handlers run and the event is submitted with the mutations staged + by the handlers that completed before it, the same outcome the chain would reach on its own + (decision 5). A handler that raises contributes nothing; its own staged mutations are discarded, so + each handler applies all-or-nothing. ```ruby -# The exception is raised; 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 } +# LIFO: the raising handler runs first, so processing stops before the other handler runs. +# The request is submitted with what completed handlers staged; the exception surfaces to the user. +network.add_request_handler { |r| r.add_header("X-Test", true) } # never runs +network.add_request_handler { |r| raise Exception } # runs first, then raises ``` ```java -network.addRequestHandler(r -> r.addHeader("X-Test", "true")); -network.addRequestHandler(r -> { throw new RuntimeException(); }); // raised to the user +network.addRequestHandler(r -> r.addHeader("X-Test", "true")); // never runs +network.addRequestHandler(r -> { throw new RuntimeException(); }); // runs first, surfaces to the user ``` 8. **Return values within the callables are ignored.** No meaning will ever be applied to anything a @@ -339,11 +343,12 @@ network.addRequestHandler(otherTab, r -> { if (blocked(r.url())) r.fail(); }); - Log the exception instead of raising it — but an uncaught exception is the handler's own bug, so it should error, not disappear into a log. - End the whole session on any uncaught exception, as an unhandled rejection effectively does in - Playwright — disproportionate to one handler's bug: it closes the browser and drops all other - handlers, whereas decision 7 surfaces the error and leaves the session running. - - Shape the wire outcome on a throw — snapshot each handler so only prior handlers' changes survive, - or revert to the browser's original request — but the event resolves before the exception - surfaces, so the outcome is moot either way. + Playwright — disproportionate to one handler's bug: it closes the browser, whereas decision 7 + surfaces the error, stops only this event's chain, and leaves the session running. + - Keep running the remaining handlers after the throw, or discard what is staged and send the + browser's original request — the first runs a chain past a fault the user is already being told + about, the second throws away changes from handlers that completed cleanly; stopping and submitting + what completed handlers staged does neither. - Abort or mock-respond on any handler error — deterministic, but turns a handler bug into a failed or empty request instead of letting it proceed. - **Return values (decision 8).** @@ -370,6 +375,8 @@ network.addRequestHandler(otherTab, r -> { if (blocked(r.url())) r.fail(); }); handlers rather than diverging. - 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. +- A handler's mutations apply all-or-nothing, so each handler's changes are staged separately and + committed only when it returns cleanly rather than accumulated on one shared event object. - Authentication handlers gain a callable form in addition to static credentials, so credentials can be produced — or the challenge cancelled — per challenge. - Handlers can be scoped to a specific browsing or user context, so interception can target a From 7d08abd106b374aec06b632fd3c74b106a3f4ce9 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 13 Aug 2026 07:49:54 -0500 Subject: [PATCH 15/20] [docs] ADR 17685: clarify bindings may serialize URL patterns but not match or expand them --- docs/decisions/17685-network-handler-behavior.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index a88b8efe094ca..420bc4044012e 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -74,11 +74,12 @@ network.clearRequestHandlers(); wants one may write it inside the callable. Everything specified by a url pattern argument must be resolvable by the - remote end; no filtering is done client-side. Patterns are passed to the - remote as given, and input that is not a valid pattern errors. A binding may - log a warning when a value looks like a glob, to flag that Selenium passes it - through rather than expanding it; that detection is optional and left to the - binding rather than specified here. + remote end. A binding may serialize a supported input into the remote's + pattern form, but it does no URL matching or pattern expansion of its own; + patterns are forwarded to the remote for evaluation, and input that is not a + valid pattern errors. A binding may log a warning when a value looks like a + glob, to flag that Selenium forwards it rather than expanding it; that + detection is optional and left to the binding rather than specified here. ```ruby # A pattern string or components — an event matches any of them From aea88db70600de3e6b88cffe9425de82c3bba3dd Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 14 Aug 2026 15:57:56 -0500 Subject: [PATCH 16/20] [docs] ADR 17685: error locally on invalid URL pattern; note Playwright hangs on handler throw --- docs/decisions/17685-network-handler-behavior.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 420bc4044012e..c225809f535fe 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -77,9 +77,10 @@ network.clearRequestHandlers(); remote end. A binding may serialize a supported input into the remote's pattern form, but it does no URL matching or pattern expansion of its own; patterns are forwarded to the remote for evaluation, and input that is not a - valid pattern errors. A binding may log a warning when a value looks like a - glob, to flag that Selenium forwards it rather than expanding it; that - detection is optional and left to the binding rather than specified here. + valid pattern errors locally before anything is sent. A binding may log a + warning when a value looks like a glob, to flag that Selenium forwards it + rather than expanding it; that detection is optional and left to the binding + rather than specified here. ```ruby # A pattern string or components — an event matches any of them @@ -343,9 +344,12 @@ network.addRequestHandler(otherTab, r -> { if (blocked(r.url())) r.fail(); }); - **Failure (decision 7).** - Log the exception instead of raising it — but an uncaught exception is the handler's own bug, so it should error, not disappear into a log. - - End the whole session on any uncaught exception, as an unhandled rejection effectively does in - Playwright — disproportionate to one handler's bug: it closes the browser, whereas decision 7 - surfaces the error, stops only this event's chain, and leaves the session running. + - End the whole session on any uncaught exception — disproportionate to one handler's bug: it closes + the browser, whereas decision 7 surfaces the error, stops only this event's chain, and leaves the + session running. + - Leave the request unresolved on a throw, as Playwright does — a handler that raises without + settling leaves the request hanging until it times out. Decision 7 submits the staged state instead + so the browser is never left waiting. - Keep running the remaining handlers after the throw, or discard what is staged and send the browser's original request — the first runs a chain past a fault the user is already being told about, the second throws away changes from handlers that completed cleanly; stopping and submitting From 2d970338dddc7b56f64fa953a01bda3165013fd8 Mon Sep 17 00:00:00 2001 From: AutomatedTester Date: Mon, 17 Aug 2026 15:45:25 +0100 Subject: [PATCH 17/20] [docs] ADR 17685: scope handlers by top-level browsing context or user context, never both Decision 11 said a handler applies to "the current browsing context" and that the user may pass a browsing context, a user context, or both. None of that matches WebDriver BiDi: - network.addIntercept takes phases/contexts/urlPatterns only; it has no userContexts parameter (w3c/webdriver-bidi#845). Only session.subscribe accepts one. - contexts and userContexts are mutually exclusive; passing both is an invalid argument error. - addIntercept rejects a non-top-level navigable, and session.subscribe widens a child id to its top-level traversable, so frame-level network scoping does not exist. Scope is now stated as one top-level browsing context (the current window handle by default) or a user context, never both, with the user-context contract spelled out as covering tabs opened later. Consequences record that a binding resolves a user-context scope itself until #845 lands. The scope argument is named instead of the ambiguous `context:`, following 17776 for events and 17681 for handle objects. --- .../17685-network-handler-behavior.md | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index c225809f535fe..105425d8ce19e 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -269,18 +269,34 @@ network.add_request_handler(collect_body: true) { |r| log(r.body) } network.addRequestHandler(new BodyCollection(), r -> log(r.body())); ``` -11. **Handlers are scoped to the current browsing context by default.** A handler applies to the - browsing context that is active when it is registered, resolved from the current window; the user - may pass a browsing context, a user context, or both to scope it elsewhere. This lets a handler - apply to a context that is not the active one, such as a background tab that does not currently - have focus. +11. **Handlers are scoped to one top-level browsing context by default.** A handler applies to the + top-level browsing context the session is on when it is registered — the current window handle. + Being switched into a frame does not narrow that: a frame is not a scope the protocol can express, + since it rejects a child context outright when an intercept is registered and widens one to its + top-level context when an event is subscribed to. Narrowing to a frame belongs in the callable. + + To scope a handler elsewhere the user passes **either** a browsing context **or** a user context, + never both — the two are mutually exclusive in the protocol, which errors when given both. A + browsing context targets that one tab, including a background tab that does not have focus. A user + context targets every top-level browsing context in that isolation partition, including ones + opened after the handler is registered, which makes it the unit for interception that spans a + partition rather than a known tab. + + The value a binding takes for the browsing context is the same one that scopes script and log + events — a window handle ([#17776](https://github.com/SeleniumHQ/selenium/pull/17776)), or the + browsing-context handle object where a binding exposes one + ([#17681](https://github.com/SeleniumHQ/selenium/pull/17681)). This record does not add a third + spelling. ```ruby -network.add_request_handler(context: other_tab) { |r| r.fail if blocked?(r.url) } +# Either a browsing context or a user context, never both +network.add_request_handler(window_handle: other_tab) { |r| r.fail if blocked?(r.url) } +network.add_request_handler(user_context: isolated) { |r| r.fail if blocked?(r.url) } ``` ```java -network.addRequestHandler(otherTab, r -> { if (blocked(r.url())) r.fail(); }); +network.addRequestHandler(otherTab, r -> { if (blocked(r.url())) r.fail(); }); // one tab +network.addRequestHandler(isolated, r -> { if (blocked(r.url())) r.fail(); }); // whole partition ``` ## Considered options @@ -369,10 +385,16 @@ network.addRequestHandler(otherTab, r -> { if (blocked(r.url())) r.fail(); }); - Collect response bodies too — intercepting a response holds it blocked before its body is collected, so it is not available while intercepting. - **Context scoping (decision 11).** - - No scoping, so every handler applies globally — cannot target a specific tab, a background context, - or a user context, which network work spanning several contexts needs. - - Scope only by browsing context — a user context is the natural unit for some interception, so both - are accepted. + - No scoping, so every handler applies globally — cannot target a specific tab, a background tab, or + a user context, which network work spanning several contexts needs. + - Scope only by browsing context — a user context is the natural unit for interception that spans a + partition, and it also covers tabs opened later, so either unit is accepted. + - Accept a browsing context and a user context together and intersect them — the protocol rejects + the combination as `invalid argument`, so there is nothing to send; and a handler scoped to one tab + is already narrower than any partition that tab belongs to. + - Scope to a frame rather than a top-level browsing context — not expressible: an intercept rejects a + child context and a subscription widens one to its top-level context, so a frame-scoped handler + would quietly behave as a tab-scoped one. ## Consequences @@ -384,7 +406,13 @@ network.addRequestHandler(otherTab, r -> { if (blocked(r.url())) r.fail(); }); committed only when it returns cleanly rather than accumulated on one shared event object. - Authentication handlers gain a callable form in addition to static credentials, so credentials can be produced — or the challenge cancelled — per challenge. -- Handlers can be scoped to a specific browsing or user context, so interception can target a - background tab or an isolated context rather than only the active one. +- Handlers can be scoped to a single top-level browsing context or to a user context, so interception + can target a background tab or a whole isolated partition rather than only the current tab. Because + the two are mutually exclusive, a binding rejects being given both rather than forwarding them. +- The protocol has no user-context parameter for network interception + ([w3c/webdriver-bidi#845](https://github.com/w3c/webdriver-bidi/issues/845)) — only for event + subscription. Until that lands, a binding honoring a user-context scope resolves it to that + partition's top-level browsing contexts itself, and keeps doing so as contexts are created, so the + handler covers tabs opened later. - This changes handler behavior that several bindings already ship, so it is not backwards compatible. From 6dcc6691344088e261f926a50feb612f21edb75b Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 21 Aug 2026 09:40:24 -0500 Subject: [PATCH 18/20] [docs] ADR 17685: state handler scope as behavior, drop protocol/PR detail; scope filters the chain; link 17670 by record path --- .../17685-network-handler-behavior.md | 71 ++++++++----------- 1 file changed, 31 insertions(+), 40 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 105425d8ce19e..2baf9c9fd363b 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -26,7 +26,7 @@ ordering, multi-handler resolution, error handling, and what an event exposes ar | JavaScript | No request or response handler API. | Handlers are reached through `driver.network`, the supported protocol-neutral API established by the -BiDi implementation boundaries decision ([#17670](https://github.com/SeleniumHQ/selenium/pull/17670)); +BiDi implementation boundaries decision ([17670](17670-bidi-implementation-boundaries.md)); nothing here exposes a protocol type. ## Decision @@ -133,9 +133,11 @@ network.addAuthentication(UsernameAndPassword.of("user", "pass"), ``` 4. **When a handler settles a disposition, the first to do so resolves the event and - stops the chain.** The user settles the event by acting on the object the callable receives. A - handler that only stages mutations does not settle; it passes the event to the next handler - (decision 5). + stops the chain.** An event's chain is the registered handlers whose URL patterns (decision 2) and + scope (decision 11) match it; a handler outside the event's scope is not consulted, even when a + broader handler is what caused the event to be intercepted. The user settles the event by acting on + the object the callable receives. A handler that only stages mutations does not settle; it passes + the event to the next handler (decision 5). * A request has three: `fail` (BiDi's `FailRequest`) ends it with an error; `respond` (`ProvideResponse`) replies with a mock, so nothing reaches the server; `submit` (`ContinueRequest`) sends it on, with any staged mutations, and consults no further handler. @@ -269,34 +271,27 @@ network.add_request_handler(collect_body: true) { |r| log(r.body) } network.addRequestHandler(new BodyCollection(), r -> log(r.body())); ``` -11. **Handlers are scoped to one top-level browsing context by default.** A handler applies to the - top-level browsing context the session is on when it is registered — the current window handle. - Being switched into a frame does not narrow that: a frame is not a scope the protocol can express, - since it rejects a child context outright when an intercept is registered and widens one to its - top-level context when an event is subscribed to. Narrowing to a frame belongs in the callable. - - To scope a handler elsewhere the user passes **either** a browsing context **or** a user context, - never both — the two are mutually exclusive in the protocol, which errors when given both. A - browsing context targets that one tab, including a background tab that does not have focus. A user - context targets every top-level browsing context in that isolation partition, including ones - opened after the handler is registered, which makes it the unit for interception that spans a - partition rather than a known tab. - - The value a binding takes for the browsing context is the same one that scopes script and log - events — a window handle ([#17776](https://github.com/SeleniumHQ/selenium/pull/17776)), or the - browsing-context handle object where a binding exposes one - ([#17681](https://github.com/SeleniumHQ/selenium/pull/17681)). This record does not add a third - spelling. +11. **Handlers are scoped to one window handle by default.** A window handle is a top-level browsing + context; by default a handler applies to the one the session is on when it is registered. Being + switched into a frame does not narrow that; a frame is not a scope this API expresses, so narrowing + to one belongs in the callable. + + To scope a handler elsewhere the user passes either a window handle or a user context, never both. + A window handle targets that one tab, including a background tab that does not have focus. A user + context targets every window handle it contains, including ones opened later, so it scopes + interception to a whole user context rather than a single known tab. The two are mutually + exclusive: a handler is scoped by one or the other, and a binding rejects being given both. A + handler must only act on events within its scope. ```ruby -# Either a browsing context or a user context, never both +# Either a window handle or a user context, never both network.add_request_handler(window_handle: other_tab) { |r| r.fail if blocked?(r.url) } network.add_request_handler(user_context: isolated) { |r| r.fail if blocked?(r.url) } ``` ```java network.addRequestHandler(otherTab, r -> { if (blocked(r.url())) r.fail(); }); // one tab -network.addRequestHandler(isolated, r -> { if (blocked(r.url())) r.fail(); }); // whole partition +network.addRequestHandler(isolated, r -> { if (blocked(r.url())) r.fail(); }); // whole user context ``` ## Considered options @@ -387,14 +382,13 @@ network.addRequestHandler(isolated, r -> { if (blocked(r.url())) r.fail(); }); - **Context scoping (decision 11).** - No scoping, so every handler applies globally — cannot target a specific tab, a background tab, or a user context, which network work spanning several contexts needs. - - Scope only by browsing context — a user context is the natural unit for interception that spans a - partition, and it also covers tabs opened later, so either unit is accepted. - - Accept a browsing context and a user context together and intersect them — the protocol rejects - the combination as `invalid argument`, so there is nothing to send; and a handler scoped to one tab - is already narrower than any partition that tab belongs to. - - Scope to a frame rather than a top-level browsing context — not expressible: an intercept rejects a - child context and a subscription widens one to its top-level context, so a frame-scoped handler - would quietly behave as a tab-scoped one. + - Scope only by window handle — a user context is the natural unit for interception that spans + several tabs and covers tabs opened later, so either unit is accepted. + - Accept a window handle and a user context together — the two are mutually exclusive, and a handler + scoped to one tab is already narrower than the user context that tab belongs to, so combining them + has no meaning. + - Scope to a frame rather than a window handle — not a scope this API expresses; narrowing to a + frame goes in the callable. ## Consequences @@ -406,13 +400,10 @@ network.addRequestHandler(isolated, r -> { if (blocked(r.url())) r.fail(); }); committed only when it returns cleanly rather than accumulated on one shared event object. - Authentication handlers gain a callable form in addition to static credentials, so credentials can be produced — or the challenge cancelled — per challenge. -- Handlers can be scoped to a single top-level browsing context or to a user context, so interception - can target a background tab or a whole isolated partition rather than only the current tab. Because - the two are mutually exclusive, a binding rejects being given both rather than forwarding them. -- The protocol has no user-context parameter for network interception - ([w3c/webdriver-bidi#845](https://github.com/w3c/webdriver-bidi/issues/845)) — only for event - subscription. Until that lands, a binding honoring a user-context scope resolves it to that - partition's top-level browsing contexts itself, and keeps doing so as contexts are created, so the - handler covers tabs opened later. +- Handlers can be scoped to a single window handle or to a user context, so interception can target a + background tab or a whole user context rather than only the current tab. The two are mutually + exclusive, so a binding rejects being given both. +- Handlers with different scopes coexist: each acts only on events in its own scope, so an event a + broadly scoped handler intercepts does not invoke a narrower handler whose scope excludes it. - This changes handler behavior that several bindings already ship, so it is not backwards compatible. From fde91e435769dc486c72f0dff3be7a09eb7355a5 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 8 Sep 2026 12:17:40 -0500 Subject: [PATCH 19/20] [docs] ADR 17685: fail the event on an uncaught handler exception instead of submitting it --- .../17685-network-handler-behavior.md | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 2baf9c9fd363b..5680ae428be32 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -199,17 +199,17 @@ network.addRequestHandler(r -> r.addHeader("X-Test", "true")); network.addRequestHandler(r -> r.removeHeader("X-Test")); ``` -7. **An uncaught exception surfaces to the user and stops the chain.** The handler callable is +7. **An uncaught exception surfaces to the user and fails the event.** The handler callable is responsible for its own error handling. An exception it does not catch is not swallowed or merely logged: it surfaces to the user so it can be caught, and it does not on its own end the session. - When a handler raises, no further handlers run and the event is submitted with the mutations staged - by the handlers that completed before it, the same outcome the chain would reach on its own - (decision 5). A handler that raises contributes nothing; its own staged mutations are discarded, so - each handler applies all-or-nothing. + When a handler raises, no further handlers run and the event is failed (BiDi's `FailRequest`) + rather than sent: a request shaped by code that errored partway does not reach the server, and any + staged mutations are discarded. The failure is visible on the wire, not only as the raised + exception. ```ruby # LIFO: the raising handler runs first, so processing stops before the other handler runs. -# The request is submitted with what completed handlers staged; the exception surfaces to the user. +# The request is failed and the header is never applied; the exception surfaces to the user. network.add_request_handler { |r| r.add_header("X-Test", true) } # never runs network.add_request_handler { |r| raise Exception } # runs first, then raises ``` @@ -359,14 +359,13 @@ network.addRequestHandler(isolated, r -> { if (blocked(r.url())) r.fail(); }); the browser, whereas decision 7 surfaces the error, stops only this event's chain, and leaves the session running. - Leave the request unresolved on a throw, as Playwright does — a handler that raises without - settling leaves the request hanging until it times out. Decision 7 submits the staged state instead - so the browser is never left waiting. - - Keep running the remaining handlers after the throw, or discard what is staged and send the - browser's original request — the first runs a chain past a fault the user is already being told - about, the second throws away changes from handlers that completed cleanly; stopping and submitting - what completed handlers staged does neither. - - Abort or mock-respond on any handler error — deterministic, but turns a handler bug into a failed - or empty request instead of letting it proceed. + settling leaves the request hanging until it times out. Decision 7 fails the event instead so the + browser is never left waiting. + - Submit the request anyway, with whatever the completed handlers staged, or keep running the + remaining handlers — the first sends a request shaped by code that errored partway; the second runs + a chain past a fault the user is already being told about. Failing the event does neither. + - Mock-respond on any handler error — deterministic like failing, but fabricates a response for a + handler bug instead of surfacing it as a failed request. - **Return values (decision 8).** - Let a return value set event or handler state instead of acting on the wrapper — not straightforward across all languages. @@ -396,8 +395,6 @@ network.addRequestHandler(isolated, r -> { if (blocked(r.url())) r.fail(); }); handlers rather than diverging. - 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. -- A handler's mutations apply all-or-nothing, so each handler's changes are staged separately and - committed only when it returns cleanly rather than accumulated on one shared event object. - Authentication handlers gain a callable form in addition to static credentials, so credentials can be produced — or the challenge cancelled — per challenge. - Handlers can be scoped to a single window handle or to a user context, so interception can target a From d1a64c8600093834864ca7bd9e959d17a03605c4 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 8 Sep 2026 18:53:09 -0500 Subject: [PATCH 20/20] [docs] ADR 17685: clarify decision 4 handler scope vs event dispatch; a window handle is a window or tab --- docs/decisions/17685-network-handler-behavior.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/decisions/17685-network-handler-behavior.md b/docs/decisions/17685-network-handler-behavior.md index 5680ae428be32..27c251e78ea47 100644 --- a/docs/decisions/17685-network-handler-behavior.md +++ b/docs/decisions/17685-network-handler-behavior.md @@ -134,8 +134,9 @@ network.addAuthentication(UsernameAndPassword.of("user", "pass"), 4. **When a handler settles a disposition, the first to do so resolves the event and stops the chain.** An event's chain is the registered handlers whose URL patterns (decision 2) and - scope (decision 11) match it; a handler outside the event's scope is not consulted, even when a - broader handler is what caused the event to be intercepted. The user settles the event by acting on + scope (decision 11) match it. Interception blocks the event as a whole, not per handler, so one + matching handler is enough to block it; a handler the event does not match is not consulted, even + though it was blocked on another handler's behalf. The user settles the event by acting on the object the callable receives. A handler that only stages mutations does not settle; it passes the event to the next handler (decision 5). * A request has three: `fail` (BiDi's `FailRequest`) ends it with an error; @@ -277,7 +278,7 @@ network.addRequestHandler(new BodyCollection(), r -> log(r.body())); to one belongs in the callable. To scope a handler elsewhere the user passes either a window handle or a user context, never both. - A window handle targets that one tab, including a background tab that does not have focus. A user + A window handle targets that one window or tab, including one in the background without focus. A user context targets every window handle it contains, including ones opened later, so it scopes interception to a whole user context rather than a single known tab. The two are mutually exclusive: a handler is scoped by one or the other, and a binding rejects being given both. A