Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions docs/decisions/17685-network-handler-behavior.md
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

Copy link
Copy Markdown
Member Author

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.

Copy link
Copy Markdown
Member

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

@titusfortner titusfortner Jun 18, 2026

Copy link
Copy Markdown
Member Author

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

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
```