# Browser Hybrid — Reliability & Stability Plan

**Goal**: Make browser-hybrid robust enough for production use (scripted automation, not human-in-the-loop debugging).

---

## Current State (v0.1.0)

### What Works
- Basic CDP operations via HTTP/WebSocket
- Tab management (list, new, activate, close)
- Accessibility tree parsing
- Click/type/form fill
- Screenshot, HTML, JS evaluation

### Reliability Issues

| Issue | Severity | Impact |
|-------|----------|--------|
| No retry logic | High | Transient network failures crash scripts |
| New WebSocket per operation | High | Slow (10-50ms overhead each), connection churn |
| No per-operation timeout | High | Hung operations block forever |
| `_message_id` not thread-safe | Medium | Race conditions in concurrent use |
| `_run_async` = new event loop | Medium | Inefficient, breaks in some contexts |
| Vague error messages | Medium | Hard to debug failures |
| No connection health check | Low | Can't detect stale Chrome sessions |
| No graceful degradation | Low | All-or-nothing failure mode |

---

## Implementation Plan

### Phase 1: Connection Management (Foundation)

**Goal**: Single persistent WebSocket with health monitoring.

#### 1.1 WebSocket Connection Pool

```python
class Connection:
    """Manages a single WebSocket connection to a tab."""
    
    def __init__(self, ws_url: str, timeout: float = 10.0):
        self.ws_url = ws_url
        self.timeout = timeout
        self._ws: Optional[websockets.WebSocketClientProtocol] = None
        self._lock = asyncio.Lock()
        self._message_id = 0
        self._pending: Dict[int, asyncio.Future] = {}
    
    async def connect(self) -> None:
        """Establish WebSocket connection."""
        ...
    
    async def disconnect(self) -> None:
        """Close WebSocket connection."""
        ...
    
    async def send(self, method: str, params: Optional[Dict] = None) -> Dict:
        """Send CDP command, await response."""
        ...
    
    def is_connected(self) -> bool:
        """Check if WebSocket is open and healthy."""
        ...
```

#### 1.2 Browser-Level Connection Cache

```python
class Browser:
    def __init__(self, ...):
        self._connections: Dict[str, Connection] = {}  # tab_id -> Connection
    
    def _get_connection(self, tab: Tab) -> Connection:
        """Get or create connection for tab."""
        if tab.id not in self._connections:
            self._connections[tab.id] = Connection(tab.web_socket_debugger_url)
        return self._connections[tab.id]
```

**Tests:**
- Connection reuse across multiple operations
- Connection cleanup on tab close
- Reconnection on WebSocket failure

---

### Phase 2: Timeout & Retry Logic (Resilience)

**Goal**: Handle transient failures gracefully.

#### 2.1 Per-Operation Timeout

```python
class TimeoutError(BrowserError):
    """Operation timed out."""
    def __init__(self, message: str, timeout: float, operation: str):
        super().__init__(f"{message} (timeout={timeout}s, operation={operation})")
        self.timeout = timeout
        self.operation = operation
```

```python
async def send_with_timeout(self, method: str, params: Dict, timeout: float) -> Dict:
    """Send CDP command with explicit timeout."""
    try:
        return await asyncio.wait_for(
            self.send(method, params),
            timeout=timeout
        )
    except asyncio.TimeoutError:
        raise TimeoutError(
            f"CDP command timed out: {method}",
            timeout=timeout,
            operation=method
        )
```

#### 2.2 Retry Logic with Exponential Backoff

```python
async def send_with_retry(
    self,
    method: str,
    params: Optional[Dict] = None,
    max_retries: int = 3,
    base_delay: float = 0.1,
) -> Dict:
    """Send CDP command with retry on transient failures."""
    last_error = None
    
    for attempt in range(max_retries):
        try:
            return await self.send_with_timeout(method, params, self.timeout)
        except (ConnectionError, TimeoutError) as e:
            last_error = e
            if attempt < max_retries - 1:
                delay = base_delay * (2 ** attempt)
                await asyncio.sleep(delay)
                await self.reconnect()
    
    raise last_error
```

**Tests:**
- Timeout raises `TimeoutError` with context
- Retry succeeds after transient failure
- Retry gives up after max attempts

---

### Phase 3: Thread Safety (Concurrency)

**Goal**: Safe concurrent use from multiple threads.

#### 3.1 Thread-Safe Message ID

```python
import threading

class Connection:
    def __init__(self, ...):
        self._message_id = 0
        self._id_lock = threading.Lock()
    
    def _next_id(self) -> int:
        with self._id_lock:
            self._message_id += 1
            return self._message_id
```

#### 3.2 Thread-Safe Run Async

```python
class Browser:
    def __init__(self, ...):
        self._loop: Optional[asyncio.AbstractEventLoop] = None
        self._loop_lock = threading.Lock()
    
    def _get_loop(self) -> asyncio.AbstractEventLoop:
        """Get or create shared event loop."""
        with self._loop_lock:
            if self._loop is None or self._loop.is_closed():
                self._loop = asyncio.new_event_loop()
            return self._loop
    
    def _run_async(self, coro):
        """Run coroutine on shared loop."""
        loop = self._get_loop()
        if threading.current_thread() is threading.main_thread():
            return loop.run_until_complete(coro)
        else:
            future = asyncio.run_coroutine_threadsafe(coro, loop)
            return future.result()
```

**Tests:**
- Concurrent operations from multiple threads
- Message IDs are unique across threads
- Event loop is reused

---

### Phase 4: Error Messages (Debuggability)

**Goal**: Actionable error messages.

