sigoden/aichat · critical

{err_msg}

Error message

{err_msg}

What it means

Thrown by fetch_access_token in src/client/vertexai.rs:485 when the OAuth2 token endpoint responds with a JSON body containing an `error_description` field instead of a valid access_token/expires_in pair. The library propagates Google's own error description verbatim as the error message. This is an authentication failure — Google rejected the token exchange for the service account credentials being used.

Solutions

  1. Read the error_description in the message (e.g. "invalid_grant") and fix the underlying Google auth problem it names.
  2. Regenerate the service account key and update the credentials JSON / GOOGLE_APPLICATION_CREDENTIALS path.
  3. Check the system clock — significant skew invalidates the signed JWT; run NTP sync.
  4. Verify the service account exists and is enabled in the Google Cloud console with correct IAM roles.
  5. Re-run gcloud auth application-default login if relying on Application Default Credentials.

Example fix

// before (stale key in credentials.json)
// error: Invalid JWT Signature.
// after
// regenerate key:
//   gcloud iam service-accounts keys create new-key.json \
//     --iam-account=my-sa@project.iam.gserviceaccount.com
// then set GOOGLE_APPLICATION_CREDENTIALS=/path/to/new-key.json
Defensive patterns

Strategy: retry

Validate before calling

// Validate the ADC credentials file before attempting the token exchange
fn validate_adc_file(path: &str) -> anyhow::Result<()> {
    let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path)?)?;
    let key_ok = v["private_key"].as_str().map_or(false, |k| k.contains("BEGIN PRIVATE KEY"));
    let acct_ok = v["client_email"].as_str().map_or(false, |e| e.ends_with(".iam.gserviceaccount.com"));
    anyhow::ensure!(key_ok && acct_ok, "credentials file missing valid private_key/client_email");
    Ok(())
}

Try / catch

let token = loop {
    match prepare_gcloud_access_token().await {
        Ok(t) => break t,
        Err(e) if is_invalid_grant(&e.to_string()) => {
            rotate_service_account_key()?; // or alert ops, then retry once
            attempts += 1;
            if attempts > 1 { return Err(e.into()); }
        }
        Err(e) => return Err(e.into()),
    }
};

Prevention

When it happens

Trigger: Calling prepare_gcloud_access_token -> fetch_access_token where the token endpoint returns e.g. {"error":"invalid_grant","error_description":"Invalid JWT Signature."} — caused by expired/rotated service account keys, wrong private_key in the credentials JSON, badly skewed system clock, or the service account being disabled/deleted.

Common situations: Rotated or deleted service account keys while old GOOGLE_APPLICATION_CLOUD credentials JSON still on disk; misconfigured GOOGLE_APPLICATION_CREDENTIALS pointing to the wrong file; clock drift on the machine invalidating signed JWTs; disabled service accounts or missing IAM roles on the project.

Related errors


AI-assisted analysis of sigoden/aichat@82976d349a (2026-09-09). Data as JSON: /api/errors/980279e0d51cf279. Report an issue: GitHub.

Appendix: source

Thrown at src/client/vertexai.rs:485

async fn fetch_access_token(
    client: &reqwest::Client,
    file: &Option<String>,
) -> Result<(String, i64)> {
    let credentials = load_adc(file).await?;
    let value: Value = client
        .post("https://oauth2.googleapis.com/token")
        .json(&credentials)
        .send()
        .await?
        .json()
        .await?;

    if let (Some(access_token), Some(expires_in)) =
        (value["access_token"].as_str(), value["expires_in"].as_i64())
    {
        Ok((access_token.to_string(), expires_in))
    } else if let Some(err_msg) = value["error_description"].as_str() {
        bail!("{err_msg}")
    } else {
        bail!("Invalid response data: {value}")
    }
}

async fn load_adc(file: &Option<String>) -> Result<Value> {
    let adc_file = file
        .as_ref()
        .map(PathBuf::from)
        .or_else(default_adc_file)
        .ok_or_else(|| anyhow!("No application_default_credentials.json"))?;
    let data = tokio::fs::read_to_string(adc_file).await?;
    let data: Value = serde_json::from_str(&data)?;
    if let (Some(client_id), Some(client_secret), Some(refresh_token)) = (
        data["client_id"].as_str(),
        data["client_secret"].as_str(),
        data["refresh_token"].as_str(),
    ) {

View on GitHub (pinned to 82976d349a)