# 401 Auto-Refresh Implementation Plan (Revised after DCR)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** When any API call gets a 401, automatically try refreshing the primary token before giving up. Extract shared token exchange logic to eliminate 3 copies of the same HTTP POST. Add circuit breaker to prevent amplification on dead accounts.

**Architecture:** Three structural changes: (1) Extract `_exchange_refresh_token` shared helper that all token refresh paths use. (2) Add `_try_refresh_primary_token` that uses per-account locking, stale-token detection, and distinguishes `invalid_grant` from transient failures. (3) Apply a single consistent "401 → refresh → retry" pattern across all API callers with a circuit breaker for dead refresh tokens.

**Tech Stack:** Python, httpx, asyncio, pytest

---

## DCR Findings Addressed

| Finding | Fix |
|---|---|
| 4x duplicated token exchange | Task 1: `_exchange_refresh_token` shared helper |
| No lock on refresh | Task 2: uses `_get_refresh_lock(account_id)` |
| No distinction `invalid_grant` vs transient | Task 2: returns `(token, error_type)` tuple |
| No circuit breaker | Task 2: stores `_refresh_failed_at` per-account, 10-min cooldown |
| 3 contradictory Task 2 approaches | Single recursive approach with `_refresh_depth` guard (unified with existing `_retry_depth`) |
| `fetch_profile` underspecified | Task 4: complete code with recursion guard |
| Metadata extraction placeholder | Task 4: extract `_update_profile_metadata` helper |
| Inconsistent retry patterns | All callers use the same recursive pattern |

## File Structure

| File | Responsibility |
|------|---------------|
| `jacked/web/auth.py` | `_exchange_refresh_token` shared helper, `_try_refresh_primary_token` with locking + circuit breaker, update all 401 handlers |
| `jacked/api/usage_monitor.py` | Update `_fetch_candidate_usage` to skip invalid accounts |

---

### Task 1: Extract `_exchange_refresh_token` shared helper

**Files:**
- Modify: `jacked/web/auth.py`

The same HTTP POST to TOKEN_URL exists in `refresh_cc_token`, `refresh_account_token`, and `_try_refresh_on_429`. Extract the common logic.

- [ ] **Step 1: Add the shared helper**

After the TOKEN_URL / CLIENT_ID constants (near the top of auth.py), add:

```python
async def _exchange_refresh_token(
    refresh_token: str,
    timeout: float = 15.0,
) -> dict | None:
    """Exchange a refresh token for new tokens via Anthropic's OAuth endpoint.

    Returns dict with 'access_token', 'refresh_token', 'expires_in' on success.
    Returns None on any failure (caller should check HTTP status via the
    response object if they need to distinguish error types).

    This is the SINGLE implementation of the token exchange POST.
    All callers (refresh_cc_token, refresh_account_token,
    _try_refresh_on_429, _try_refresh_primary_token) should use this.
    """
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            resp = await client.post(
                TOKEN_URL,
                json={
                    "grant_type": "refresh_token",
                    "refresh_token": refresh_token,
                    "client_id": CLIENT_ID,
                },
                headers={
                    "Content-Type": "application/json",
                    "anthropic-beta": OAUTH_BETA_HEADER,
                },
            )
        if resp.status_code == 200:
            return resp.json()
        # Return the response for caller to inspect status code
        return None
    except Exception:
        return None
```

Wait — callers need to know the status code to distinguish `invalid_grant` from transient failures. Better approach: return the response object or a richer result.

