Table of Contents

Error Handling

This guide covers comprehensive error handling strategies for WebDriverBiDi.NET applications.

Overview

WebDriverBiDi.NET operations can fail for various reasons:

  • Network connectivity issues
  • Browser crashes or disconnections
  • Invalid command parameters
  • Timeout waiting for responses
  • JavaScript exceptions in the browser
  • Protocol-level errors

Understanding how to handle these errors properly is crucial for building robust automation.

Exception Types

Exception Hierarchy

Every protocol-level failure is reported through WebDriverBiDiException, so one catch clause covers everything the browser or the connection can do to you. Caller mistakes surface as the usual .NET exceptions instead, which are listed below after the table. The more specific protocol types let you react differently to different failures:

Exception
└── WebDriverBiDiException
    ├── WebDriverBiDiErrorResponseException (abstract)
    │   ├── WebDriverBiDiCommandException
    │   └── WebDriverBiDiProtocolException
    ├── WebDriverBiDiTimeoutException
    ├── WebDriverBiDiConnectionException
    └── WebDriverBiDiSerializationException
Type Thrown when Where you see it
WebDriverBiDiCommandException The browser answers a command with an error response From the command call (NavigateAsync, ExecuteCommandAsync, ...)
WebDriverBiDiProtocolException The browser sends an error response that matches no pending command Never from a command call; routed through UnexpectedErrorBehavior (ignored, collected, or thrown from the next command)
WebDriverBiDiTimeoutException No response arrives within the command timeout; a connection does not open within its StartupTimeout; a send does not obtain exclusive access to the connection within its DataTimeout; or an operation does not obtain exclusive access to the connection within Transport.ConnectionLockTimeout (see Transport Connection Lock Timeout) From the command call, or from StartAsync or StopAsync
WebDriverBiDiConnectionException Sending while not connected; starting an already-started driver; the connection drops while a command is in flight; the connection cannot be opened; or the connection is lost while the session is being established (see Losing the connection while connecting) From the command call, or from StartAsync
WebDriverBiDiSerializationException Command parameters cannot be serialized, or a response cannot be deserialized From the command call. A malformed error response or event that belongs to no command is routed through ProtocolErrorBehavior instead, and a message that is not valid JSON through UnknownMessageBehavior
WebDriverBiDiException (directly) A command is canceled or returns no result or a result of the wrong type; a duplicate command ID; a RemoteValue.As<T>() or EvaluateResult.As<T>() cast or a LocalValue conversion fails; event arguments of an unexpected type From the call that performed the cast, conversion, or command

WebDriverBiDiErrorResponseException is the abstract base of the two types that carry a structured error from the browser. It exposes ErrorDetails (the raw ErrorResult), ErrorCode (the ErrorCode enum value, or ErrorCode.UnsetErrorCode for an unrecognized error string), ProtocolErrorType, ProtocolErrorMessage, and RemoteStackTrace. Prefer ErrorCode over inspecting Message when deciding how to react.

Library calls also throw the usual .NET exceptions for caller mistakes: ArgumentNullException/ArgumentOutOfRangeException (null parameters, negative timeouts), ArgumentException (a connection string the connection cannot accept, such as a StartAsync URL that is not an absolute ws/wss URI), ObjectDisposedException (using a disposed driver), InvalidOperationException (registering a module, event, or resolver after StartAsync), and OperationCanceledException (a canceled CancellationToken). StopAsync throws AggregateException when errors were accumulated under TransportErrorBehavior.Collect, and the next command throws AggregateException under Terminate when more than one error accumulated (see Transport Error Behavior Configuration).

Catching each type:

try
{
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (WebDriverBiDiCommandException ex)
{
    // The browser answered the command with an error response.
    Console.WriteLine($"Command failed: {ex.ErrorCode} ({ex.ProtocolErrorType}): {ex.ProtocolErrorMessage}");
    if (ex.RemoteStackTrace is not null)
    {
        Console.WriteLine(ex.RemoteStackTrace);
    }
}
catch (WebDriverBiDiTimeoutException ex)
{
    // No response within the command timeout (or the connection did not open in time).
    Console.WriteLine($"Timed out: {ex.Message}");
}
catch (WebDriverBiDiConnectionException ex)
{
    // Not connected, already connected, or the connection dropped mid-command.
    Console.WriteLine($"Connection problem: {ex.Message}");
}
catch (WebDriverBiDiSerializationException ex)
{
    // Parameters could not be serialized, or the response could not be deserialized.
    Console.WriteLine($"Serialization problem: {ex.Message}");
}
catch (WebDriverBiDiException ex)
{
    // Everything else the library raises: canceled command, unexpected result type,
    // RemoteValue conversion failures, and so on.
    Console.WriteLine($"Other BiDi error: {ex.Message}");
}

WebDriverBiDiException

Catching the base type handles every library failure in one place:

try
{
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (WebDriverBiDiException ex)
{
    Console.WriteLine($"WebDriver BiDi error: {ex.Message}");
    Console.WriteLine($"Stack trace: {ex.StackTrace}");
}

Common Error Scenarios

Use ErrorCode on WebDriverBiDiCommandException to distinguish the error responses you expect to handle:

try
{
    NavigateCommandResult result = await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (WebDriverBiDiTimeoutException ex)
{
    Console.WriteLine("Navigation timeout - page took too long to load");
    // Handle timeout specifically
}
catch (WebDriverBiDiCommandException ex) when (ex.ErrorCode == ErrorCode.NoSuchFrame)
{
    Console.WriteLine("Browsing context no longer exists");
    // Handle missing context
}
catch (WebDriverBiDiCommandException ex) when (ex.ErrorCode == ErrorCode.InvalidArgument)
{
    Console.WriteLine($"Invalid command parameters: {ex.ProtocolErrorMessage}");
    // Handle parameter error
}
catch (WebDriverBiDiException ex)
{
    Console.WriteLine($"Other BiDi error: {ex.Message}");
    // Handle general errors
}

Transport Error Behavior Configuration

WebDriverBiDi.NET allows you to configure how transport-layer errors are handled using the TransportErrorBehavior enum. This controls errors that no command call can report: exceptions in event handlers, and messages from the browser that the transport cannot process or does not recognize.

Important: Command errors always throw exceptions immediately, regardless of this setting. This behavior only affects:

  • Exceptions thrown by event handlers (EventHandlerExceptionBehavior)
  • Protocol errors: an error response or registered event whose payload cannot be deserialized, or an unexpected failure while processing a message (ProtocolErrorBehavior)
  • Unknown messages: a message that is not valid JSON, or not a command response, error response or registered event (UnknownMessageBehavior)
  • Unexpected error responses without matching commands (UnexpectedErrorBehavior)

Late responses are not errors. When a command times out, is canceled by its CancellationToken, or is canceled directly through Transport.CancelCommand, the browser does not know that you stopped waiting and may still answer. The transport remembers the commands among its most recent cancellations (the last 1,024 per session by default, adjustable through driver.TransportConfiguration.MaxTrackedCanceledCommands) and, when such a response or error response arrives, discards it after logging a Debug-level message through OnLogMessage and emitting the CanceledCommandResponseDiscarded EventSource event. It is not counted under UnknownMessageBehavior or UnexpectedErrorBehavior, so a slow navigation that times out and then completes does not terminate the session in Terminate mode. Only a response whose command ID was never issued, or whose command has been forgotten because a full window of further commands was canceled after it, is treated as an unknown message or unexpected error. The window counts cancellations, so a command can be forgotten even when the late responses for the commands canceled after it have already arrived. The commands remembered are those of the current session, so a response to a command of an earlier session that the connection delivers only after the transport has reconnected is also treated as an unknown message or unexpected error; command IDs are never reused, so it cannot be mistaken for the response to a command of the new session.

Each remembered entry is a CanceledCommandInfo, carrying the command's CommandId and CommandName, the ResponseType the answer would have been deserialized to, the TimeSinceCancellation, and a Reason of type CommandCancellationReason:

Reason Meaning
Canceled The command's CancellationToken fired, or the command was canceled directly through Transport.CancelCommand
TimedOut The command's timeout elapsed before a response arrived
ConnectionClosed The command was still pending when the connection closed

The reason appears in the Debug log message and in the CanceledCommandResponseDiscarded EventSource event's payload, which is how you tell a slow-but-successful command apart from one abandoned at shutdown.

public enum TransportErrorBehavior
{
    Ignore,     // Neither collect nor throw; still reported through diagnostics (default)
    Collect,    // Store errors for later inspection
    Terminate   // Throw exception on next command
}

Understanding Event Handler Error Propagation

Event handlers run on separate threads from your main application code. This means exceptions in event handlers don't directly propagate to the calling code. The transport error behavior determines what happens when event handler exceptions occur:

  • Ignore (default): Exception is neither collected nor thrown; it is raised on OnEventHandlerErrorOccurred
  • Collect: Exception is stored in a list for later inspection
  • Terminate: Exception is stored and thrown when you send the next command

For handlers registered with ObservableEventHandlerOptions.RunHandlerAsynchronously, this behavior also applies to exceptions that occur after the handler has returned control to the transport thread. In other words, exceptions from handlers being run asynchronously are not silently dropped.

Each failing observer is reported on its own, whether it runs synchronously or asynchronously, and the report identifies it: OnEventHandlerErrorOccurred receives the observer's ObserverId and ObserverDescription, the name of the event it was added to, and the exception it threw. When several observers of one event fail, each is reported separately, and every observer is still notified.

The one important exception is capture session task capture. If you capture async handler tasks by using WaitForCapturedTasksAsync() or WaitForCapturedTasksCompleteAsync(), those task exceptions remain owned by your code. They are propagated through the captured task path rather than being surfaced again through EventHandlerExceptionBehavior.

Why Ignore is the Default

WebDriverBiDi.NET defaults all error behaviors to Ignore for several important reasons:

1. Protocol Stability During Evolution

  • The WebDriver BiDi protocol is actively evolving with new features being added regularly
  • Browsers may send events or messages that aren't yet fully specified
  • Unknown messages (ones that match no structure the library recognizes) are common during protocol transitions
  • Ignore mode allows automation to continue working even when protocols diverge slightly between library and browser versions

2. Event Handler Resilience

  • Event handlers are secondary to the main automation flow
  • In many cases, a failing log observer or network monitor shouldn't halt critical automation workflows
  • Users can explicitly handle errors within their handlers using try-catch blocks
  • Forcing all users to handle potential event handler exceptions would add significant boilerplate

3. Graceful Degradation

  • Libraries like this are often used for web scraping, testing, and monitoring where partial success is acceptable
  • Stopping the entire driver on a malformed protocol message would be overly aggressive
  • Users can opt-in to stricter error handling (Terminate mode) when needed

4. Backward Compatibility

  • As browsers implement new WebDriver BiDi features, older library versions will encounter unknown protocol extensions
  • Ignore allows older library versions to continue functioning with newer browsers
  • This is especially important for long-running automation infrastructure

When to Change from the Default:

  • Development/Testing: Use Terminate mode to catch issues early and ensure proper error handling
  • Diagnostics: Use Collect mode to gather all errors for troubleshooting
  • Production (with mature code): Consider Terminate mode once your automation is stable and tested

Ignore Mode (Default)

Ignore mode neither collects nor throws transport errors. This is the default behavior for all error types. An ignored error is not silent, though: each kind is still reported through the driver's diagnostic channels, so you can watch for it without changing the behavior:

Error Still reported through
Event handler exception OnEventHandlerErrorOccurred, and the EventHandlerError EventSource event
Protocol error OnLogMessage at Error, and, for a payload that cannot be deserialized, the ProtocolError EventSource event as well. A fault of the message-processing loop itself raises that EventSource event and is not logged. No observable event is raised for any of them
Unknown message OnUnknownMessageReceived, and the UnknownMessageReceived EventSource event; a message that is not valid JSON is also written to OnLogMessage at Error
Unexpected error OnUnexpectedErrorReceived
// Default behavior - errors are ignored
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));

try
{
    await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

    // Event handler errors are neither collected nor thrown; they are raised on OnEventHandlerErrorOccurred
    driver.Log.OnEntryAdded.AddObserver((e) =>
    {
        // This runs on a separate thread
        // If it throws, the exception is reported through OnEventHandlerErrorOccurred
        ProcessLogEntry(e);  // May throw
    });

    await driver.Session.SubscribeAsync(subscribeParams);

    // Commands proceed normally; event handler errors surface only through OnEventHandlerErrorOccurred
    await driver.BrowsingContext.NavigateAsync(navParams);
}
finally
{
    await driver.StopAsync();
}

Use Ignore Mode When:

  • Working with evolving protocol versions where unknown messages are expected
  • Event handler failures shouldn't stop critical automation workflows
  • You handle all errors within event handlers themselves using try-catch
  • Operating in scenarios where graceful degradation is preferred
  • Backward compatibility is more important than strict error reporting

⚠️ Note: While this is the default, consider using Terminate mode during development to catch issues early. The default exists primarily for protocol stability and backward compatibility, not because it's always the best choice for your use case

Collect Mode

Collect mode stores transport errors in a list, throwing them when the driver is stopped:

WebSocketConnection connection = new WebSocketConnection();
Transport transport = new Transport(connection)
{
    EventHandlerExceptionBehavior = TransportErrorBehavior.Collect,
    ProtocolErrorBehavior = TransportErrorBehavior.Collect,
    UnknownMessageBehavior = TransportErrorBehavior.Collect,
    UnexpectedErrorBehavior = TransportErrorBehavior.Collect,
};
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30), transport);

