zed-industries/zed · error

Failed to connect to API

Error message

Failed to connect to API: {} {}

What it means

Thrown by stream_completion in Zed's Copilot chat client when the POST to the Copilot chat-completions endpoint (completion_url, with Copilot vision/user-initiated headers) returns a non-2xx status. The message embeds the HTTP status code and the raw response body, which carries the real reason: 401 expired OAuth token, 403 missing Copilot entitlement, 429 rate limiting, or a GitHub 5xx. It fires before any SSE line parsing, so no partial stream content is produced.

Solutions

  1. If status is 401: sign out and back into Copilot so a fresh OAuth token is issued
  2. If 403 with entitlement text: verify an active Copilot subscription at github.com/settings/copilot
  3. If 429: back off and retry after the indicated interval; reduce request frequency
  4. Read the embedded body text - it names the exact upstream reason; act on that specific status
  5. If 5xx or proxy HTML: check githubstatus.com and bypass/inspect the proxy

Example fix

// before: fire-and-forget call surfaces the raw bail
let stream = stream_completion(client, token, url, request, true, location).await?;

// after: retry transient statuses, fail fast on auth/entitlement
let mut attempt = 0;
loop {
    attempt += 1;
    match stream_completion(client.clone(), token.clone(), url.clone(), request.clone(), true, location).await {
        Ok(stream) => break stream,
        Err(err) if attempt < 3 && err.to_string().contains("429") => {
            smol::Timer::after(Duration::from_secs(2u64.pow(attempt))).await;
        }
        Err(err) if err.to_string().contains("401") => return Err(err.context("re-authenticate Copilot")),
        Err(err) => return Err(err),
    }
}
Defensive patterns

Strategy: retry

Validate before calling

// Pre-flight: refresh the token before sending if it is near expiry
if oauth_token.expires_in_remaining() < Duration::from_secs(30) {
    oauth_token = refresh_copilot_token(client).await?;
}

Try / catch

match stream_completion(/* .. */).await {
    Ok(stream) => { /* handle stream */ }
    Err(err) => {
        let msg = err.to_string();
        if msg.contains("401") {
            // trigger re-auth flow
        } else if msg.contains("429") || msg.contains("50") {
            // schedule backoff retry
        } else {
            return Err(err);
        }
    }
}

Prevention

When it happens

Trigger: Any Copilot chat completion request (request.stream either true or false) sent with: a stale/revoked GitHub Copilot OAuth token (401), a user with no active Copilot subscription (403, body often contains 'copilot_not_enabled'), tripping secondary rate limits (429), oversized prompts rejected (413), or GitHub API incidents (5xx). Vision requests additionally send 'Copilot-Vision-Request: true' and can be rejected when vision is not entitled.

Common situations: Token revoked by changing GitHub password or re-authorizing elsewhere; free-tier or lapsed Copilot subscriptions; aggressive retry loops hitting rate limits; corporate proxies returning HTML error pages as the body; requests to a stale cached completion_url.

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


AI-assisted analysis of zed-industries/zed@916fc2b8cb (2026-08-20). Data as JSON: /api/errors/3a07d51521f3012a. Report an issue: GitHub.

Appendix: source

Thrown at crates/copilot_chat/src/copilot_chat.rs:1088

    anyhow::ensure!(
        response.status().is_success(),
        "Failed to request models: {}",
        response.status()
    );
    let mut body = Vec::new();
    response.body_mut().read_to_end(&mut body).await?;

    let body_str = std::str::from_utf8(&body)?;

    let models = serde_json::from_str::<ModelSchema>(body_str)?.data;

    Ok(models)
}

async fn stream_completion(
    client: Arc<dyn HttpClient>,
    oauth_token: String,
    completion_url: Arc<str>,
    request: Request,
    is_user_initiated: bool,
    location: ChatLocation,
) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
    let is_vision_request = request.messages.iter().any(|message| match message {
        ChatMessage::User { content }
        | ChatMessage::Assistant { content, .. }
        | ChatMessage::Tool { content, .. } => {
            matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. })))
        }
        _ => false,
    });

    let request_builder = copilot_request_headers(
        HttpRequest::builder()
            .method(Method::POST)
            .uri(completion_url.as_ref()),

View on GitHub (pinned to 916fc2b8cb)