Expand ClientApiError with structured variants for connection, server, and response errors (#56214)

Conrad Irwin created

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

Change summary

crates/cloud_api_client/src/cloud_api_client.rs | 106 +++++++++++++-----
crates/reqwest_client/examples/download.rs      |  75 +++++++++++++
2 files changed, 148 insertions(+), 33 deletions(-)

Detailed changes

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<AsyncBody>,
-    ) -> Result<Request<AsyncBody>> {
+    ) -> Result<Request<AsyncBody>, 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<String>,
     ) -> Result<GetAuthenticatedUserResponse, ClientApiError> {
+        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<Task<Result<Connection>>> {
@@ -153,11 +189,13 @@ impl CloudApiClient {
         system_id: Option<String>,
         organization_id: Option<OrganizationId>,
     ) -> Result<CreateLlmTokenResponse, ClientApiError> {
+        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<bool> {

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 -- <url>"))?;
+
+    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(())
+    })
+}