try
{
    await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

    // Subscribe to events with potentially failing handlers
    driver.Log.OnEntryAdded.AddObserver((e) =>
    {
        // This runs on a separate thread
        // If it throws, error is collected but never thrown
        ProcessLogEntry(e);
    });

    driver.Network.OnBeforeRequestSent.AddObserver((e) =>
    {
        // If this throws, error is collected
        ProcessNetworkRequest(e);
    });

    await driver.Session.SubscribeAsync(subscribeParams);

    // Send commands - event handler errors won't be thrown
    await driver.BrowsingContext.NavigateAsync(navParams);
    await driver.Script.EvaluateAsync(evalParams);

    await driver.StopAsync();
}
catch (AggregateException ex)
{
    // Explicitly check for collected errors when ready
    if (ex.InnerExceptions.Count > 0)
    {
        Console.WriteLine($"\nCollected {ex.InnerExceptions.Count} transport errors:");
        foreach (Exception error in ex.InnerExceptions)
        {
            Console.WriteLine($"  [{error.GetType().Name}] {error.Message}");
            Console.WriteLine($"    From: {error.StackTrace?.Split('\n')[0].Trim()}");
        }

        // Analyze error types
        var eventHandlerErrors = ex.InnerExceptions
            .Where(e => e.StackTrace?.Contains("AddObserver") == true)
            .ToList();

        // WebDriverBiDiProtocolException is raised for error responses that match no
        // pending command (UnexpectedErrorBehavior); deserialization failures are
        // collected as WebDriverBiDiSerializationException.
        var protocolErrors = ex.InnerExceptions
            .OfType<WebDriverBiDiProtocolException>()
            .ToList();

        Console.WriteLine($"  Event handler errors: {eventHandlerErrors.Count}");
        Console.WriteLine($"  Unexpected error responses: {protocolErrors.Count}");
    }
}
finally
{
    await driver.DisposeAsync();
}

Use Collect Mode When:

  • Diagnosing flaky or failing event handlers
  • You want to continue operation despite event handler errors
  • Testing error resilience of your event handling code
  • You need a complete error report after operations
  • Protocol errors might be transient

Important: Your code continues normally—errors throw when driver stopped as an AggregateException. This includes exceptions from handlers being run asynchronously unless you have explicitly captured those handler tasks via a capture session.

Collected errors are thrown only by StopAsync(). DisposeAsync() calls StopAsync() internally, but it catches the resulting AggregateException, logs it at Warn level through OnLogMessage, and does not rethrow. If you rely on await using (or a bare DisposeAsync()) without calling StopAsync() first, every collected error is discarded after that single log message. Always call await driver.StopAsync() inside a try block and observe the AggregateException there, as the sample above does; the BIDI012 analyzer reports a warning when a Collect behavior is configured in a method that disposes the driver without stopping it first.

Errors belong to the session they arose in. An error that arises while the transport processes a message received in one session, including a fault in a handler run for that message, even one run asynchronously that faults much later, is collected by that session alone. If the transport has reconnected by the time the error arises, that session has ended: its collected errors can no longer be thrown by stopping it, and it can no longer be terminated. The error is therefore not collected by the new session, under Collect or Terminate; it is logged at Warn level through OnLogMessage instead, so it cannot fail or terminate a session it has nothing to do with.

Terminate Mode

Terminate mode stores exceptions from event handlers and throws them when you send the next command:

// Create transport with Terminate behavior (opt-in to stricter error handling)
WebSocketConnection connection = new WebSocketConnection();
Transport transport = new Transport(connection)
{
    EventHandlerExceptionBehavior = TransportErrorBehavior.Terminate,
    ProtocolErrorBehavior = TransportErrorBehavior.Terminate,
    UnknownMessageBehavior = TransportErrorBehavior.Terminate,
    UnexpectedErrorBehavior = TransportErrorBehavior.Terminate,
};
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30), transport);

try
{
    await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

    // Subscribe to events
    driver.Log.OnEntryAdded.AddObserver((e) =>
    {
        // This runs on a separate thread
        if (e.Level == LogLevel.Error)
        {
            throw new InvalidOperationException("Error log entry received");
        }
    });

    await driver.Session.SubscribeAsync(subscribeParams);

    // If an error log event occurs, the exception won't throw immediately
    // because the event handler runs on a separate thread

    // The exception will be thrown here when we send the next command. With exactly
    // one accumulated error it is a WebDriverBiDiException wrapping it; with more than
    // one, an AggregateException is thrown instead.
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (WebDriverBiDiException ex)
{
    // One accumulated error arrives here, wrapped.
    Console.WriteLine($"Transport error: {ex.Message}");
}
catch (AggregateException ex)
{
    // Several accumulated errors arrive here, one per inner exception.
    foreach (Exception inner in ex.InnerExceptions)
    {
        Console.WriteLine($"Transport error: {inner.Message}");
    }
}
finally
{
    await driver.StopAsync();
}

Note: If an error log event occurs, the exception won't throw immediately because the event handler runs on a separate thread. The exception will be thrown when you send the next command (e.g., NavigateAsync), and your catch block will receive it. With exactly one accumulated error the thrown exception is a WebDriverBiDiException wrapping it; if more than one error accumulated before the next command, an AggregateException containing all of them is thrown instead, so catch both.

Why This Matters:

  • Event handlers execute asynchronously on the transport thread
  • Your main code doesn't directly wait for event handlers to complete
  • Terminate mode ensures errors are eventually reported to your code, including exceptions from handlers being run asynchronously
  • The error surfaces when you send the next command, which is a natural synchronization point

Capture-session-owned exceptions are different: if you use WaitForCapturedTasksAsync() or WaitForCapturedTasksCompleteAsync() to take ownership of async handler tasks, exceptions from those tasks propagate through the returned task path instead of terminating on the next command.

Use Terminate Mode When:

  • You want event handler errors to be reported (recommended for development)
  • You need fast failure on protocol errors
  • Operating in production with known stable protocol versions
  • You prefer explicit error handling over silent failures

Behavior Comparison

Mode Event Handler Exceptions Protocol Errors Command Errors When Error Surfaces
Ignore (default) Discarded and logged, including exceptions from asynchronously run handlers when those tasks are not capture-session-owned Discarded and logged Always throws immediately Never
Collect Stored in list, including exceptions from asynchronously run handlers when those tasks are not capture-session-owned Stored in list Always throws immediately When driver stopped
Terminate Throws on next command, including exceptions from asynchronously run handlers when those tasks are not capture-session-owned Throws on next command Always throws immediately Synchronization point (next command); WebDriverBiDiException for one error, AggregateException for several

When async handler tasks are explicitly owned via a capture session, their exceptions are owned by the caller instead of being routed through the transport behavior above.

Threading Model and Error Propagation

Understanding the threading model is crucial for error handling:

BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));

