Skip to main content

taskfast_client/
client.rs

1// SPDX-License-Identifier: MIT
2//! Authenticated TaskFast client.
3//!
4//! [`TaskFastClient`] wraps the progenitor-generated [`crate::api::Client`]
5//! with the cross-cutting concerns the generated code deliberately omits:
6//!
7//!   1. **Auth** — injects `X-API-Key` on every request via reqwest's
8//!      `default_headers`, so every generated method call carries it without
9//!      per-call plumbing.
10//!   2. **Typed errors** — translates [`crate::api::Error<()>`] into
11//!      [`crate::errors::Error`] by reading the response body of an
12//!      `UnexpectedResponse` and classifying by status code. The normalizer
13//!      strips non-2xx response declarations from the spec (see
14//!      `xtask::normalize_spec`), so every TaskFast failure arrives here as
15//!      `UnexpectedResponse` — we are the single funnel.
16//!   3. **Retry** — [`TaskFastClient::call_with_retry`] runs a closure
17//!      under the configured [`RetryPolicy`], honoring 429 `Retry-After`
18//!      and retrying 5xx with exponential backoff.
19//!
20//! Direct access to the generated client is available via [`TaskFastClient::inner`]
21//! so callers can invoke `client.inner().get_agent_profile().await` and then
22//! pipe the result through [`map_api_error`] or the retry wrapper as they prefer.
23
24use std::collections::HashMap;
25use std::future::Future;
26use std::time::Duration;
27
28use progenitor_client::ClientInfo;
29use reqwest::header::{HeaderMap, HeaderValue};
30use serde::{Deserialize, Serialize};
31use tokio::sync::OnceCell;
32
33use crate::api;
34use crate::errors::{Error, Result};
35use crate::retry::{with_backoff, RetryPolicy};
36
37/// Default connect timeout for the underlying reqwest::Client.
38const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
39/// Default total request timeout (connect + body read).
40const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
41/// Hard cap on any single response body we read (16 MiB). TaskFast
42/// responses are JSON envelopes — the largest realistic payload is a
43/// task list page, ~KBs. A 16-MiB ceiling gives generous headroom
44/// while preventing a compromised (or buggy) server from memory-
45/// exhausting the CLI with a gigabyte body.
46const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
47/// Per-request timeout for upload complete: the server waits on its ingest
48/// Worker hashing the whole object, which can outlast the default 30s.
49const COMPLETE_REQUEST_TIMEOUT: Duration = Duration::from_mins(5);
50/// Backoff ceiling between upload complete retries.
51const MAX_COMPLETE_BACKOFF: Duration = Duration::from_secs(10);
52
53/// Authenticated TaskFast API client.
54pub struct TaskFastClient {
55    inner: api::Client,
56    policy: RetryPolicy,
57    /// Per-client cache of the public `GET /config/network` payload.
58    /// Fetched lazily on first access; immutable per deployment, so a
59    /// process-lifetime cache is sufficient.
60    network_cfg: OnceCell<NetworkConfigResponse>,
61}
62
63impl TaskFastClient {
64    /// Construct a client from an API key. The key is sent as `X-API-Key`
65    /// on every request; the underlying reqwest::Client marks the header
66    /// sensitive so it won't appear in debug traces.
67    ///
68    /// `base_url` is the API host (e.g. `https://api.taskfast.app`). The
69    /// generated client appends the unprefixed path keys from the spec.
70    /// Callers pass hosts, not versioned paths; a trailing `/` is tolerated.
71    pub fn from_api_key(base_url: &str, api_key: &str) -> Result<Self> {
72        let mut value = HeaderValue::from_str(api_key)
73            .map_err(|_| Error::Auth("api key contains invalid header bytes".into()))?;
74        value.set_sensitive(true);
75        let mut headers = HeaderMap::new();
76        headers.insert("X-API-Key", value);
77
78        // F5: pin redirect policy to `none`. reqwest's default follows up
79        // to 10 redirects. `Authorization` is stripped cross-host by
80        // reqwest, but `X-API-Key` is a custom header so it would be
81        // replayed verbatim to whatever 3xx `Location` the server points
82        // at — exfiltrating the PAT on a single malicious redirect. The
83        // real API never 3xx's, so refusing all redirects is safe: a 302
84        // from `api.taskfast.app` is itself the anomaly we want to catch.
85        let http = reqwest::ClientBuilder::new()
86            .default_headers(headers)
87            .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
88            .timeout(DEFAULT_REQUEST_TIMEOUT)
89            .redirect(reqwest::redirect::Policy::none())
90            .build()?;
91
92        let resolved = base_url.trim_end_matches('/').to_owned();
93
94        Ok(Self {
95            inner: api::Client::new_with_client(&resolved, http),
96            policy: RetryPolicy::default(),
97            network_cfg: OnceCell::new(),
98        })
99    }
100
101    /// Underlying reqwest::Client, pre-configured with the `X-API-Key` header.
102    /// Lets callers build requests outside the progenitor-generated surface
103    /// (e.g. to forward a JSON-RPC body to `POST /rpc/{network}` via the
104    /// same connection pool + default headers).
105    pub fn http_client(&self) -> reqwest::Client {
106        self.inner.client().clone()
107    }
108
109    /// Override the retry policy (default: [`RetryPolicy::default`]).
110    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
111        self.policy = policy;
112        self
113    }
114
115    /// Direct access to the generated typed client. Use to invoke endpoint
116    /// methods; combine with [`map_api_error`] to surface typed errors.
117    pub fn inner(&self) -> &api::Client {
118        &self.inner
119    }
120
121    /// The retry policy in effect for [`Self::call_with_retry`].
122    pub fn retry_policy(&self) -> RetryPolicy {
123        self.policy
124    }
125
126    /// Run `op` under the configured retry policy. `op` receives the attempt
127    /// number (1-indexed) and should produce a typed [`Result`]. Typical use:
128    ///
129    /// ```ignore
130    /// let profile = client.call_with_retry(|_| async {
131    ///     client.inner().get_agent_profile().await
132    ///         .map(|r| r.into_inner())
133    ///         .map_err(map_api_error).await // cannot await inside map_err
134    /// }).await?;
135    /// ```
136    ///
137    /// The callsite ergonomics bite is that [`map_api_error`] is async (it
138    /// reads the response body). Callers should match the Result themselves:
139    ///
140    /// ```ignore
141    /// client.call_with_retry(|_| async {
142    ///     match client.inner().get_agent_profile().await {
143    ///         Ok(ok) => Ok(ok.into_inner()),
144    ///         Err(e) => Err(map_api_error(e).await),
145    ///     }
146    /// }).await
147    /// ```
148    pub async fn call_with_retry<T, F, Fut>(&self, op: F) -> Result<T>
149    where
150        F: FnMut(u32) -> Fut,
151        Fut: Future<Output = Result<T>>,
152    {
153        with_backoff(self.policy, op).await
154    }
155
156    /// `GET /users/me` — fetch the human owner's display name + email.
157    ///
158    /// Hand-rolled because the endpoint is not yet in `spec/openapi.yaml`
159    /// (server-side work in flight). 404 is promoted to `Ok(None)` so
160    /// callers can gracefully fall back when running against an older
161    /// server deployment; every other non-2xx is surfaced via the shared
162    /// `classify_response` funnel.
163    pub async fn get_user_profile(&self) -> Result<Option<UserProfile>> {
164        let url = format!("{}/users/me", self.inner.baseurl());
165        let resp = self.inner.client().get(url).send().await?;
166        let status = resp.status();
167        if status.is_success() {
168            let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
169            return Ok(Some(serde_json::from_slice::<UserProfile>(&bytes)?));
170        }
171        if status == reqwest::StatusCode::NOT_FOUND {
172            return Ok(None);
173        }
174        Err(classify_response(resp).await)
175    }
176
177    /// Upload a file artifact to `POST /tasks/{task_id}/artifacts`.
178    ///
179    /// Hand-rolled because progenitor's default templates skip `multipart/
180    /// form-data` endpoints. We reuse the inner reqwest::Client (same auth
181    /// headers, same timeouts) and funnel non-2xx responses through the
182    /// same `classify_response` used for generated methods — so upload
183    /// errors map to the same `Error` variants as every other call.
184    pub async fn upload_artifact(
185        &self,
186        task_id: &uuid::Uuid,
187        filename: String,
188        content_type: String,
189        bytes: Vec<u8>,
190    ) -> Result<api::types::Artifact> {
191        let url = format!("{}/tasks/{}/artifacts", self.inner.baseurl(), task_id);
192        let part = reqwest::multipart::Part::bytes(bytes)
193            .file_name(filename)
194            .mime_str(&content_type)
195            .map_err(|e| Error::Server(format!("invalid content-type: {e}")))?;
196        let form = reqwest::multipart::Form::new().part("file", part);
197        let resp = self.inner.client().post(url).multipart(form).send().await?;
198        if resp.status().is_success() {
199            let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
200            Ok(serde_json::from_slice::<api::types::Artifact>(&bytes)?)
201        } else {
202            Err(classify_response(resp).await)
203        }
204    }
205
206    /// `POST /tasks/{task_id}/artifacts/uploads` — ask for a direct-upload
207    /// ticket (gh#193). The server checks `size_bytes` against its own
208    /// per-type cap here, before any bytes move, so oversize files fail as
209    /// 413 `file_too_large` carrying `max_bytes`. The CLI holds no cap.
210    ///
211    /// Hand-rolled until the endpoint lands in `spec/openapi.yaml`
212    /// (taskfast#1690). 404 is promoted to `Ok(None)`: an older server with
213    /// no direct-upload endpoint.
214    pub async fn initiate_artifact_upload(
215        &self,
216        task_id: &uuid::Uuid,
217        filename: &str,
218        content_type: &str,
219        size_bytes: u64,
220    ) -> Result<Option<ArtifactUpload>> {
221        let url = format!(
222            "{}/tasks/{}/artifacts/uploads",
223            self.inner.baseurl(),
224            task_id
225        );
226        let body = serde_json::json!({
227            "filename": filename,
228            "content_type": content_type,
229            "size_bytes": size_bytes,
230        });
231        let resp = self.inner.client().post(url).json(&body).send().await?;
232        let status = resp.status();
233        if status.is_success() {
234            let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
235            return Ok(Some(serde_json::from_slice::<ArtifactUpload>(&bytes)?));
236        }
237        if status == reqwest::StatusCode::NOT_FOUND {
238            return Ok(None);
239        }
240        Err(classify_response(resp).await)
241    }
242
243    /// `POST /tasks/{task_id}/artifacts/uploads/{upload_id}/complete` — have
244    /// the server fingerprint the PUT object and record the artifact.
245    ///
246    /// Complete is idempotent server-side, so 503 (ingest Worker unreachable)
247    /// and transport failures (including our own timeout while the Worker
248    /// hashes a large file) retry with doubling backoff until `expires_at`.
249    /// Only complete retries; the caller must never PUT again.
250    pub async fn complete_artifact_upload(
251        &self,
252        task_id: &uuid::Uuid,
253        upload_id: &uuid::Uuid,
254        expires_at: chrono::DateTime<chrono::Utc>,
255    ) -> Result<api::types::Artifact> {
256        let url = format!(
257            "{}/tasks/{}/artifacts/uploads/{}/complete",
258            self.inner.baseurl(),
259            task_id,
260            upload_id
261        );
262        let mut delay = self.policy.base_delay;
263        loop {
264            let err = match self
265                .inner
266                .client()
267                .post(&url)
268                .timeout(COMPLETE_REQUEST_TIMEOUT)
269                .send()
270                .await
271            {
272                Ok(resp) if resp.status().is_success() => {
273                    match read_body_capped(resp, MAX_RESPONSE_BYTES).await {
274                        Ok(bytes) => return Ok(serde_json::from_slice(&bytes)?),
275                        // Headers can arrive before a disconnect or body timeout.
276                        // Recover via idempotent complete, just as for send failures.
277                        Err(err @ Error::Network(_)) => err,
278                        Err(err) => return Err(err),
279                    }
280                }
281                Ok(resp) if resp.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
282                    classify_response(resp).await
283                }
284                Ok(resp) => return Err(classify_response(resp).await),
285                Err(e) => Error::Network(e),
286            };
287            if chrono::Utc::now() + delay >= expires_at {
288                return Err(err);
289            }
290            tracing::warn!(
291                ?delay,
292                error = err.kind(),
293                "retrying artifact upload complete"
294            );
295            tokio::time::sleep(delay).await;
296            delay = (delay * 2).min(MAX_COMPLETE_BACKOFF);
297        }
298    }
299
300    /// `GET /config/network` — per-network chain metadata.
301    ///
302    /// Public endpoint, no auth required; the `X-API-Key` header attached by
303    /// `from_api_key` is harmless on the server side. Result is cached on
304    /// this client instance for the rest of the process — the payload is
305    /// immutable per deployment (operator-side config changes require a
306    /// redeploy to take effect).
307    ///
308    /// Each entry's `rpc_url` points at this same deployment's authenticated
309    /// `POST /rpc/{network}` proxy, NOT the upstream Tempo gateway.
310    pub async fn fetch_network_config(&self) -> Result<&NetworkConfigResponse> {
311        self.network_cfg
312            .get_or_try_init(|| async {
313                let url = format!("{}/config/network", self.inner.baseurl());
314                let resp = self.inner.client().get(url).send().await?;
315                if !resp.status().is_success() {
316                    return Err(classify_response(resp).await);
317                }
318                let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
319                Ok(serde_json::from_slice::<NetworkConfigResponse>(&bytes)?)
320            })
321            .await
322    }
323
324    /// `POST /rpc/{network}` — forward a JSON-RPC call through the
325    /// operator's upstream proxy.
326    ///
327    /// `network` is the key into the `fetch_network_config` map
328    /// (`"testnet"` / `"mainnet"`). Response body is returned verbatim as
329    /// [`serde_json::Value`] — the proxy forwards the upstream JSON-RPC
330    /// envelope without touching it, so callers parse `result` / `error`
331    /// per the JSON-RPC 2.0 spec.
332    ///
333    /// 429 surfaces as [`Error::RateLimited`] with the `Retry-After`
334    /// header honored by `classify_response`; 5xx as [`Error::Server`].
335    pub async fn post_json_rpc(
336        &self,
337        network: &str,
338        request: &serde_json::Value,
339    ) -> Result<serde_json::Value> {
340        let url = format!("{}/rpc/{}", self.inner.baseurl(), network);
341        let resp = self.inner.client().post(url).json(request).send().await?;
342        if !resp.status().is_success() {
343            return Err(classify_response(resp).await);
344        }
345        let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
346        Ok(serde_json::from_slice::<serde_json::Value>(&bytes)?)
347    }
348
349    /// `GET /agents/me` — raw JSON escape hatch.
350    ///
351    /// Mirrors the generated `get_agent_profile` but returns the body as
352    /// [`serde_json::Value`] so `taskfast me` can print fields the server
353    /// added after this client's spec was vendored (gh#172).
354    pub async fn get_agent_profile_raw(&self) -> Result<serde_json::Value> {
355        let url = format!("{}/agents/me", self.inner.baseurl());
356        let resp = self.inner.client().get(url).send().await?;
357        if !resp.status().is_success() {
358            return Err(classify_response(resp).await);
359        }
360        let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
361        Ok(serde_json::from_slice::<serde_json::Value>(&bytes)?)
362    }
363
364    /// `GET /agents/me/events` — raw JSON escape hatch.
365    ///
366    /// Mirrors the generated `list_agent_events` but returns
367    /// [`serde_json::Value`] instead of the strict [`api::types::AgentEventListResponse`]
368    /// so the agent layer can apply per-item tolerant decoding when a
369    /// single malformed event would otherwise fail the whole page.
370    /// Non-2xx responses still funnel through `classify_response`.
371    pub async fn list_agent_events_raw(
372        &self,
373        cursor: Option<&str>,
374        limit: Option<i64>,
375    ) -> Result<serde_json::Value> {
376        let url = format!("{}/agents/me/events", self.inner.baseurl());
377        let mut req = self.inner.client().get(url);
378        let mut query: Vec<(&str, String)> = Vec::new();
379        if let Some(c) = cursor {
380            query.push(("cursor", c.to_string()));
381        }
382        if let Some(l) = limit {
383            query.push(("limit", l.to_string()));
384        }
385        if !query.is_empty() {
386            req = req.query(&query);
387        }
388        let resp = req.send().await?;
389        if !resp.status().is_success() {
390            return Err(classify_response(resp).await);
391        }
392        let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
393        Ok(serde_json::from_slice::<serde_json::Value>(&bytes)?)
394    }
395}
396
397/// PUT `file` to a presigned storage `url` with exactly `headers` (gh#193).
398///
399/// Deliberately a fresh reqwest::Client, not [`TaskFastClient::http_client`]:
400/// that one carries `X-API-Key` as a default header, which must never reach a
401/// storage host. Redirects are off for the same reason as in
402/// [`TaskFastClient::from_api_key`]. No total timeout, since a 200 MB video
403/// on a slow link legitimately runs long. The body streams from disk with an
404/// explicit `Content-Length`, because S3-style presigned PUTs refuse
405/// `Transfer-Encoding: chunked`.
406pub async fn put_presigned_upload<S: std::hash::BuildHasher>(
407    url: &str,
408    headers: &HashMap<String, String, S>,
409    file: std::fs::File,
410    size_bytes: u64,
411) -> Result<()> {
412    let http = reqwest::ClientBuilder::new()
413        .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
414        .redirect(reqwest::redirect::Policy::none())
415        .build()?;
416    let mut req = http
417        .put(url)
418        .header(reqwest::header::CONTENT_LENGTH, size_bytes)
419        .body(reqwest::Body::from(tokio::fs::File::from_std(file)));
420    // The server signs `content-length` too. `header` appends, and R2's edge
421    // answers 400 to a duplicate, so keep only the one set from `size_bytes`.
422    for (name, value) in headers {
423        if !name.eq_ignore_ascii_case("content-length") {
424            req = req.header(name, value);
425        }
426    }
427    // The URL is a write credential; errors reach CLI output and agent logs.
428    let resp = req.send().await.map_err(reqwest::Error::without_url)?;
429    let status = resp.status();
430    if status.is_success() {
431        return Ok(());
432    }
433    // Storage errors are S3 XML, not TaskFast's envelope; keep the raw text
434    // (e.g. `SignatureDoesNotMatch`) for the operator.
435    let body = read_body_capped(resp, 64 * 1024).await.unwrap_or_default();
436    Err(Error::Server(format!(
437        "artifact storage PUT failed: HTTP {}: {}",
438        status.as_u16(),
439        String::from_utf8_lossy(&body).trim()
440    )))
441}
442
443/// Answer to `POST /tasks/{task_id}/artifacts/uploads`. Lives alongside the
444/// hand-rolled call until the endpoint is added to `spec/openapi.yaml` and
445/// the type can be regenerated under [`crate::api::types`].
446#[derive(Debug, Clone, Deserialize)]
447#[serde(tag = "method", rename_all = "lowercase")]
448pub enum ArtifactUpload {
449    /// Upload straight to storage with [`put_presigned_upload`], then call
450    /// [`TaskFastClient::complete_artifact_upload`].
451    Put {
452        /// Server id for this upload. A UUID, so it is safe to put in a path.
453        upload_id: uuid::Uuid,
454        /// Presigned storage URL.
455        url: String,
456        /// Headers the PUT must carry exactly (the signed `content-type`).
457        #[serde(default)]
458        headers: HashMap<String, String>,
459        /// When the presigned URL and the pending upload expire.
460        expires_at: chrono::DateTime<chrono::Utc>,
461    },
462    /// The server stores uploads itself (development's Local adapter): use
463    /// [`TaskFastClient::upload_artifact`].
464    Multipart,
465}
466
467/// Stream `resp` body chunks into memory, refusing to allocate past `cap`.
468///
469/// Reqwest offers no native body-size cap on the ClientBuilder. Wrapping
470/// `chunk()` is the cheap route: each call yields whatever reqwest has
471/// buffered, so we can bail as soon as the running total would exceed
472/// the ceiling — no need for a trusted `Content-Length` header (chunked
473/// bodies from a malicious server carry none).
474async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> Result<Vec<u8>> {
475    // Capture the server x-request-id for the CLI trace (gh#85). Generated
476    // methods are covered by progenitor's post-hook; the hand-rolled methods
477    // all funnel their bodies (success and error, via classify_response)
478    // through here, so this one line covers every hand-rolled path.
479    crate::corr::record_corr_response(&resp);
480    let mut buf = Vec::new();
481    while let Some(chunk) = resp.chunk().await? {
482        if buf.len().saturating_add(chunk.len()) > cap {
483            return Err(Error::Server(format!(
484                "response body exceeds {cap}-byte safety cap"
485            )));
486        }
487        buf.extend_from_slice(&chunk);
488    }
489    Ok(buf)
490}
491
492/// Translate a progenitor [`api::Error<()>`] into a typed [`Error`].
493///
494/// Async because the `UnexpectedResponse` path has to consume the response
495/// body to extract TaskFast's `{error, message}` envelope.
496pub async fn map_api_error(err: api::Error<()>) -> Error {
497    use api::Error as AE;
498    match err {
499        AE::InvalidRequest(m) => Error::Server(format!("invalid request: {m}")),
500        AE::CommunicationError(e) => Error::Network(e),
501        AE::InvalidUpgrade(e) => Error::Network(e),
502        AE::ResponseBodyError(e) => Error::Network(e),
503        AE::InvalidResponsePayload(_, e) => Error::Decode(e),
504        AE::UnexpectedResponse(resp) => classify_response(resp).await,
505        AE::ErrorResponse(_) => {
506            // Unreachable with stripped-non-2xx normalization (E = ()), but we
507            // refuse to panic — produce a descriptive Server error instead.
508            Error::Server(
509                "progenitor emitted typed ErrorResponse for E=() — spec normalization regression"
510                    .into(),
511            )
512        }
513        // progenitor 0.14 collapsed the former Pre/PostHookError variants
514        // into a single Custom variant for consumer-defined hook failures.
515        AE::Custom(m) => Error::Server(format!("hook: {m}")),
516    }
517}
518
519/// Per-network chain configuration returned by `GET /config/network`.
520///
521/// Keys of `networks` are network names (`"testnet"`, `"mainnet"`);
522/// networks the deployment does not support are simply absent.
523#[derive(Debug, Clone, Deserialize, Serialize)]
524pub struct NetworkConfigResponse {
525    /// Network name → chain metadata.
526    pub networks: HashMap<String, NetworkConfigEntry>,
527}
528
529impl NetworkConfigResponse {
530    /// Return the entry for `network` name or an `Error::Validation` if
531    /// the deployment does not advertise it.
532    pub fn entry(&self, network: &str) -> Result<&NetworkConfigEntry> {
533        self.networks.get(network).ok_or_else(|| Error::Validation {
534            code: "unknown_network".to_string(),
535            message: format!("deployment does not advertise network `{network}`"),
536        })
537    }
538
539    /// Reverse-lookup: find the entry whose `chain_id` matches. Used when
540    /// the caller knows the chain ID (e.g. from escrow params) but not
541    /// the network name. Errors if no entry matches.
542    pub fn entry_by_chain_id(&self, chain_id: i64) -> Result<(&str, &NetworkConfigEntry)> {
543        self.networks
544            .iter()
545            .find(|(_, e)| e.chain_id == chain_id)
546            .map(|(name, entry)| (name.as_str(), entry))
547            .ok_or_else(|| Error::Validation {
548                code: "unknown_chain_id".to_string(),
549                message: format!("no network in /config/network advertises chain_id={chain_id}"),
550            })
551    }
552}
553
554/// Chain metadata for a single Tempo network.
555#[derive(Debug, Clone, Deserialize, Serialize)]
556pub struct NetworkConfigEntry {
557    /// EVM chain ID (e.g. `4217` for Tempo mainnet, `42_431` for moderato testnet).
558    pub chain_id: i64,
559    /// Authenticated JSON-RPC proxy URL on this deployment
560    /// (`{api_base}/rpc/{network}`). NOT the upstream Tempo gateway.
561    pub rpc_url: String,
562    /// WebSocket JSON-RPC URL on the native Tempo gateway (no proxy).
563    /// `None` for Tempo Zone entries — a zone venue advertises only its RPC
564    /// proxy (the server emits `null`), and rejecting the whole
565    /// `/config/network` payload over it would break every command that
566    /// resolves the default proxy route while any zone venue is active.
567    #[serde(default)]
568    pub wss_url: Option<String>,
569    /// Block-explorer base URL for this network; `None` for Tempo Zone
570    /// entries (zones have no public explorer).
571    #[serde(default)]
572    pub explorer_url: Option<String>,
573    /// Default stablecoin ticker; `None` when the deployment has not
574    /// finalized a token for the network (e.g. mainnet pre-launch).
575    pub default_stablecoin: Option<String>,
576    /// TaskBond contract address for this network; `None` when the
577    /// deployment has not published one (or predates the field).
578    pub task_bond_contract: Option<String>,
579}
580
581/// Owning human's display name + email, returned by `GET /users/me`.
582/// Lives alongside the hand-rolled fetch until the endpoint is added to
583/// `spec/openapi.yaml` and the type can be regenerated under
584/// [`crate::api::types`].
585#[derive(Debug, Clone, Deserialize)]
586pub struct UserProfile {
587    /// Display name for the owning human (e.g. `"Alice Smith"`).
588    pub name: String,
589    /// Owning human's email address.
590    pub email: String,
591}
592
593#[derive(Debug, Deserialize)]
594struct ErrorBody {
595    /// TaskFast's `components/schemas/Error.error` — short code.
596    #[serde(default)]
597    error: String,
598    /// Human-readable detail.
599    #[serde(default)]
600    message: String,
601    /// Upload size cap for this content type, sent with 413
602    /// `file_too_large` (taskfast#1690). Appended to `message` as-is so the
603    /// server's real limit reaches the operator; the CLI holds no copy.
604    #[serde(default)]
605    max_bytes: Option<u64>,
606}
607
608/// Classify a non-2xx response by status code, reading the body to extract
609/// TaskFast's `{error, message}` envelope when relevant.
610///
611/// Body read + JSON parse are best-effort: a transport failure mid-body or a
612/// non-JSON payload collapses to an empty envelope (status line surfaces in
613/// `Display`). Both failure modes emit a `tracing::warn` so silent classifier
614/// drops are observable in logs.
615async fn classify_response(resp: reqwest::Response) -> Error {
616    let status = resp.status();
617    let code = status.as_u16();
618    let retry_after = parse_retry_after(&resp);
619    // Cap the error body read at 64 KiB. TaskFast error envelopes are
620    // `{error, message}` JSON objects — a megabyte-class body on a 4xx
621    // is an abuse signal, not a legitimate payload. Smaller cap than
622    // success bodies: error paths never carry task-list pages.
623    let body = match read_body_capped(resp, 64 * 1024).await {
624        Ok(b) => String::from_utf8_lossy(&b).into_owned(),
625        Err(e) => {
626            tracing::warn!(error = %e.kind(), %code, "classify_response: failed to read body");
627            String::new()
628        }
629    };
630    let mut parsed: ErrorBody = match serde_json::from_str(&body) {
631        Ok(p) => p,
632        Err(e) => {
633            if !body.is_empty() {
634                tracing::warn!(error = %e, %code, "classify_response: body is not JSON; using raw text");
635            }
636            ErrorBody {
637                error: String::new(),
638                message: body,
639                max_bytes: None,
640            }
641        }
642    };
643    if let Some(max) = parsed.max_bytes {
644        parsed.message = format!("{} (max_bytes: {max})", parsed.message);
645    }
646
647    match code {
648        401 | 403 => Error::Auth(format_status(
649            code,
650            display_or_status(&parsed, status.as_str()),
651        )),
652        422 => Error::Validation {
653            code: or_default(&parsed.error, "validation_error"),
654            message: or_default(&parsed.message, "request body failed validation"),
655        },
656        429 => Error::RateLimited {
657            retry_after: retry_after.unwrap_or(Duration::from_secs(1)),
658        },
659        500..=599 => Error::Server(format_status(
660            code,
661            display_or_status(&parsed, status.as_str()),
662        )),
663        // 4xx other than the above (404, 409, etc.) — progenitor would have
664        // surfaced this as UnexpectedResponse because we stripped non-2xx from
665        // the spec. Treat as a validation-ish failure so callers can branch.
666        400..=499 => Error::Validation {
667            code: or_default(&parsed.error, status.as_str()),
668            message: or_default(
669                &parsed.message,
670                status.canonical_reason().unwrap_or("client error"),
671            ),
672        },
673        _ => Error::Server(format_status(
674            code,
675            display_or_status(&parsed, status.as_str()),
676        )),
677    }
678}
679
680fn parse_retry_after(resp: &reqwest::Response) -> Option<Duration> {
681    let v = resp.headers().get(reqwest::header::RETRY_AFTER)?;
682    let s = v.to_str().ok()?.trim();
683    // Only seconds form supported for now; HTTP-date form can land later.
684    s.parse::<u64>().ok().map(Duration::from_secs)
685}
686
687fn or_default(s: &str, fallback: &str) -> String {
688    if s.is_empty() {
689        fallback.to_string()
690    } else {
691        s.to_string()
692    }
693}
694
695fn display_or_status<'a>(body: &'a ErrorBody, status: &'a str) -> &'a str {
696    if !body.message.is_empty() {
697        &body.message
698    } else if !body.error.is_empty() {
699        &body.error
700    } else {
701        status
702    }
703}
704
705fn format_status(code: u16, detail: &str) -> String {
706    format!("HTTP {code}: {detail}")
707}