zed-industries/zed · error

Token exchange failed

Error message

Token exchange failed (HTTP {}): {}

What it means

This error is raised in `exchange_code` when the OAuth2 token endpoint responds with a non-2xx HTTP status. The library includes the HTTP status code and the (redacted) response body so the developer can see why the authorization-code exchange was rejected, while `redact_token_body` prevents tokens/secrets from leaking into logs. It wraps any server-side rejection of the code-for-token swap during `do_oauth_flow`.

Solutions

  1. Re-run the full OAuth flow to obtain a fresh authorization code — codes expire within minutes and are single-use
  2. Verify client_id, client_secret, and redirect_uri exactly match the values registered with the provider
  3. Log (redacted) response body to check the provider's error field, e.g. 'invalid_grant' vs 'invalid_client'
  4. Check for clock skew on the machine (NTP) if the provider validates code issuance time
  5. Retry later if the status is 5xx, as the provider may be temporarily unavailable

Example fix

// before
if !response.status().is_success() {
    return Err(anyhow!("Token exchange failed (HTTP {}): {}", response.status(), redact_token_body(&body)));
}
// after
if !response.status().is_success() {
    let reason = serde_json::from_str::<serde_json::Value>(&body)
        .ok()
        .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from));
    return Err(anyhow!(
        "Token exchange failed (HTTP {}): {} ({:?})",
        response.status(),
        redact_token_body(&body),
        reason
    ));
}
Defensive patterns

Strategy: try-catch

Validate before calling

// before starting the flow
assert!(!auth_code.is_empty(), "authorization code is missing");
assert!(!client_secret.is_empty(), "client secret missing (check env/creds file)");
// redirect_uri must byte-match the one used in the authorize request
assert_eq!(token_redirect_uri, authorize_redirect_uri);

Try / catch

match do_oauth_flow(cx).await {
    Err(e) if e.to_string().contains("Token exchange failed (HTTP 5") => {
        // provider outage: retry with backoff
    }
    Err(e) if e.to_string().contains("Token exchange failed") => {
        // permanent rejection (invalid_grant/invalid_client): restart full OAuth flow
    }
    Err(e) => return Err(e),
    Ok(creds) => Ok(creds),
}

Prevention

When it happens

Trigger: POSTing the authorization code, client_id, client_secret, and redirect_uri to the token endpoint and receiving a 400/401 (invalid code, expired code, code already used), a 403 (redirect_uri or client mismatch), or a 5xx from the auth server.

Common situations: Stale or replayed authorization codes (codes are single-use and short-lived), mismatched redirect_uri between the authorize and token requests, wrong client_id/client_secret in the environment, clock skew invalidating the code, or the IdP being temporarily down.

Related errors


AI-assisted analysis of zed-industries/zed@916fc2b8cb (2026-09-19). Data as JSON: /api/errors/92b1fef18c72679e. Report an issue: GitHub.

Appendix: source

Thrown at crates/x_ai_subscribed/src/x_ai_subscribed.rs:875

        .append_pair("grant_type", "authorization_code")
        .append_pair("client_id", CLIENT_ID)
        .append_pair("code", code)
        .append_pair("redirect_uri", redirect_uri)
        .append_pair("code_verifier", verifier)
        .finish();

    let request = HttpRequest::builder()
        .method(Method::POST)
        .uri(XAI_TOKEN_URL)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(AsyncBody::from(body))?;

    let mut response = client.send(request).await?;
    let mut body = String::new();
    smol::io::AsyncReadExt::read_to_string(response.body_mut(), &mut body).await?;

    if !response.status().is_success() {
        return Err(anyhow!(
            "Token exchange failed (HTTP {}): {}",
            response.status(),
            redact_token_body(&body)
        ));
    }

    serde_json::from_str::<TokenResponse>(&body).context("Failed to parse token response")
}

async fn refresh_token(
    client: &Arc<dyn HttpClient>,
    refresh_token: &str,
) -> Result<TokenResponse, RefreshError> {
    let body = form_urlencoded::Serializer::new(String::new())
        .append_pair("grant_type", "refresh_token")
        .append_pair("client_id", CLIENT_ID)
        .append_pair("refresh_token", refresh_token)
        .finish();

View on GitHub (pinned to 916fc2b8cb)