// Terminate mode is what surfaces a handler's exception on a later command; under the default
// Ignore the command below completes and the catch never runs.
driver.TransportConfiguration.EventHandlerExceptionBehavior = TransportErrorBehavior.Terminate;
await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

// Main thread (your code)
Console.WriteLine("Main thread: Setting up event handler");

driver.Log.OnEntryAdded.AddObserver((e) =>
{
    // The reader task, not the thread that started the driver
    Console.WriteLine($"Reader task: Processing event {e.Type}");

    // If this throws, the exception occurs on the reader task
    if (e.Level == LogLevel.Error)
    {
        throw new InvalidOperationException("Error log entry");
    }
});

await driver.Session.SubscribeAsync(subscribeParams);

Console.WriteLine("Main thread: Executing command");

try
{
    // Your own thread: with Terminate mode, a handler's exception from the reader task is
    // surfaced here, on the next command
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (AggregateException ex)
{
    // More than one error accumulated before this command: each is an inner exception.
    Console.WriteLine($"Caught errors from the reader task: {string.Join(", ", ex.InnerExceptions.Select(inner => inner.Message))}");
}
catch (WebDriverBiDiException ex)
{
    Console.WriteLine($"Caught exception from the reader task: {ex.Message}");
}

Connection-Level Error Monitoring

For real-time error visibility without affecting behavior, use connection events:

WebSocketConnection connection = new WebSocketConnection();

// These run on the transport thread but don't throw to main thread
connection.OnConnectionError.AddObserver((errorArgs) =>
{
    Console.WriteLine($"[Connection Error] {errorArgs.Exception.Message}");
    LogToFile($"Transport error: {errorArgs.Exception}");
});

connection.OnLogMessage.AddObserver((logArgs) =>
{
    if (logArgs.Level == WebDriverBiDiLogLevel.Error)
    {
        Console.WriteLine($"[Transport Error Log] {logArgs.Message}");
    }
});

Transport transport = new Transport(connection);
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30), transport);

await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

Complete Error Handling Pattern

Combine all approaches for comprehensive error management. See the Collect Mode and Complete Event Handler Pattern sections for the key patterns.

Best Practices

  1. Use Terminate mode during development: Surfaces handler bugs and protocol issues as exceptions instead of leaving them to diagnostic events nothing may be watching
  2. Handle errors inside event handlers: Use try-catch within handlers when possible
  3. Use Collect mode for diagnostics: Helpful for troubleshooting event handler issues
  4. Monitor connection events: Use driver.OnConnectionLost to learn when the connection ends, and OnConnectionError for real-time error visibility
  5. Remember the threading model: Event handlers run on separate threads
  6. Check for errors at logical points: With Collect mode, inspect errors after operations
  7. Don't lean on Ignore mode to hide errors: The Ignore default is appropriate for production only when your handlers do their own error handling (see the mode table below); it is not a substitute for explicit error handling

Script Execution Errors

Handling JavaScript Exceptions

JavaScript errors are returned as EvaluateResultException rather than thrown:

EvaluateResult result = await driver.Script.EvaluateAsync(
    new EvaluateCommandParameters(
        "document.querySelector('.missing').textContent",
        new ContextTarget(contextId),
        true));

if (result is EvaluateResultSuccess success)
{
    string text = success.Result.As<StringRemoteValue>().Value;
    Console.WriteLine($"Text: {text}");
}
else if (result is EvaluateResultException exception)
{
    Console.WriteLine($"JavaScript error: {exception.ExceptionDetails.Text}");
    Console.WriteLine($"Line: {exception.ExceptionDetails.LineNumber}");
    Console.WriteLine($"Column: {exception.ExceptionDetails.ColumnNumber}");

    // StackTrace is always present; an empty CallFrames list is how "no frames" arrives.
    Console.WriteLine("Stack trace:");
    foreach (var frame in exception.ExceptionDetails.StackTrace.CallFrames)
    {
        Console.WriteLine($"  at {frame.FunctionName} ({frame.Url}:{frame.LineNumber})");
    }

    // Handle the error appropriately
}

Safe Script Execution Pattern

public async Task<T?> TryEvaluateAsync<T>(
    BiDiDriver driver,
    string expression,
    string contextId,
    T? defaultValue = default)
{
    try
    {
        EvaluateResult result = await driver.Script.EvaluateAsync(
            new EvaluateCommandParameters(
                expression,
                new ContextTarget(contextId),
                true));

        if (result is EvaluateResultSuccess success &&
            success.Result is ITypeSafeRemoteValue<T> typeSafeValue)
        {
            return typeSafeValue.Value;
        }
        else if (result is EvaluateResultException exception)
        {
            Console.WriteLine($"Script error: {exception.ExceptionDetails.Text}");
            return defaultValue;
        }
    }
    catch (WebDriverBiDiException ex)
    {
        Console.WriteLine($"Command error: {ex.Message}");
    }

    return defaultValue;
}
// Usage
string? title = await TryEvaluateAsync<string>(
    driver,
    "document.title",
    contextId,
    "Unknown");

Connection Errors

Handling Disconnections

public class ResilientDriver
{
    private BiDiDriver driver;
    private readonly string webSocketUrl;
    private readonly int maxRetries = 3;