```python
from dataclasses import dataclass

@dataclass
class TokenExchangeResult:
    success: bool
    access_token: str | None = None
    refresh_token: str | None = None
    expires_in: int | None = None
    error: str | None = None       # "invalid_grant", "server_error", "network_error"
    status_code: int | None = None


async def _exchange_refresh_token(
    refresh_token: str,
    timeout: float = 15.0,
) -> TokenExchangeResult:
    """Exchange a refresh token for new tokens via Anthropic's OAuth endpoint.

    Single implementation of the token exchange POST. All refresh paths use this.
    """
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            resp = await client.post(
                TOKEN_URL,
                json={
                    "grant_type": "refresh_token",
                    "refresh_token": refresh_token,
                    "client_id": CLIENT_ID,
                },
                headers={
                    "Content-Type": "application/json",
                    "anthropic-beta": OAUTH_BETA_HEADER,
                },
            )

        if resp.status_code == 200:
            data = resp.json()
            return TokenExchangeResult(
                success=True,
                access_token=data["access_token"],
                refresh_token=data.get("refresh_token", refresh_token),
                expires_in=data.get("expires_in", DEFAULT_TOKEN_TTL_SECONDS),
            )

        # Parse error type
        error = "unknown"
        try:
            error = resp.json().get("error", f"http_{resp.status_code}")
        except Exception:
            error = f"http_{resp.status_code}"

        return TokenExchangeResult(
            success=False,
            error=error,
            status_code=resp.status_code,
        )

    except Exception as exc:
        return TokenExchangeResult(
            success=False,
            error="network_error",
        )
```

- [ ] **Step 2: Refactor `refresh_account_token` to use the shared helper**

Find the HTTP POST block in `refresh_account_token` (around lines 308-327). Replace with:

```python
        result = await _exchange_refresh_token(account["refresh_token"])
        if result.success:
            db.update_account(
                account_id,
                access_token=result.access_token,
                refresh_token=result.refresh_token,
                expires_at=int(time.time()) + result.expires_in,
            )
            db.clear_account_errors(account_id)
            # Also refresh profile metadata
            await fetch_profile(account_id, db, access_token=result.access_token)
            return True

        if result.error == "invalid_grant":
            # ... existing invalid_grant handling ...
```

Do the same for `refresh_cc_token` and `_try_refresh_on_429` — replace the inline POST with `_exchange_refresh_token` call. Keep each function's unique behavior (which DB fields to update, whether to sync credential stores, lock handling).

- [ ] **Step 3: Run tests**

Run: `uv run python -m pytest tests/ -q`

- [ ] **Step 4: Commit**

```bash
git add jacked/web/auth.py
git commit -m "refactor: extract _exchange_refresh_token shared helper — eliminates 3x duplicate POST"
```

---

### Task 2: Add `_try_refresh_primary_token` with locking + circuit breaker

**Files:**
- Modify: `jacked/web/auth.py`

- [ ] **Step 1: Add per-account refresh state for circuit breaker**

Near the existing `_account_usage_state` dict, add:

```python
_primary_refresh_state: dict[int, dict] = {}

def _get_primary_refresh_state(account_id: int) -> dict:
    if account_id not in _primary_refresh_state:
        _primary_refresh_state[account_id] = {
            "last_failed_at": 0.0,
            "dead": False,  # True if invalid_grant received
        }
    return _primary_refresh_state[account_id]
```

- [ ] **Step 2: Add the helper function**

After `_try_refresh_on_429`, add:

```python
async def _try_refresh_primary_token(
    account_id: int,
    db,
    stale_token: str | None = None,
) -> str | None:
    """Refresh the primary access token on 401. Returns new token or None.

    Uses per-account asyncio lock to prevent concurrent refresh races.
    Checks circuit breaker (10-min cooldown after failure, permanent
    skip after invalid_grant). Compares stale_token against current DB
    to detect if another coroutine already refreshed.

    Does NOT write to credential stores — primary tokens are for
    jacked's own API calls, not for Claude Code.
    """
    _COOLDOWN_SECONDS = 600  # 10 minutes

    state = _get_primary_refresh_state(account_id)

    # Circuit breaker: skip if refresh token is known dead
    if state["dead"]:
        return None

    # Circuit breaker: skip if recently failed (transient cooldown)
    if time.time() - state["last_failed_at"] < _COOLDOWN_SECONDS:
        return None

    lock = _get_refresh_lock(account_id)
    async with lock:
        # Re-read account under lock — another coroutine may have refreshed
        account = db.get_account(account_id)
        if not account:
            return None

        # If the DB token changed since the caller's stale copy,
        # someone else already refreshed — use the new token
        current_token = account.get("access_token")
        if stale_token and current_token and current_token != stale_token:
            logger.info(
                "Account %d: primary token already refreshed by another path",
                account_id,
            )
            return current_token

        refresh_token = account.get("refresh_token")
        if not refresh_token:
            return None

        result = await _exchange_refresh_token(refresh_token)

        if result.success:
            db.update_account(
                account_id,
                access_token=result.access_token,
                refresh_token=result.refresh_token,
                expires_at=int(time.time()) + result.expires_in,
            )
            state["last_failed_at"] = 0.0
            state["dead"] = False
            logger.info(
                "Account %d: primary token refreshed%s",
                account_id,
                " (refresh rotated)" if result.refresh_token != refresh_token else "",
            )
            return result.access_token

        # Handle specific error types
        if result.error == "invalid_grant":
            state["dead"] = True
            logger.warning(
                "Account %d: primary refresh token is dead (invalid_grant)",
                account_id,
            )
        else:
            state["last_failed_at"] = time.time()
            logger.warning(
                "Account %d: primary token refresh failed (%s) — will retry in %ds",
                account_id, result.error, _COOLDOWN_SECONDS,
            )

        return None
```

