headroomlabs-ai/headroom · error · RuntimeError

Copilot token exchange failed with HTTP {exc.code}: {body}

Error message

Copilot token exchange failed with HTTP {exc.code}: {body}

What it means

Raised by CopilotTokenProvider._exchange_token_sync when the GET to the Copilot token-exchange endpoint returns an HTTPError (4xx/5xx). The RuntimeError embeds both exc.code and the decoded response body, preserving GitHub's error JSON for diagnosis. Common codes: 401 bad/expired OAuth token, 403 no Copilot entitlement or forbidden client, 404 wrong exchange URL (e.g. GHE misconfig).

Source

Thrown at headroom/copilot_auth.py:1213

        sku = payload.get("sku")
        return CopilotAPIToken(
            token=token,
            expires_at=expires_at,
            api_url=api_url,
            refresh_in=int(refresh_in) if isinstance(refresh_in, int | float) else None,
            sku=str(sku) if isinstance(sku, str) and sku.strip() else None,
        )

    @staticmethod
    def _exchange_token_sync(headers: dict[str, str]) -> dict[str, Any]:
        request = urllib_request.Request(_token_exchange_url(), headers=headers, method="GET")
        try:
            with urllib_request.urlopen(request, timeout=10.0) as response:
                payload = json.loads(response.read().decode("utf-8"))
                return payload if isinstance(payload, dict) else {}
        except urllib_error.HTTPError as exc:
            body = exc.read().decode("utf-8", errors="replace")
            raise RuntimeError(
                f"Copilot token exchange failed with HTTP {exc.code}: {body}"
            ) from exc


_provider: CopilotTokenProvider | None = None


def get_copilot_token_provider() -> CopilotTokenProvider:
    """Return the shared Copilot token provider."""

    global _provider
    if _provider is None:
        _provider = CopilotTokenProvider()
    return _provider


def _is_copilot_api_token(token: str) -> bool:
    """Return True when the token looks like a short-lived Copilot API token.

View on GitHub (pinned to 322425c43b)

Solutions

  1. Read the embedded HTTP code: 401 → re-run the device login to mint a fresh OAuth token; 403 → verify Copilot entitlement/seat; 404 → check the configured domain/_token_exchange_url for GHE
  2. For 5xx, retry after a short backoff — the exchange is a plain GET and safe to repeat
  3. If the token was revoked in GitHub settings, clear the headroom auth cache and re-login
  4. Wrap callers of get_copilot_token_provider() token fetches so this RuntimeError surfaces as a re-auth prompt rather than a crash
Defensive patterns

Strategy: retry

Try / catch

import time

for attempt in range(3):
    try:
        api_token = await provider.get_token()
        break
    except RuntimeError as e:
        msg = str(e)
        if "HTTP 5" in msg and attempt < 2:
            time.sleep(2 ** attempt)  # transient GitHub error: back off and retry
            continue
        if "HTTP 401" in msg:
            trigger_device_login()  # credential expired: re-auth, do not retry
            continue
        raise

Prevention

When it happens

Trigger: Exchanging an expired or revoked OAuth refresh token (401); account without Copilot access (403); _token_exchange_url() pointing at a host/path that does not exist for the configured domain (404); transient 5xx from GitHub.

Common situations: Cached OAuth token older than its lifetime so exchange 401s; GitHub Enterprise hosts lacking the Copilot endpoints; rotating/revoking the OAuth grant in GitHub settings; short GitHub outages surfacing as 502/503.

Related errors


AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15). Data as JSON: /api/errors/83649564969407cf. Report an issue: GitHub.