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); nothing here exposes a protocol type.
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.
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.
handle = network.add_request_handler { |r| r.fail if blocked?(r.url) } network.remove_request_handler(handle) network.clear_request_handlers
RequestHandler handle = network.addRequestHandler( r -> { if (blocked(r.url())) r.fail(); }); network.removeRequestHandler(handle); network.clearRequestHandlers();
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.
# 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
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(); });
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"}])
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()));
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.fail and submit. It has already round-tripped, so whether submit maps to ContinueResponse or ProvideResponse follows from whether a replacement body was given.# 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) }
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); });
# Stages a change and passes to the next handler; no disposition specified network.add_request_handler { |r| r.add_header("X-Test", true) }
network.addRequestHandler(r -> r.addHeader("X-Test", "true"));
# 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") }
network.addRequestHandler(r -> r.addHeader("X-Test", "true")); network.addRequestHandler(r -> r.removeHeader("X-Test"));
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.# 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
network.addRequestHandler(r -> r.addHeader("X-Test", "true")); // never runs network.addRequestHandler(r -> { throw new RuntimeException(); }); // runs first, surfaces to the user
# Ruby: this implicit return value is ignored network.add_request_handler { |r| r.add_header("X-Test", true); "this value is ignored" }
// Java: the handler is a void Consumer, so there is no return value to ignore network.addRequestHandler(r -> r.addHeader("X-Test", "true"));
# 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) }
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"));
addDataCollector / getData or tears a collector down.addRequestHandler registration.# Declare body collection at registration; the body is then available on the event network.add_request_handler(collect_body: true) { |r| log(r.body) }
network.addRequestHandler(new BodyCollection(), r -> log(r.body()));
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.
# 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) }
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
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./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.add_*_handler would promise a handler the user never wrote.remove and clear would silently miss it, and the two would not have a defined order relative to each other.continueRequest override failures and stubs (current Python) — no obvious reason that command should win.continue — reads ambiguously as “continue this request” versus “continue to the next handler”; submit names the intent of sending this request now.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.