    public async Task<T> ExecuteWithRetryAsync<T>(
        Func<BiDiDriver, Task<T>> operation)
    {
        int attempt = 0;
        Exception? lastException = null;

        while (attempt < maxRetries)
        {
            try
            {
                return await operation(driver);
            }
            catch (WebDriverBiDiException ex) when (
                ex.Message.Contains("WebSocket") ||
                ex.Message.Contains("connection"))
            {
                lastException = ex;
                attempt++;

                if (attempt < maxRetries)
                {
                    Console.WriteLine($"Connection lost. Reconnecting... (attempt {attempt})");
                    await Task.Delay(TimeSpan.FromSeconds(2));

                    // Reconnect
                    await driver.StopAsync();
                    driver = new BiDiDriver(TimeSpan.FromSeconds(30));
                    await driver.StartAsync(webSocketUrl);
                }
            }
        }

        throw new Exception(
            $"Operation failed after {maxRetries} attempts",
            lastException);
    }
}
// Usage
ResilientDriver resilientDriver = new ResilientDriver();

NavigateCommandResult result = await resilientDriver.ExecuteWithRetryAsync(
    async (d) => await d.BrowsingContext.NavigateAsync(navParams));

Timeout Handling

For per-command timeouts, prefer the built-in timeoutOverride parameter over custom patterns (e.g., Task.WhenAny with Task.Delay). Use custom patterns only when you need different semantics than the built-in timeout.

Preferred Approach: Use the Built-in Timeout Override

The simplest and recommended way to set a per-command timeout is the timeoutOverride parameter on module methods. Every module command accepts this as the second parameter:

// Per-driver default timeout (set when creating the driver)
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));

// Per-command timeout override (preferred)
NavigateCommandResult result = await driver.BrowsingContext.NavigateAsync(
    navParams,
    TimeSpan.FromSeconds(60));  // Override for this command only

// Quick operations can use a shorter timeout
StatusCommandResult status = await driver.Session.StatusAsync(
    null,
    timeoutOverride: TimeSpan.FromSeconds(5));

You can also use ExecuteCommandAsync when working at the driver level:

NavigateCommandResult result = await driver.ExecuteCommandAsync<NavigateCommandResult>(
    navParams,
    TimeSpan.FromSeconds(60));

When to Use Custom Timeout Patterns

Use a custom pattern (e.g., Task.WhenAny with Task.Delay) only when you need different semantics than the built-in timeout—for example, returning null instead of throwing, or implementing custom retry logic:

public async Task<NavigateCommandResult?> NavigateWithTimeoutAsync(
    BiDiDriver driver,
    NavigateCommandParameters parameters,
    TimeSpan timeout)
{
    Task<NavigateCommandResult> navigationTask =
        driver.BrowsingContext.NavigateAsync(parameters, timeout);

    try
    {
        return await navigationTask;
    }
    catch (WebDriverBiDiTimeoutException)
    {
        Console.WriteLine($"Navigation timeout after {timeout.TotalSeconds}s");
        return null;
    }
}
// Usage
NavigateCommandResult? result = await NavigateWithTimeoutAsync(
    driver,
    navParams,
    TimeSpan.FromSeconds(30));

if (result == null)
{
    Console.WriteLine("Navigation failed - timeout");
    // Handle timeout
}

When to use each:

Need Use
Standard per-command timeout timeoutOverride parameter on module methods
Return null on timeout instead of throwing Custom wrapper that catches WebDriverBiDiTimeoutException
Different timeout per retry attempt Custom retry loop with timeoutOverride

Element Not Found Handling

Safe Element Location

public async Task<RemoteValue?> FindElementSafelyAsync(
    BiDiDriver driver,
    string contextId,
    string selector,
    TimeSpan timeout)
{
    DateTime endTime = DateTime.Now + timeout;

    while (DateTime.Now < endTime)
    {
        try
        {
            LocateNodesCommandResult result =
                await driver.BrowsingContext.LocateNodesAsync(
                    new LocateNodesCommandParameters(contextId, new CssLocator(selector)));

            if (result.Nodes.Count > 0)
            {
                return result.Nodes[0];
            }
        }
        catch (WebDriverBiDiException ex)
        {
            Console.WriteLine($"Error locating element: {ex.Message}");
        }

        await Task.Delay(100);
    }

    Console.WriteLine($"Element not found after {timeout.TotalSeconds}s: {selector}");
    return null;
}
// Usage
RemoteValue? element = await FindElementSafelyAsync(
    driver,
    contextId,
    "button.submit",
    TimeSpan.FromSeconds(10));

if (element == null)
{
    Console.WriteLine("Submit button not found");
    // Handle missing element
}

Wait for Element Pattern

public async Task<bool> WaitForElementAsync(
    BiDiDriver driver,
    string contextId,
    string selector,
    TimeSpan timeout)
{
    string waitScript = $$"""
        new Promise((resolve) => {
            const checkElement = () => {
                const element = document.querySelector('{{selector}}');
                if (element) {
                    resolve(true);
                } else {
                    setTimeout(checkElement, 100);
                }
            };
            checkElement();
            setTimeout(() => resolve(false), {{timeout.TotalMilliseconds}});
        })
        """;

    try
    {
        EvaluateResult result = await driver.Script.EvaluateAsync(
            new EvaluateCommandParameters(
                waitScript,
                new ContextTarget(contextId),
                true));

        if (result is EvaluateResultSuccess success &&
            success.Result is BooleanRemoteValue boolValue)
        {
            return boolValue.Value;
        }
    }
    catch (WebDriverBiDiException ex)
    {
        Console.WriteLine($"Error waiting for element: {ex.Message}");
    }

    return false;
}

Event Handler Errors

Observable Event Handler Options

Event handlers can be configured with ObservableEventHandlerOptions to control execution behavior:

// The default: the reader task waits for the handler, so the next event is not dispatched until it
// returns, and a command sent from here cannot be answered while it runs.
driver.Log.OnEntryAdded.AddObserver(
    (e) => Console.WriteLine($"Log entry: {e.Text}"),
    ObservableEventHandlerOptions.RunHandlerSynchronously);

// Queued to the thread pool: the reader task moves on at once, so a handler that performs I/O, or
// drives the driver, does not hold up the events behind it.
driver.Log.OnEntryAdded.AddObserver(
    async (e) => await ArchiveLogEntryAsync(e),
    ObservableEventHandlerOptions.RunHandlerAsynchronously);
public enum ObservableEventHandlerOptions
{
    RunHandlerSynchronously = 0,  // Synchronous execution (default)
    RunHandlerAsynchronously = 1   // Asynchronous execution
}

Synchronous vs. Asynchronous Handlers

By default (RunHandlerSynchronously), event handlers run synchronously on the transport thread, which blocks message processing:

// ❌ Bad: Synchronous handler blocks transport thread
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    // This runs on the transport thread, blocking it
    Thread.Sleep(1000);  // Blocks ALL message processing for 1 second!
    ProcessLogEntry(e);
});

// ❌ Also bad: Default (RunHandlerSynchronously) still blocks
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    ProcessLogEntry(e);  // Blocks transport thread until complete
}, ObservableEventHandlerOptions.RunHandlerSynchronously);

// ✅ Good: Asynchronous handler doesn't block transport
driver.Log.OnEntryAdded.AddObserver(
    async (e) =>
    {
        // This runs on a Task pool thread
        await Task.Delay(1000);  // Doesn't block transport thread
        await ProcessLogEntryAsync(e);
    },
    ObservableEventHandlerOptions.RunHandlerAsynchronously
);

When to Use Asynchronous Handlers:

  • Handler performs I/O operations (file, network, database)
  • Handler does CPU-intensive work
  • Handler calls async APIs
  • You want to avoid blocking message processing
  • Recommended for most scenarios where handler does more than trivial work

Exception Handling in Event Handlers

Event handler exceptions are subject to the TransportErrorBehavior setting. Unhandled exceptions depend on the mode (Terminate/Collect/Ignore). Handle exceptions within the handler for best results:

// Unhandled exception - behavior depends on TransportErrorBehavior
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    ProcessLogEntry(e);  // May throw
    // If throws:
    //   Terminate mode: Exception thrown on next command
    //   Collect mode: Exception stored by the transport and thrown from StopAsync()
    //   Ignore mode: Exception discarded
});

// ✅ Better: Handle exceptions within the handler
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    try
    {
        ProcessLogEntry(e);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error processing log entry: {ex.Message}");
        // Error handled, won't be reported to TransportErrorBehavior
    }
});

// ✅ Best: Async handler with error handling
driver.Network.OnBeforeRequestSent.AddObserver(
    async (e) =>
    {
        try
        {
            await ProcessNetworkRequestAsync(e);
        }
        catch (Exception ex)
        {
            await LogErrorAsync($"Error processing request: {ex.Message}");
            // Error handled within handler
        }
    },
    ObservableEventHandlerOptions.RunHandlerAsynchronously
);

Handler Execution Behavior

BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));
await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

// Synchronous handler (default)
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    // Runs on transport thread
    // Blocks transport until complete
    // Other messages wait for this to finish
    ProcessLogEntry(e);
});

// Explicit synchronous
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    ProcessLogEntry(e);
}, ObservableEventHandlerOptions.RunHandlerSynchronously);

// Asynchronous handler
driver.Network.OnResponseCompleted.AddObserver(
    async (e) =>
    {
        // Starts on transport thread, continues on task pool
        // Transport thread returns immediately
        // Other messages processed concurrently
        await AnalyzeResponseAsync(e);
    },
    ObservableEventHandlerOptions.RunHandlerAsynchronously
);

Multiple Handlers for Same Event

When multiple handlers are registered, they all execute in sequence. With synchronous handlers, Handler 1 executes completely, then Handler 2. With asynchronous handlers, both start and run concurrently:

// Handler 1 (synchronous)
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    Console.WriteLine($"Handler 1: {e.Text}");
});

// Handler 2 (synchronous)
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    Console.WriteLine($"Handler 2: {e.Text}");
});

// With synchronous handlers:
// - Handler 1 executes completely, then Handler 2 executes
// - If Handler 1 throws, TransportErrorBehavior determines what happens
// - With Terminate: Exception thrown on next command
// - With Collect: Exception collected, Handler 2 still executes
// - With Ignore: Exception ignored, Handler 2 still executes
// Handler 1 (asynchronous)
driver.Network.OnBeforeRequestSent.AddObserver(
    async (e) =>
    {
        await LogRequestAsync(e.Request.Url);
    },
    ObservableEventHandlerOptions.RunHandlerAsynchronously
);

// Handler 2 (asynchronous)
driver.Network.OnBeforeRequestSent.AddObserver(
    async (e) =>
    {
        await AnalyzeSecurityHeadersAsync(e.Request);
    },
    ObservableEventHandlerOptions.RunHandlerAsynchronously
);

// With asynchronous handlers:
// - Both handlers start and run concurrently
// - Transport thread doesn't wait for either to complete
// - Exceptions handled according to TransportErrorBehavior

Complete Event Handler Pattern

// Use Collect mode to see all handler errors
WebSocketConnection connection = new WebSocketConnection();
Transport transport = new Transport(connection)
{
    EventHandlerExceptionBehavior = TransportErrorBehavior.Collect,
    ProtocolErrorBehavior = TransportErrorBehavior.Collect,
    UnknownMessageBehavior = TransportErrorBehavior.Collect,
    UnexpectedErrorBehavior = TransportErrorBehavior.Collect,
};
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30), transport);

await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

