-
-
Notifications
You must be signed in to change notification settings - Fork 8.7k
[adr] network handler behavior proposal #17685
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
titusfortner
wants to merge
1
commit into
SeleniumHQ:trunk
Choose a base branch
from
titusfortner:adr_handler_behavior
base: trunk
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+230
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ``` | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, maybe the right answer here is to make this user-toggled with the default being to raise. I didn't consider this when suggesting alternatives below. Perhaps we do want to error-by-default but provide a global escape hatch while things are still in draft.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel fail fast should always be the right approach for people
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First, to clarify, Python as written does the error behavior in this ADR (except for it doesn't discard staged changes).
As for fast-fail, that's correct for synchronous code evaluating the behavior. In network handlers, the failures will come from things the author didn't write like third-party scripts, analytics beacons, favicon retries, an auth challenge from an unrelated CDN. We don't want to necessarily couple the test's pass/fail to the open internet via a still-under-development BiDi protocol.
The reason I don't love this proposal as written is because when a user wants to mutate a specific thing as the intent of the test, silently ignoring it is a real problem, but I also don't want to de facto require users to always wrap everything in a try/catch which seems like a bad experience.
Honestly the desired default behavior is going to be different if a user wants to observe vs mutate, which might be a good reason to support the differentiation earlier. The alternative would be an error handling toggle for propagate vs log