Key features:
- **Per-account lock:** `_get_refresh_lock(account_id)` prevents concurrent refresh races
- **Stale-token check:** Under lock, re-reads DB token and skips exchange if already refreshed
- **Circuit breaker:** `dead=True` on `invalid_grant` (permanent), 10-min cooldown on transient failures
- **Rich error handling:** Distinguishes `invalid_grant` from transient failures

- [ ] **Step 3: Run tests**

Run: `uv run python -m pytest tests/ -q`

- [ ] **Step 4: Commit**

```bash
git add jacked/web/auth.py
git commit -m "feat: _try_refresh_primary_token with locking, stale detection, circuit breaker"
```

---

### Task 3: Update `fetch_usage` 401 handler

**Files:**
- Modify: `jacked/web/auth.py`

- [ ] **Step 1: Unify recursion depth guards**

`fetch_usage` already has `_retry_depth` for 429 retries. Add the 401 refresh to the SAME guard. Change the existing signature:

If `_retry_depth` already exists, reuse it. The guard prevents both 429 retries AND 401 refresh retries from going deeper than 1 level.

- [ ] **Step 2: Update the 401 handler**

Find the 401 handler in `fetch_usage` (around line 626). Replace:

```python
            if resp.status_code in (401, 403):
                error_msg = f"Usage fetch failed (HTTP {resp.status_code}) — token may be revoked"
                db.update_account(
                    account_id,
                    validation_status="invalid",
                    last_error=error_msg,
                    last_error_at=datetime.now(timezone.utc).isoformat(),
                )
                logger.warning(...)
                return None
```

With:

```python
            if resp.status_code in (401, 403):
                if _retry_depth < 1:
                    fresh = await _try_refresh_primary_token(
                        account_id, db, stale_token=token,
                    )
                    if fresh:
                        state["last_fetched_at"] = 0  # Reset ceiling for retry
                        return await fetch_usage(
                            account_id, db, access_token=fresh,
                            _retry_depth=_retry_depth + 1,
                        )

                # Refresh failed or already retried — mark invalid
                error_msg = f"Usage fetch failed (HTTP {resp.status_code}) — refresh failed"
                db.update_account(
                    account_id,
                    validation_status="invalid",
                    last_error=error_msg,
                    last_error_at=datetime.now(timezone.utc).isoformat(),
                )
                return None
```

- [ ] **Step 3: Run tests, commit**

Run: `uv run python -m pytest tests/ -q`

```bash
git commit -m "fix: fetch_usage auto-refreshes primary token on 401"
```

---

### Task 4: Update `fetch_profile` and `validate_account` 401 handlers

**Files:**
- Modify: `jacked/web/auth.py`

- [ ] **Step 1: Extract `_update_profile_metadata` helper**

The same metadata extraction exists in both `fetch_profile` (lines ~719-741) and `validate_account` (lines ~794-811). Extract into a helper:

```python
def _update_profile_metadata(account_id: int, data: dict, db) -> None:
    """Update account metadata from a profile API response."""
    org = data.get("organization", {})
    org_type = org.get("organization_type", "")
    subscription_type = ORG_TYPE_MAP.get(org_type)

    updates: dict = {}
    if subscription_type:
        updates["subscription_type"] = subscription_type
    if org.get("rate_limit_tier"):
        updates["rate_limit_tier"] = org["rate_limit_tier"]
    if "has_extra_usage_enabled" in org:
        updates["has_extra_usage"] = org["has_extra_usage_enabled"]
    if org.get("name"):
        updates["organization_name"] = org["name"]
    if updates:
        db.update_account(account_id, **updates)
```

