From 7205fb9c71cc28e5488c0db171e15088146c310e Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 8 May 2026 14:35:29 -0600 Subject: [PATCH] Expand ClientApiError with structured variants for connection, server, and response errors (#56214) Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --- .../cloud_api_client/src/cloud_api_client.rs | 106 ++++++++++++------ crates/reqwest_client/examples/download.rs | 75 +++++++++++++ 2 files changed, 148 insertions(+), 33 deletions(-) create mode 100644 crates/reqwest_client/examples/download.rs diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index 43814e3b229fa0b72d9a70b030e7fa19eb965032..1121dc47572b9bf7e6fa3f3d0569103adf24ade0 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -28,10 +28,34 @@ struct Credentials { #[derive(Debug, Error)] pub enum ClientApiError { + /// 401 — credentials are invalid or expired. #[error("Unauthorized")] Unauthorized, - #[error(transparent)] - Other(#[from] anyhow::Error), + /// No credentials have been set on the client. + #[error("not signed in")] + NotSignedIn, + /// Connection-level failure: DNS, TCP, TLS, timeout, etc. + /// The HTTP request never received a response. + #[error("connection to {host} failed")] + ConnectionFailed { + host: String, + #[source] + source: anyhow::Error, + }, + /// Server returned a non-success HTTP status (other than 401). + #[error("{host} returned {status}")] + ServerError { + host: String, + status: StatusCode, + body: String, + }, + /// Failed to read or parse the response body after a successful HTTP status. + #[error("invalid response")] + InvalidResponse(#[source] anyhow::Error), + /// Failed to build the HTTP request (URL construction, serialization, etc.). + /// This typically indicates a programming error. + #[error("failed to build request")] + RequestBuildFailed(#[source] anyhow::Error), } pub struct CloudApiClient { @@ -62,25 +86,35 @@ impl CloudApiClient { *self.credentials.write() = None; } + fn cloud_host(&self) -> String { + self.http_client + .build_zed_cloud_url("/") + .ok() + .and_then(|url| url.host_str().map(String::from)) + .unwrap_or_else(|| "cloud.zed.dev".into()) + } + fn build_request( &self, req: request::Builder, body: impl Into, - ) -> Result> { + ) -> Result, ClientApiError> { let credentials = self.credentials.read(); - let credentials = credentials.as_ref().context("no credentials provided")?; - build_request(req, body, credentials) + let credentials = credentials.as_ref().ok_or(ClientApiError::NotSignedIn)?; + build_request(req, body, credentials).map_err(ClientApiError::RequestBuildFailed) } pub async fn get_authenticated_user( &self, system_id: Option, ) -> Result { + let host = self.cloud_host(); let request_builder = Request::builder() .method(Method::GET) .uri( self.http_client - .build_zed_cloud_url("/client/users/me")? + .build_zed_cloud_url("/client/users/me") + .map_err(ClientApiError::RequestBuildFailed)? .as_ref(), ) .when_some(system_id, |builder, system_id| { @@ -89,7 +123,12 @@ impl CloudApiClient { let request = self.build_request(request_builder, AsyncBody::default())?; - let mut response = self.http_client.send(request).await?; + let mut response = self.http_client.send(request).await.map_err(|source| { + ClientApiError::ConnectionFailed { + host: host.clone(), + source, + } + })?; if !response.status().is_success() { if response.status() == StatusCode::UNAUTHORIZED { @@ -97,16 +136,13 @@ impl CloudApiClient { } let mut body = String::new(); - response - .body_mut() - .read_to_string(&mut body) - .await - .context("failed to read response body")?; - - return Err(ClientApiError::Other(anyhow::anyhow!( - "Failed to get authenticated user.\nStatus: {:?}\nBody: {body}", - response.status() - ))); + response.body_mut().read_to_string(&mut body).await.ok(); + + return Err(ClientApiError::ServerError { + host, + status: response.status(), + body, + }); } let mut body = String::new(); @@ -114,9 +150,9 @@ impl CloudApiClient { .body_mut() .read_to_string(&mut body) .await - .context("failed to read response body")?; + .map_err(|e| ClientApiError::InvalidResponse(e.into()))?; - Ok(serde_json::from_str(&body).context("failed to parse response body")?) + serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into())) } pub fn connect(&self, cx: &App) -> Result>> { @@ -153,11 +189,13 @@ impl CloudApiClient { system_id: Option, organization_id: Option, ) -> Result { + let host = self.cloud_host(); let request_builder = Request::builder() .method(Method::POST) .uri( self.http_client - .build_zed_cloud_url("/client/llm_tokens")? + .build_zed_cloud_url("/client/llm_tokens") + .map_err(ClientApiError::RequestBuildFailed)? .as_ref(), ) .when_some(system_id, |builder, system_id| { @@ -169,7 +207,12 @@ impl CloudApiClient { Json(CreateLlmTokenBody { organization_id }), )?; - let mut response = self.http_client.send(request).await?; + let mut response = self.http_client.send(request).await.map_err(|source| { + ClientApiError::ConnectionFailed { + host: host.clone(), + source, + } + })?; if !response.status().is_success() { if response.status() == StatusCode::UNAUTHORIZED { @@ -177,16 +220,13 @@ impl CloudApiClient { } let mut body = String::new(); - response - .body_mut() - .read_to_string(&mut body) - .await - .context("failed to read response body")?; - - return Err(ClientApiError::Other(anyhow::anyhow!( - "Failed to create LLM token.\nStatus: {:?}\nBody: {body}", - response.status() - ))); + response.body_mut().read_to_string(&mut body).await.ok(); + + return Err(ClientApiError::ServerError { + host, + status: response.status(), + body, + }); } let mut body = String::new(); @@ -194,9 +234,9 @@ impl CloudApiClient { .body_mut() .read_to_string(&mut body) .await - .context("failed to read response body")?; + .map_err(|e| ClientApiError::InvalidResponse(e.into()))?; - Ok(serde_json::from_str(&body).context("failed to parse response body")?) + serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into())) } pub async fn validate_credentials(&self, user_id: u32, access_token: &str) -> Result { diff --git a/crates/reqwest_client/examples/download.rs b/crates/reqwest_client/examples/download.rs new file mode 100644 index 0000000000000000000000000000000000000000..63cdaada628cd2674ca5b9a4e783b8b37588c7f5 --- /dev/null +++ b/crates/reqwest_client/examples/download.rs @@ -0,0 +1,75 @@ +use std::time::Instant; + +use anyhow::{Context as _, Result, anyhow}; +use futures::AsyncReadExt as _; +use http_client::{AsyncBody, HttpClient as _}; +use reqwest_client::ReqwestClient; + +fn main() -> Result<()> { + let url = std::env::args() + .nth(1) + .ok_or_else(|| anyhow!("usage: cargo run -p reqwest_client --example download -- "))?; + + let client = ReqwestClient::user_agent("zed-reqwest-client-download-example")?; + + futures::executor::block_on(async move { + let started_at = Instant::now(); + let mut response = client + .get(&url, AsyncBody::empty(), true) + .await + .with_context(|| format!("requesting {url}"))?; + let headers_elapsed = started_at.elapsed(); + + println!("status: {}", response.status()); + println!("version: {:?}", response.version()); + if let Some(content_length) = response + .headers() + .get(http_client::http::header::CONTENT_LENGTH) + && let Ok(content_length) = content_length.to_str() + { + println!("content-length: {content_length}"); + } + println!("time-to-headers: {:.3}s", headers_elapsed.as_secs_f64()); + + let mut buffer = vec![0; 1024 * 1024]; + let mut bytes_downloaded = 0usize; + let body_started_at = Instant::now(); + + loop { + let bytes_read = response + .body_mut() + .read(&mut buffer) + .await + .context("reading response body")?; + if bytes_read == 0 { + break; + } + bytes_downloaded += bytes_read; + } + + let body_elapsed = body_started_at.elapsed(); + let total_elapsed = started_at.elapsed(); + let mebibytes = bytes_downloaded as f64 / 1024.0 / 1024.0; + let body_seconds = body_elapsed.as_secs_f64(); + let total_seconds = total_elapsed.as_secs_f64(); + + println!("downloaded-bytes: {bytes_downloaded}"); + println!("downloaded-mib: {mebibytes:.3}"); + println!("body-time: {body_seconds:.3}s"); + println!("total-time: {total_seconds:.3}s"); + if body_seconds > 0.0 { + println!("body-throughput: {:.3} MiB/s", mebibytes / body_seconds); + } + if total_seconds > 0.0 { + println!("total-throughput: {:.3} MiB/s", mebibytes / total_seconds); + } + + anyhow::ensure!( + response.status().is_success(), + "download completed with unsuccessful status {}", + response.status() + ); + + Ok(()) + }) +}