Kuberwastaken/claurst · error

Token exchange failed

Error message

Token exchange failed ({}): {}

What it means

Raised in exchange_code_for_tokens when the POST that swaps the OAuth authorization code for access/refresh tokens returns a non-success HTTP status. The error embeds the status code and the raw response body so the developer can see the OAuth server's rejection reason (invalid_grant, invalid_client, etc.).

Solutions

  1. Read the embedded status and body — an 'invalid_grant' body means the code expired or was already used; simply restart the login flow.
  2. Verify client_id/client_secret configuration matches the OAuth app registered with the provider.
  3. Confirm the redirect_uri sent to the token endpoint is byte-identical to the one in the authorization URL (scheme, host, port).
  4. Restart the login promptly after authorizing — authorization codes expire within minutes and are single-use.
  5. Check the token endpoint base URL for recent provider changes or version bumps.

Example fix

// before: blind retry re-posts an already-consumed code and fails again
let resp = client.post(&token_url).form(&params).send().await?;
// after: surface a distinct message for invalid_grant so users know to re-login
if !resp.status().is_success() {
    let status = resp.status();
    let text = resp.text().await.unwrap_or_default();
    if text.contains("invalid_grant") {
        bail!("Authorization code expired or already used — please restart the login flow");
    }
    bail!("Token exchange failed ({}): {}", status, text);
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Validate code freshness and redirect_uri consistency before exchanging
let code_age = started_at.elapsed()?;
if code_age > Duration::from_secs(300) {
    bail!("authorization code likely expired — restart login");
}
assert_eq!(redirect_uri_used_in_auth_url, redirect_uri_sent_to_token_endpoint);

Type guard

fn is_retryable_exchange_failure(status: u16, body: &str) -> bool {
    status >= 500 || status == 429
        || !(body.contains("invalid_grant") || body.contains("invalid_client"))
}

Try / catch

match exchange_code_for_tokens(&code, &verifier).await {
    Ok(tokens) => tokens,
    Err(e) if e.to_string().contains("invalid_grant") => {
        eprintln!("Authorization code expired or already used — restarting login...");
        restart_login_flow().await?
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: exchange_code_for_tokens receives resp with !resp.status().is_success() from the token endpoint: the authorization code was already consumed or expired (single-use, ~minutes lifetime), the client_id/client_secret or PKCE verifier is wrong, the redirect_uri does not exactly match the one used in the authorization request, or the endpoint is down/misconfigured (404/500).

Common situations: Developers hit this when the user takes too long to authorize (code expired), when the code was already exchanged by a retry after a timeout, when env config for client credentials is stale after a provider-side client rotation, or when the token endpoint URL changed with an API version bump.

Related errors


AI-assisted analysis of Kuberwastaken/claurst@b0637c97ec (2026-09-10). Data as JSON: /api/errors/5f39a3942536e9aa. Report an issue: GitHub.

Appendix: source

Thrown at src-rust/crates/cli/src/oauth_flow.rs:408

        "state": state,
    });

    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(30))
        .build()?;

    let resp = client
        .post(oauth::TOKEN_URL)
        .header("content-type", "application/json")
        .json(&body)
        .send()
        .await
        .context("Token exchange HTTP request failed")?;

    if !resp.status().is_success() {
        let status = resp.status();
        let text = resp.text().await.unwrap_or_default();
        bail!("Token exchange failed ({}): {}", status, text);
    }

    resp.json::<TokenExchangeResponse>()
        .await
        .context("Failed to parse token exchange response")
}

/// Exchange an OAuth access token for an Anthropic API key (Console flow only).
async fn create_api_key(access_token: &str) -> anyhow::Result<String> {
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(30))
        .build()?;

    let resp = client
        .post(oauth::API_KEY_URL)
        .header("Authorization", format!("Bearer {}", access_token))
        .send()
        .await

View on GitHub (pinned to b0637c97ec)