try
{
    // Synchronous handler for quick operations
    driver.Log.OnEntryAdded.AddObserver((e) =>
    {
        try
        {
            // Quick, synchronous operation
            if (e.Level == LogLevel.Error || e.Level == LogLevel.Warn)
            {
                Console.WriteLine($"[{e.Level}] {e.Text}");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error in log handler: {ex.Message}");
        }
    });

    // Asynchronous handler for I/O operations
    driver.Network.OnResponseCompleted.AddObserver(
        async (e) =>
        {
            try
            {
                // Async operation doesn't block transport
                await SaveResponseToFileAsync(e.Response);
            }
            catch (Exception ex)
            {
                await LogErrorAsync($"Failed to save response: {ex.Message}");
            }
        },
        ObservableEventHandlerOptions.RunHandlerAsynchronously
    );

    await driver.Session.SubscribeAsync(subscribeParams);

    // ... perform the automation ...

    // Collected errors are thrown only by StopAsync; DisposeAsync alone would
    // log and discard them, so stop explicitly before the finally disposes.
    await driver.StopAsync();
}
catch (AggregateException ex)
{
    // After operations, check for any unhandled handler errors
    if (ex.InnerExceptions.Count > 0)
    {
        Console.WriteLine($"Transport errors occurred: {ex.InnerExceptions.Count}");
        foreach (var error in ex.InnerExceptions)
        {
            Console.WriteLine($"  - {error.Message}");
        }
    }
}
finally
{
    await driver.DisposeAsync();
}

Handler Options Decision Guide

Handler Does Use Option Why
Quick in-memory work (<10ms) RunHandlerSynchronously (default) Minimal overhead, acceptable blocking
File I/O RunHandlerAsynchronously Don't block transport on disk operations
Network requests RunHandlerAsynchronously Don't block transport on network
Database queries RunHandlerAsynchronously Don't block transport on DB operations
CPU-intensive work RunHandlerAsynchronously Don't block transport thread
Logging to console RunHandlerSynchronously Quick operation, synchronous is fine
Updating counters/state RunHandlerSynchronously Quick operation, synchronous is fine

Best Practices for Event Handlers

  1. Use async handlers for I/O: Prevent blocking the transport thread
  2. Handle exceptions internally: Use try-catch within handlers when possible
  3. Keep handlers fast: Even async handlers should complete quickly
  4. Don't perform long operations: Offload heavy work to background services
  5. Test handler error paths: Verify your error handling works correctly
  6. Monitor handler performance: Track execution time to identify bottlenecks

Validation and Defensive Programming

Parameter Validation

public async Task<NavigateCommandResult> SafeNavigateAsync(
    BiDiDriver driver,
    string contextId,
    string url)
{
    // Validate inputs
    if (driver == null)
    {
        throw new ArgumentNullException(nameof(driver));
    }

    if (string.IsNullOrWhiteSpace(contextId))
    {
        throw new ArgumentException("Context ID cannot be empty", nameof(contextId));
    }

    if (string.IsNullOrWhiteSpace(url))
    {
        throw new ArgumentException("URL cannot be empty", nameof(url));
    }

    if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri))
    {
        throw new ArgumentException("Invalid URL format", nameof(url));
    }

    // Execute with error handling
    try
    {
        NavigateCommandParameters navParams = new NavigateCommandParameters(contextId, url)
        {
            Wait = ReadinessState.Complete
        };

        return await driver.BrowsingContext.NavigateAsync(navParams);
    }
    catch (WebDriverBiDiException ex)
    {
        throw new InvalidOperationException(
            $"Failed to navigate to {url}: {ex.Message}",
            ex);
    }
}

Context Validation

public async Task<bool> IsContextValidAsync(BiDiDriver driver, string contextId)
{
    try
    {
        // Use RootBrowsingContextId to fetch only this context (more efficient than full tree)
        GetTreeCommandResult tree = await driver.BrowsingContext.GetTreeAsync(
            new GetTreeCommandParameters { RootBrowsingContextId = contextId });

        return tree.ContextTree.Count > 0;
    }
    catch (WebDriverBiDiException)
    {
        return false;
    }
}
// Usage
if (!await IsContextValidAsync(driver, contextId))
{
    Console.WriteLine("Context no longer exists");
    // Handle invalid context
}

Logging and Diagnostics

Comprehensive Logging

public class DiagnosticDriver
{
    private readonly BiDiDriver driver;
    private readonly ILogger logger;

    public async Task<T> ExecuteWithLoggingAsync<T>(
        string operationName,
        Func<Task<T>> operation)
    {
        logger.LogInformation($"Starting {operationName}");
        DateTime startTime = DateTime.Now;

        try
        {
            T result = await operation();
            TimeSpan duration = DateTime.Now - startTime;

            logger.LogInformation(
                $"Completed {operationName} in {duration.TotalMilliseconds}ms");

            return result;
        }
        catch (WebDriverBiDiException ex)
        {
            TimeSpan duration = DateTime.Now - startTime;

            logger.LogError(
                ex,
                $"Failed {operationName} after {duration.TotalMilliseconds}ms: {ex.Message}");

            throw;
        }
    }
}

Error Context Capture

public class ErrorContext
{
    public string Operation { get; set; }
    public DateTime Timestamp { get; set; }
    public string? ContextId { get; set; }
    public string? Url { get; set; }
    public Exception Exception { get; set; }

    public void SaveToFile()
    {
        string fileName = $"error-{Timestamp:yyyyMMdd-HHmmss}.log";

        StringBuilder sb = new StringBuilder();
        sb.AppendLine($"Operation: {Operation}");
        sb.AppendLine($"Timestamp: {Timestamp:yyyy-MM-dd HH:mm:ss.fff}");
        sb.AppendLine($"Context ID: {ContextId ?? "N/A"}");
        sb.AppendLine($"URL: {Url ?? "N/A"}");
        sb.AppendLine($"\nException: {Exception.Message}");
        sb.AppendLine($"\nStack Trace:\n{Exception.StackTrace}");

        if (Exception.InnerException != null)
        {
            sb.AppendLine($"\nInner Exception: {Exception.InnerException.Message}");
        }

        File.WriteAllText(fileName, sb.ToString());
    }
}
// Usage
try
{
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (Exception ex)
{
    ErrorContext context = new ErrorContext
    {
        Operation = "Navigation",
        Timestamp = DateTime.Now,
        ContextId = contextId,
        Url = navParams.Url,
        Exception = ex
    };

    context.SaveToFile();
    throw;
}

Recovery Strategies

Graceful Degradation

public async Task<string> GetPageTitleAsync(BiDiDriver driver, string contextId)
{
    // Try JavaScript first
    try
    {
        EvaluateResult result = await driver.Script.EvaluateAsync(
            new EvaluateCommandParameters(
                "document.title",
                new ContextTarget(contextId),
                true));

        if (result is EvaluateResultSuccess success &&
            success.Result is StringRemoteValue stringValue)
        {
            return stringValue.Value ?? "Unknown";
        }
    }
    catch (WebDriverBiDiException ex)
    {
        Console.WriteLine($"JavaScript method failed: {ex.Message}");
    }

    // Fallback to context info
    try
    {
        GetTreeCommandResult tree = await driver.BrowsingContext.GetTreeAsync(
            new GetTreeCommandParameters { RootBrowsingContextId = contextId });

        if (tree.ContextTree.Count > 0)
        {
            return tree.ContextTree[0].Url;
        }
    }
    catch (WebDriverBiDiException ex)
    {
        Console.WriteLine($"Context method failed: {ex.Message}");
    }

    return "Unknown";
}

Circuit Breaker Pattern

public class CircuitBreaker
{
    private int failureCount = 0;
    private readonly int threshold = 5;
    private readonly TimeSpan resetTimeout = TimeSpan.FromMinutes(1);
    private DateTime? openedAt = null;
    private bool isOpen = false;

    public async Task<T> ExecuteAsync<T>(Func<Task<T>> operation)
    {
        if (isOpen)
        {
            if (openedAt.HasValue &&
                DateTime.Now - openedAt.Value > resetTimeout)
            {
                // Try to reset
                isOpen = false;
                failureCount = 0;
                openedAt = null;
            }
            else
            {
                throw new InvalidOperationException(
                    "Circuit breaker is open - too many failures");
            }
        }

        try
        {
            T result = await operation();

            // Success - reset counter
            if (failureCount > 0)
            {
                failureCount = 0;
            }

            return result;
        }
        catch (Exception)
        {
            failureCount++;

            if (failureCount >= threshold)
            {
                isOpen = true;
                openedAt = DateTime.Now;
            }

            throw;
        }
    }
}
// Usage
CircuitBreaker breaker = new CircuitBreaker();

try
{
    NavigateCommandResult result = await breaker.ExecuteAsync(
        async () => await driver.BrowsingContext.NavigateAsync(navParams));
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Circuit breaker"))
{
    Console.WriteLine("Too many failures - circuit breaker activated");
    // Handle circuit breaker state
}

Testing Error Scenarios

Simulating Errors

[Test]
public async Task TestNavigationTimeout()
{
    BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(1));

    try
    {
        await driver.StartAsync(webSocketUrl);

        // This should timeout
        NavigateCommandParameters parameters = new NavigateCommandParameters(
            contextId,
            "https://httpstat.us/200?sleep=5000");  // 5 second delay

        await driver.BrowsingContext.NavigateAsync(parameters);

        Assert.Fail("Expected timeout exception");
    }
    catch (WebDriverBiDiTimeoutException ex)
    {
        Assert.That(ex.Message, Does.Contain("Timed out"));
    }
}
[Test]
public async Task TestInvalidContext()
{
    BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(1));

    try
    {
        await driver.StartAsync(webSocketUrl);

        // Use invalid context ID
        NavigateCommandParameters parameters = new NavigateCommandParameters(
            "invalid-context-id",
            "https://example.com");

        await driver.BrowsingContext.NavigateAsync(parameters);

        Assert.Fail("Expected context error");
    }
    catch (WebDriverBiDiException ex)
    {
        Assert.That(ex.Message, Does.Contain("no such frame").IgnoreCase);
    }
}