Replace both inline copies with `_update_profile_metadata(account_id, data, db)`.

- [ ] **Step 2: Add `_refresh_depth` to `fetch_profile`**

`fetch_profile` already has `access_token: Optional[str] = None`. Add `_refresh_depth: int = 0`:

```python
async def fetch_profile(
    account_id: int, db, access_token: str | None = None,
    _refresh_depth: int = 0,
) -> dict | None:
```

Find or add the 401 handler. `fetch_profile` currently has no explicit 401 handler — check the code. If the 401 falls through to a generic handler, add an explicit one before it:

```python
            if resp.status_code in (401, 403) and _refresh_depth < 1:
                fresh = await _try_refresh_primary_token(
                    account_id, db, stale_token=token,
                )
                if fresh:
                    return await fetch_profile(
                        account_id, db, access_token=fresh,
                        _refresh_depth=_refresh_depth + 1,
                    )
            # Fall through to existing error handling
```

- [ ] **Step 3: Add refresh-then-retry to `validate_account`**

`validate_account` uses `account['access_token']` directly (line 779). Add the same pattern. Since `validate_account` doesn't have an `access_token` param, use inline retry (not recursive):

```python
            if resp.status_code in (401, 403):
                fresh = await _try_refresh_primary_token(
                    account_id, db, stale_token=account['access_token'],
                )
                if fresh:
                    retry_resp = await client.get(
                        PROFILE_URL,
                        headers={
                            "Authorization": f"Bearer {fresh}",
                            "anthropic-beta": OAUTH_BETA_HEADER,
                        },
                    )
                    if retry_resp.status_code == 200:
                        db.update_account(
                            account_id,
                            validation_status="valid",
                            last_validated_at=int(time.time()),
                            consecutive_failures=0,
                        )
                        _update_profile_metadata(account_id, retry_resp.json(), db)
                        return {"valid": True, "error": None}

                # Refresh failed — truly invalid
                db.update_account(
                    account_id,
                    validation_status="invalid",
                    last_validated_at=int(time.time()),
                    last_error=f"Token invalid (HTTP {resp.status_code}), refresh also failed",
                    last_error_at=datetime.now(timezone.utc).isoformat(),
                )
                return {"valid": False, "error": f"Token invalid (HTTP {resp.status_code})"}
```

- [ ] **Step 4: Run tests, commit**

Run: `uv run python -m pytest tests/ -q`

```bash
git commit -m "fix: fetch_profile + validate_account auto-refresh on 401, extract metadata helper"
```

---

### Task 5: Skip invalid accounts in `_fetch_candidate_usage` + window keeper retry

**Files:**
- Modify: `jacked/api/usage_monitor.py`

- [ ] **Step 1: Skip invalid accounts in `_fetch_candidate_usage`**

In `_fetch_candidate_usage`, add a filter to skip accounts with `validation_status="invalid"`:

```python
    for acct in accounts:
        if acct["id"] == active_acct_id:
            continue
        if acct.get("validation_status") == "invalid":
            continue  # Don't waste API calls on dead accounts
        cached_at = acct.get("usage_cached_at")
        if cached_at and (now - int(cached_at)) < _CANDIDATE_STALENESS_SECONDS:
            continue
        ...
```

- [ ] **Step 2: Add window keeper ping retry with CC refresh**

In `full_sweep_loop`, after a failed ping, try refreshing the CC token:

```python
                    success = await ping_account(cc_at)
                    if not success and acct.get("cc_refresh_token"):
                        from jacked.web.auth import refresh_cc_token
                        refreshed = await refresh_cc_token(acct["id"], db)
                        if refreshed:
                            fresh_acct = db.get_account(acct["id"])
                            fresh_cc = fresh_acct.get("cc_access_token") if fresh_acct else None
                            if fresh_cc and fresh_cc != cc_at:
                                success = await ping_account(fresh_cc)
```

- [ ] **Step 3: Run tests, commit**

Run: `uv run python -m pytest tests/ -q`

```bash
git commit -m "fix: skip invalid accounts in candidate fetch, retry pings with refreshed CC token"
```