#### 4.1 Enriched Exceptions

```python
class BrowserError(Exception):
    """Base exception with context."""
    def __init__(self, message: str, context: Optional[Dict] = None):
        self.context = context or {}
        super().__init__(message)
    
    def __str__(self):
        base = super().__str__()
        if self.context:
            return f"{base}\n  Context: {self.context}"
        return base

class ConnectionError(BrowserError):
    """Chrome not reachable."""
    def __init__(self, host: str, port: int, original_error: Optional[Exception] = None):
        super().__init__(
            f"Chrome DevTools not reachable at {host}:{port}",
            context={"host": host, "port": port, "original": str(original_error) if original_error else None}
        )
        self.host = host
        self.port = port

class TabError(BrowserError):
    """Tab operation failed."""
    def __init__(self, message: str, tab_id: Optional[str] = None, operation: Optional[str] = None):
        super().__init__(
            message,
            context={"tab_id": tab_id, "operation": operation}
        )
```

#### 4.2 CDP Error Parsing

```python
def _parse_cdp_error(response: Dict) -> Optional[BrowserError]:
    """Parse CDP error response into specific exception."""
    if "error" not in response:
        return None
    
    error = response["error"]
    code = error.get("code")
    message = error.get("message", "Unknown CDP error")
    
    # Map CDP error codes to exceptions
    if code == -32000:  # Invalid node
        return TabError(f"Invalid node: {message}")
    elif code == -32602:  # Invalid params
        return BrowserError(f"Invalid parameters: {message}")
    
    return BrowserError(f"CDP error {code}: {message}")
```

**Tests:**
- `ConnectionError` includes host/port
- `TabError` includes tab_id and operation
- CDP errors parsed correctly

---

### Phase 5: Connection Health (Monitoring)

**Goal**: Detect stale connections before use.

#### 5.1 Health Check Method

```python
class Connection:
    async def health_check(self) -> bool:
        """Verify WebSocket is responsive."""
        try:
            result = await self.send_with_timeout("Target.getTargetInfo", {}, timeout=2.0)
            return "result" in result
        except (ConnectionError, TimeoutError):
            return False
    
    async def ensure_connected(self) -> None:
        """Ensure connection is healthy, reconnect if needed."""
        if self._ws is None or not self.is_connected():
            await self.connect()
        
        if not await self.health_check():
            await self.reconnect()
```

#### 5.2 Browser-Level Health

```python
class Browser:
    def is_connected(self) -> bool:
        """Check if Chrome is reachable."""
        return self._check_connection()
    
    def wait_for_chrome(self, timeout: float = 30.0) -> bool:
        """Wait for Chrome to become available."""
        start = time.time()
        while time.time() - start < timeout:
            if self._check_connection():
                return True
            time.sleep(0.5)
        return False
```

**Tests:**
- Health check succeeds on good connection
- Health check fails on closed WebSocket
- Reconnection restores health

---

## File Changes

### New Files

| File | Purpose |
|------|---------|
| `src/browser_hybrid/connection.py` | Connection pool management |
| `src/browser_hybrid/errors.py` | Error classes and CDP error parsing |
| `src/browser_hybrid/utils.py` | Retry logic, timeouts, thread safety |

### Modified Files

| File | Changes |
|------|---------|
| `src/browser_hybrid/browser.py` | Use Connection class, add health methods |
| `src/browser_hybrid/models.py` | Add `Tab.is_active`, error context helpers |
| `tests/test_browser.py` | Add reliability tests |
| `tests/test_connection.py` | New: connection pool tests |
| `tests/test_errors.py` | New: error handling tests |

---

## Migration Path

### Backwards Compatible

All existing public APIs remain unchanged. New optional parameters:

```python
# Existing
browser.navigate(url)

# New (optional params)
browser.navigate(url, timeout=30.0, retry=True)
browser.screenshot(path, timeout=60.0)
```

### Deprecation Warnings

None in v0.2.0 — all changes are additive.

---

## Test Strategy

### Unit Tests (Mocked)

```python
def test_retry_on_transient_failure(mocker):
    """Retry should succeed after transient failure."""
    
def test_timeout_raises_timeout_error():
    """Timeout should raise TimeoutError with context."""
    
def test_connection_pool_reuse():
    """Multiple operations should reuse same WebSocket."""
    
def test_thread_safe_message_id():
    """Concurrent sends should have unique IDs."""
```

### Integration Tests (Require Chrome)

```python
@pytest.mark.integration
def test_real_connection_health():
    """Health check on real Chrome."""
    
@pytest.mark.integration
def test_real_reconnection_after_close():
    """Recover from WebSocket close."""
```

---

## Success Criteria

- [ ] All existing tests pass
- [ ] Integration tests pass with Chrome debug mode
- [ ] Connection reuse reduces latency by >50%
- [ ] Transient failures retried successfully
- [ ] Timeouts raise `TimeoutError` with context
- [ ] Error messages include actionable context
- [ ] Thread-safe for concurrent use
- [ ] Health check detects stale connections

---

## Estimated Effort

| Phase | Effort | Priority |
|-------|--------|----------|
| Phase 1: Connection Management | 4-6 hours | Critical |
| Phase 2: Timeout & Retry | 2-3 hours | Critical |
| Phase 3: Thread Safety | 2-3 hours | High |
| Phase 4: Error Messages | 1-2 hours | Medium |
| Phase 5: Connection Health | 1-2 hours | Medium |

**Total**: 10-16 hours

---

## Next Step

Start with **Phase 1** — connection management is the foundation for everything else.