Hmbown/CodeWhale · error

OAuth device-code poll failed

Error message

OAuth device-code poll failed ({detail})

What it means

Thrown when a single token-endpoint poll during the device grant returns an outcome that is not a pending state (`authorization_pending`, `slow_down`) and not a success — i.e. a terminal OAuth error. The detail comes from `oauth_failure_detail` (`error`, `error_description`, or HTTP status).

Solutions

  1. Read the `detail`: `access_denied` means the user refused — retry and approve the request
  2. `expired_token`: restart the device login and complete it faster
  3. `invalid_client`/`invalid_grant`: fix the provider's client credentials in config
  4. If polls were faster than the provider interval, respect the provider's `interval` and `slow_down` backoff
Defensive patterns

Strategy: retry

Validate before calling

// before polling, respect the provider interval
await sleep(Math.max(providedIntervalSecs, 5) * 1000);

Try / catch

try {
  let token = pollDeviceGrant(...);
} catch (e) {
  if (e.matches('authorization_pending')) { await sleep(interval); retry(); }
  else if (e.matches('slow_down')) { interval += 5; await sleep(interval); retry(); }
  else if (e.matches('expired_token') || e.matches('access_denied')) { restartLogin(); }
  else { throw e; }
}

Prevention

When it happens

Trigger: `poll_device_grant` receives a terminal error from the token endpoint: `access_denied` (user rejected), `expired_token` (user took too long), `invalid_grant`, `invalid_client`, or any non-success HTTP status.

Common situations: User declined the login on their phone; the user code expired because polling took longer than the provider's interval (often due to `slow_down` backoff being ignored); provider rejects the client_id during token exchange.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/00f212eb77f11cfd. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/oauth.rs:795

        .send()
        .context("OAuth device-code poll failed")?;
    let (status, body): (_, OAuthTokenMaterial) =
        parse_oauth_json(response, "OAuth device-code poll")?;
    if status.is_success() && body.error.is_none() {
        return Ok(DevicePollOutcome::Complete(body));
    }
    match body.error.as_deref().unwrap_or("") {
        "authorization_pending" => Ok(DevicePollOutcome::Pending),
        "slow_down" => Ok(DevicePollOutcome::SlowDown {
            interval_seconds: body.interval,
        }),
        _ => {
            let detail = oauth_failure_detail(
                body.error.as_deref(),
                body.error_description.as_deref(),
                status,
            );
            bail!("OAuth device-code poll failed ({detail})");
        }
    }
}

/// Interactive device-code login for any provider whose row offers it.
/// Prints the verification URL + user code to stderr and polls until
/// approved. A provider with no device flow (ChatGPT) fails here with the
/// reason, instead of deep in transport code.
pub async fn device_code_login(provider: OAuthProvider) -> Result<PendingOAuthLogin> {
    // Endpoint resolution does blocking HTTP (discovery): it must run on the
    // blocking worker, never on the async executor. Providers with no device
    // flow fail here, before any thread spawns and before any network.
    let params = oauth_provider_params(provider);
    if params.device_code_path.is_none() {
        bail!(
            "{} offers no device-code flow; sign in through the browser login instead",
            params.display_name
        );

View on GitHub (pinned to 73e0f67d83)