zeroclaw-labs/zeroclaw · error · anyhow::Error

OpenAI OAuth token request failed ({status}): {body}

Error message

OpenAI OAuth token request failed ({status}): {body}

What it means

The OpenAI token endpoint (auth.openai.com/oauth/token) returned a non-2xx response inside `parse_token_response`. This single chokepoint backs `exchange_code_for_tokens`, `refresh_access_token`, and device-code polling, so it fires for code exchange, token refresh, and device grants alike. The HTTP status and raw body are embedded; typical bodies are OAuth errors such as `invalid_grant` or `invalid_client`, or an HTML page from an intermediary.

Source

Thrown at crates/zeroclaw-providers/src/auth/openai_oauth.rs:396

    None
}

pub fn extract_expiry_from_jwt(token: &str) -> Option<chrono::DateTime<Utc>> {
    let payload = token.split('.').nth(1)?;
    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(payload)
        .ok()?;
    let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
    let exp = claims.get("exp").and_then(|v| v.as_i64())?;
    chrono::DateTime::<Utc>::from_timestamp(exp, 0)
}

async fn parse_token_response(response: reqwest::Response) -> Result<TokenSet> {
    if !response.status().is_success() {
        let status = response.status();
        let body = response.text().await.unwrap_or_default();
        anyhow::bail!("OpenAI OAuth token request failed ({status}): {body}");
    }

    let token: TokenResponse = response
        .json()
        .await
        .context("Failed to parse OpenAI token response")?;

    let expires_at = token.expires_in.and_then(|seconds| {
        if seconds <= 0 {
            None
        } else {
            Some(Utc::now() + chrono::Duration::seconds(seconds))
        }
    });

    Ok(TokenSet {
        access_token: token.access_token,
        refresh_token: token.refresh_token,

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Read the embedded status and body: `invalid_grant` on refresh means the refresh token is dead — run a fresh interactive login; on code exchange, restart the flow for a new code
  2. Use the same `PkceState` instance for `build_authorize_url` (challenge) and `exchange_code_for_tokens` (verifier)
  3. Confirm redirect_uri is exactly `http://localhost:1455/auth/callback` (OPENAI_OAUTH_REDIRECT_URI)
  4. If the body is HTML or status is 5xx/429, suspect the network path (proxy, outage) and retry with backoff

Example fix

// before: any failure aborts
let tokens = refresh_access_token(&client, &refresh).await?;

// after: invalid_grant triggers re-login, transient failures retry
let tokens = match refresh_access_token(&client, &refresh).await {
    Ok(t) => t,
    Err(e) if e.to_string().contains("invalid_grant") => run_interactive_login().await?,
    Err(e) if e.to_string().contains("token request failed") => retry_with_backoff().await?,
    Err(e) => return Err(e),
};
Defensive patterns

Strategy: try-catch

Validate before calling

// avoid needless refresh calls: only refresh when expiry is close
if let Some(exp) = extract_expiry_from_jwt(&tokens.access_token) {
    if exp > Utc::now() + chrono::Duration::seconds(60) {
        return Ok(tokens); // still valid, skip the token endpoint round-trip
    }
}

Type guard

fn is_openai_token_error(e: &anyhow::Error) -> bool {
    e.to_string().contains("OpenAI OAuth token request failed")
}

fn is_invalid_grant(e: &anyhow::Error) -> bool {
    e.to_string().contains("invalid_grant")
}

Try / catch

let tokens = match refresh_access_token(&client, &refresh).await {
    Ok(t) => t,
    Err(e) if is_openai_token_error(&e) && is_invalid_grant(&e) => run_interactive_login().await?,
    Err(e) if is_openai_token_error(&e) => retry_with_backoff(refresh_access_token(&client, &refresh)).await?,
    Err(e) => return Err(e),
};

Prevention

When it happens

Trigger: Exchanging an authorization code that expired (codes are short-lived and single-use) or was already consumed; refreshing with a revoked or rotated refresh_token; a PKCE code_verifier that does not match the challenge sent in the authorize URL; any 4xx/5xx from the endpoint.

Common situations: App crashed after exchanging but before persisting tokens — retry replays the used code; refresh token invalidated by a login on another machine; corporate proxy intercepting TLS and answering with an error page; clock skew.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/950bff478138fd69. Report an issue: GitHub.