| # 17685. The network async/event API |
| |
| - Status: Accepted |
| - Discussion: [#17685](https://github.com/SeleniumHQ/selenium/pull/17685) |
| - Tracking: https://github.com/SeleniumHQ/selenium/issues/18019 |
| |
| ## Context |
| |
| 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. |
| |
| 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 | |
| |------------|------------------| |
| | 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. | |
| | 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](17670-bidi-implementation-boundaries.md)); |
| nothing here exposes a protocol type. |
| |
| ## Decision |
| |
| 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: |
| `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. |
| |
| Additionally, a convenience method named `addAuthentication` wraps |
| `addAuthenticationHandler`, taking credentials without a callable for the |
| 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) } |
| network.remove_request_handler(handle) |
| network.clear_request_handlers |
| ``` |
| |
| ```java |
| RequestHandler handle = network.addRequestHandler( |
| r -> { if (blocked(r.url())) r.fail(); }); |
| 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. |
| |
| 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 a url pattern argument must be resolvable by the |
| 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 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 |
| network.add_request_handler( |
| url_patterns: ["https://api.example.com/orders", |
| {hostname: "cdn.example.com"}] |
| ) { |r| r.fail } |
| |
| # 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 |
| ``` |
| |
| ```java |
| network.addRequestHandler( |
| 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 read it, change it, or settle its disposition |
| (decisions 4–5); an authentication handler settles a challenge by supplying |
| credentials or cancelling. |
| |
| ```ruby |
| 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 -> { |
| 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. **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. 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; |
| `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 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 |
| `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 |
| 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 (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); }); |
| ``` |
| |
| 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 |
| # Stages a change and passes to the next handler; no disposition specified |
| network.add_request_handler { |r| r.add_header("X-Test", true) } |
| ``` |
| |
| ```java |
| network.addRequestHandler(r -> r.addHeader("X-Test", "true")); |
| ``` |
| |
| 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. |
| * 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") } |
| ``` |
| |
| ```java |
| network.addRequestHandler(r -> r.addHeader("X-Test", "true")); |
| network.addRequestHandler(r -> r.removeHeader("X-Test")); |
| ``` |
| |
| 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 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 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 |
| ``` |
| |
| ```java |
| 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 |
| 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" } |
| ``` |
| |
| ```java |
| // Java: the handler is a void Consumer, so there is no return value to ignore |
| network.addRequestHandler(r -> r.addHeader("X-Test", "true")); |
| ``` |
| |
| 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 |
| 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) } |
| ``` |
| |
| ```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")); |
| ``` |
| |
| 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` 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_request_handler(collect_body: true) { |r| log(r.body) } |
| ``` |
| |
| ```java |
| network.addRequestHandler(new BodyCollection(), r -> log(r.body())); |
| ``` |
| |
| 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 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 |
| handler must only act on events within its scope. |
| |
| ```ruby |
| # 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 user context |
| ``` |
| |
| ## Considered options |
| |
| - **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 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 |
| id. |
| - **Filtering (decision 2).** |
| - 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. |
| - 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).** |
| - 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. |
| - 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. |
| - **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 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 |
| 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. |
| - 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).** |
| - 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 — 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 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. |
| - **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 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. |
| - 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 tab, or |
| a user context, which network work spanning several contexts needs. |
| - 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 |
| |
| - Every binding implements one add / remove / clear surface for request, response, and authentication |
| 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 |
| 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 |
| 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. |