Best Practices

  1. Always handle WebDriverBiDiException: Protocol errors should be caught and handled
  2. Check script result types: Don't assume success, check for exceptions
  3. Validate inputs: Check parameters before sending commands
  4. Log errors comprehensively: Include context for debugging
  5. Implement retries: Handle transient failures with retry logic
  6. Use timeouts: Always specify appropriate timeouts
  7. Clean up resources: Use try-finally or using statements
  8. Test error paths: Write tests for failure scenarios
  9. Provide fallbacks: Implement graceful degradation where possible
  10. Monitor health: Track error rates and patterns

Common Pitfalls

Don't Swallow Exceptions

// ❌ Bad: Silent failure
try
{
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch
{
    // Error ignored
}

// ✅ Good: Proper handling
try
{
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (WebDriverBiDiException ex)
{
    logger.LogError(ex, "Navigation failed");
    throw; // or handle appropriately
}

Don't Use Generic Catch

// ❌ Bad: Too broad
try
{
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (Exception ex)
{
    // Catches everything, including bugs
}

// ✅ Good: Specific handling
try
{
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (WebDriverBiDiException ex)
{
    // Handle protocol errors
}
catch (TaskCanceledException ex)
{
    // Handle cancellation
}

Troubleshooting and FAQ

Common Error Messages

Error Message Likely Cause What to Do
"Transport must be connected to a remote end to execute commands" Commands sent before StartAsync or after disconnect Ensure StartAsync has completed before sending commands. Check IsStarted before operations.
"no such frame" / "no such window" Browsing context was closed or no longer exists Verify the context ID is still valid. Use GetTreeAsync to refresh context list.
"Timed out executing command" Command exceeded the timeout Increase timeoutOverride for the command, or construct the driver with a larger DefaultCommandTimeout (it is set via the BiDiDriver constructor).
"Cannot register a type info resolver after the transport is connected" RegisterTypeInfoResolverAsync called after StartAsync Register type resolvers before calling StartAsync.
"This observable event only allows N observer(s)" Too many observers added to an event with MaxObserverCount Remove observers with Unobserve() or Dispose() before adding new ones.
"This observable event only allows 1 observer" on OnDataReceived OnDataReceived transfers ownership of a pooled buffer, so it admits only the Transport Do not observe OnDataReceived. To inspect traffic, set LogLevel to Trace and observe OnLogMessage. See Connection Management.
"The provided connection already has a listener for its OnDataReceived event" A Transport was constructed over a connection whose OnDataReceived was already observed Remove the observer; the Transport requires exclusive use of that event.

Connection Diagnostics

When the connection fails or behaves unexpectedly:

  1. Verify the WebSocket URL: Ensure the URL is a WebDriver BiDi endpoint — the webSocketUrl from a driver session (e.g., ws://localhost:9515/session/...) or Firefox's /session — not Chrome's CDP /devtools/browser/... URL, which accepts the connection but fails every command.
  2. Check browser is running: The remote end must be listening before you connect.
  3. Use connection events: Subscribe to connection.OnConnectionError and connection.OnLogMessage for real-time diagnostics, and to driver.OnConnectionLost to learn when the browser has closed the connection.
  4. Inspect UnhandledErrors: Transport.UnhandledErrors is protected, so it is readable only from within your own Transport subclass (see Custom Modules — Pending commands and unhandled errors for its members); from application code, observe collected errors through the AggregateException thrown by StopAsync() (when using TransportErrorBehavior.Collect).

Interpreting TransportErrorBehavior

Mode When to Use Trade-off
Ignore (default) Protocol evolution, backward compatibility, event handlers that should not stop automation Errors may go unnoticed. Use for production when you handle errors in handlers.
Collect Diagnostics, gathering all errors for post-mortem analysis Errors surface only when you stop the driver. Operation continues despite errors.
Terminate Development, strict protocol conformance, fail-fast behavior First error stops the driver on the next command. Best for catching issues early.

Event Handlers Not Firing

If events are not received:

  1. Add observer before subscribing: Call AddObserver before Session.SubscribeAsync.
  2. Subscribe to the event: Adding an observer alone is not enough—you must call Session.SubscribeAsync with the event names.
  3. Use correct event names: Prefer driver.Log.OnEntryAdded.EventName over string literals to avoid typos.
  4. Check context filtering: If you passed Contexts or UserContexts to SubscribeCommandParameters, events are limited to those contexts.

See Also

Next Steps