Skip to main content

cli_engine/transport/
client.rs

1use std::{
2    collections::BTreeMap,
3    io::Write,
4    path::Path,
5    sync::{Arc, OnceLock, RwLock},
6    time::Duration,
7};
8
9use bytes::Bytes;
10use reqwest::{Method, StatusCode, header};
11use serde::{Serialize, de::DeserializeOwned};
12use serde_json::Value;
13use tokio::time;
14
15use super::{AuthInjector, Error};
16use crate::{CliCoreError, Result};
17
18const MAX_RETRIES: usize = 3;
19const BASE_BACKOFF: Duration = Duration::from_millis(500);
20const BUILTIN_DEFAULT_USER_AGENT: &str = "cli/dev";
21static DEFAULT_USER_AGENT: OnceLock<RwLock<String>> = OnceLock::new();
22
23/// Sets the process-wide default user-agent for outbound requests.
24///
25/// Applies to subsequently created [`HttpClient`] values (those that do not set
26/// their own via [`HttpClientBuilder::user_agent`]) and to the engine's other
27/// outbound token traffic that reads this default — the PKCE provider's
28/// token/refresh requests and the client-credentials injector. A per-client
29/// user-agent still overrides it for that client.
30pub fn set_default_user_agent(user_agent: impl Into<String>) {
31    let lock =
32        DEFAULT_USER_AGENT.get_or_init(|| RwLock::new(BUILTIN_DEFAULT_USER_AGENT.to_owned()));
33    if let Ok(mut current) = lock.write() {
34        *current = user_agent.into();
35    }
36}
37
38/// Returns the process-wide default user-agent set via
39/// [`set_default_user_agent`], or the builtin default when none was set.
40///
41/// Used by [`HttpClientBuilder`] and by the engine's OAuth token requests so
42/// that all outbound traffic carries the same user-agent.
43pub(crate) fn default_user_agent() -> String {
44    DEFAULT_USER_AGENT
45        .get_or_init(|| RwLock::new(BUILTIN_DEFAULT_USER_AGENT.to_owned()))
46        .read()
47        .map_or_else(
48            |_| BUILTIN_DEFAULT_USER_AGENT.to_owned(),
49            |value| value.clone(),
50        )
51}
52
53/// Serializes unit tests that mutate the process-wide default user-agent so
54/// they cannot observe one another's writes. Integration tests in
55/// `tests/foundation.rs` run in a separate binary and use their own lock.
56#[cfg(test)]
57pub(crate) static UA_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
58
59/// Restores the process-wide default user-agent to the builtin on drop, so a
60/// panicking assertion in a test that mutates it cannot leak the value into
61/// later tests in this binary. Declare it after acquiring [`UA_TEST_LOCK`] so
62/// the reset runs while the lock is still held.
63#[cfg(test)]
64pub(crate) struct RestoreDefaultUserAgent;
65
66#[cfg(test)]
67impl Drop for RestoreDefaultUserAgent {
68    fn drop(&mut self) {
69        set_default_user_agent(BUILTIN_DEFAULT_USER_AGENT);
70    }
71}
72
73static DEFAULT_TRANSPORT_LOGGER: OnceLock<RwLock<Arc<dyn TransportLogger>>> = OnceLock::new();
74
75fn default_transport_logger_lock() -> &'static RwLock<Arc<dyn TransportLogger>> {
76    DEFAULT_TRANSPORT_LOGGER.get_or_init(|| RwLock::new(Arc::new(NoopTransportLogger)))
77}
78
79/// Sets the process-wide default transport logger for outbound HTTP traffic.
80///
81/// Applies to subsequently created [`HttpClient`] values (those that do not set
82/// their own via [`HttpClientBuilder::logger`]) and to the free
83/// [`super::debug_log_reqwest_request`] / [`super::debug_log_reqwest_response`]
84/// helpers used by code that talks to `reqwest` directly.
85///
86/// The CLI installs a logger from this setter when `--debug` selects the
87/// `transport` component, so command handlers get request/response diagnostics
88/// without any per-command wiring. A per-client logger still overrides it for
89/// that client.
90pub fn set_default_transport_logger(logger: Arc<dyn TransportLogger>) {
91    // Recover from a poisoned lock (a panic while a writer held it) instead of
92    // silently doing nothing, which would leave a stale logger installed and
93    // make `--debug transport` appear ineffective.
94    let mut current = default_transport_logger_lock()
95        .write()
96        .unwrap_or_else(std::sync::PoisonError::into_inner);
97    *current = logger;
98}
99
100/// Returns the process-wide default transport logger set via
101/// [`set_default_transport_logger`], or a [`NoopTransportLogger`] when none was
102/// set.
103#[must_use]
104pub fn default_transport_logger() -> Arc<dyn TransportLogger> {
105    default_transport_logger_lock()
106        .read()
107        .unwrap_or_else(std::sync::PoisonError::into_inner)
108        .clone()
109}
110
111/// Logs a `reqwest::Request` to the process-wide default transport logger.
112///
113/// This is the bridge for code that talks to `reqwest` directly — bare clients
114/// or progenitor-generated clients that cannot use [`HttpClient`] — so a single
115/// `--debug`-controlled trace can still cover them. Captures the request method,
116/// URL, headers, and in-memory body. Pairs with [`debug_log_reqwest_response`].
117/// It is a no-op (no header clone or body copy) unless an enabled logger has
118/// been installed via [`set_default_transport_logger`].
119pub fn debug_log_reqwest_request(request: &reqwest::Request) {
120    let logger = default_transport_logger();
121    if !logger.enabled() {
122        return;
123    }
124    logger.debug(&TransportLogEvent {
125        message: "http request",
126        fields: BTreeMap::from([
127            ("method".to_owned(), request.method().as_str().to_owned()),
128            ("url".to_owned(), request.url().as_str().to_owned()),
129        ]),
130        headers: Some(header_pairs(request.headers())),
131        body: request
132            .body()
133            .and_then(reqwest::Body::as_bytes)
134            .map(<[u8]>::to_vec),
135    });
136}
137
138/// Logs an HTTP response (status, headers, body) to the process-wide default
139/// transport logger.
140///
141/// Companion to [`debug_log_reqwest_request`] for `reqwest`-direct call sites.
142/// The caller passes the already-read response body. It is a no-op (no header
143/// clone or body copy) unless an enabled logger has been installed via
144/// [`set_default_transport_logger`].
145pub fn debug_log_reqwest_response(status: StatusCode, headers: &header::HeaderMap, body: &[u8]) {
146    let logger = default_transport_logger();
147    if !logger.enabled() {
148        return;
149    }
150    logger.debug(&TransportLogEvent {
151        message: "http response",
152        fields: BTreeMap::from([("status".to_owned(), status.as_u16().to_string())]),
153        headers: Some(header_pairs(headers)),
154        body: Some(body.to_vec()),
155    });
156}
157
158#[derive(serde::Deserialize)]
159struct GraphQlError {
160    message: String,
161}
162
163#[derive(Default, serde::Deserialize)]
164struct GraphQlEnvelope {
165    data: Option<Value>,
166    #[serde(default)]
167    errors: Vec<GraphQlError>,
168}
169
170/// Structured debug event emitted by [`TransportLogger`].
171///
172/// `message` and `fields` are the stable breadcrumb surface (method, url,
173/// status, retry attempt). `headers` and `body` carry the raw, un-redacted
174/// request or response payload when one is available; loggers that print these
175/// (such as [`StderrTransportLogger`](super::StderrTransportLogger)) are
176/// responsible for redacting sensitive headers.
177#[derive(Clone, Debug, Default)]
178pub struct TransportLogEvent {
179    /// Event name such as `http request` or `retrying request`.
180    pub message: &'static str,
181    /// Stable event fields.
182    pub fields: BTreeMap<String, String>,
183    /// Raw header name/value pairs for the request or response, when known.
184    pub headers: Option<Vec<(String, String)>>,
185    /// Raw request or response body bytes, when captured. Streaming and
186    /// byte-download responses omit this and report a `body_bytes` field
187    /// instead to avoid buffering large payloads into the log.
188    pub body: Option<Vec<u8>>,
189}
190
191/// Debug logger interface for transport events.
192pub trait TransportLogger: Send + Sync + std::fmt::Debug {
193    /// Records one debug event.
194    fn debug(&self, event: &TransportLogEvent);
195
196    /// Whether this logger records anything.
197    ///
198    /// Defaults to `true`. The transport checks this before capturing request
199    /// and response headers/bodies, so a logger that returns `false` (such as
200    /// [`NoopTransportLogger`]) keeps the common non-debug path free of those
201    /// clones.
202    fn enabled(&self) -> bool {
203        true
204    }
205}
206
207/// Logger that intentionally drops transport events.
208#[derive(Clone, Debug, Default)]
209pub struct NoopTransportLogger;
210
211impl TransportLogger for NoopTransportLogger {
212    fn debug(&self, _event: &TransportLogEvent) {}
213
214    fn enabled(&self) -> bool {
215        false
216    }
217}
218
219/// Authenticated HTTP client for CLI command implementations.
220///
221/// The client covers the transport behavior command authors usually need: auth
222/// injection, JSON request/response helpers, structured HTTP errors,
223/// idempotent retries, ETag helpers, raw streaming helpers, multipart helpers,
224/// and GraphQL envelope decoding.
225#[derive(Clone, Debug)]
226pub struct HttpClient {
227    base: reqwest::Client,
228    base_url: String,
229    auth: Arc<dyn AuthInjector>,
230    user_agent: String,
231    default_headers: BTreeMap<String, String>,
232    logger: Arc<dyn TransportLogger>,
233}
234
235/// Builder for [`HttpClient`].
236#[derive(Clone, Debug)]
237pub struct HttpClientBuilder {
238    base_url: String,
239    auth: Arc<dyn AuthInjector>,
240    user_agent: String,
241    default_headers: BTreeMap<String, String>,
242    logger: Arc<dyn TransportLogger>,
243}
244
245impl HttpClientBuilder {
246    /// Creates a builder with a base URL and auth injector.
247    #[must_use]
248    pub fn new(base_url: impl Into<String>, auth: Arc<dyn AuthInjector>) -> Self {
249        Self {
250            base_url: base_url.into(),
251            auth,
252            user_agent: default_user_agent(),
253            default_headers: BTreeMap::new(),
254            logger: default_transport_logger(),
255        }
256    }
257
258    /// Sets the user-agent for this client.
259    #[must_use]
260    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
261        self.user_agent = user_agent.into();
262        self
263    }
264
265    /// Alias for [`HttpClientBuilder::user_agent`] for migration readability.
266    #[must_use]
267    pub fn with_user_agent(self, user_agent: impl Into<String>) -> Self {
268        self.user_agent(user_agent)
269    }
270
271    /// Sets headers sent on every request.
272    #[must_use]
273    pub fn default_headers(mut self, headers: BTreeMap<String, String>) -> Self {
274        self.default_headers = headers;
275        self
276    }
277
278    /// Alias for [`HttpClientBuilder::default_headers`] for migration readability.
279    #[must_use]
280    pub fn with_default_headers(self, headers: BTreeMap<String, String>) -> Self {
281        self.default_headers(headers)
282    }
283
284    /// Sets the transport debug logger.
285    #[must_use]
286    pub fn logger(mut self, logger: Arc<dyn TransportLogger>) -> Self {
287        self.logger = logger;
288        self
289    }
290
291    /// Alias for [`HttpClientBuilder::logger`] for migration readability.
292    #[must_use]
293    pub fn with_logger(self, logger: Arc<dyn TransportLogger>) -> Self {
294        self.logger(logger)
295    }
296
297    /// Builds the client.
298    #[must_use]
299    pub fn build(self) -> HttpClient {
300        HttpClient {
301            base: reqwest::Client::new(),
302            base_url: self.base_url,
303            auth: self.auth,
304            user_agent: self.user_agent,
305            default_headers: self.default_headers,
306            logger: self.logger,
307        }
308    }
309}
310
311impl HttpClient {
312    /// Creates a client builder.
313    #[must_use]
314    pub fn builder(base_url: impl Into<String>, auth: Arc<dyn AuthInjector>) -> HttpClientBuilder {
315        HttpClientBuilder::new(base_url, auth)
316    }
317
318    /// Creates a client with default settings.
319    #[must_use]
320    pub fn new(base_url: impl Into<String>, auth: Arc<dyn AuthInjector>) -> Self {
321        HttpClientBuilder::new(base_url, auth).build()
322    }
323
324    /// Sends GET and decodes a JSON response.
325    pub async fn get<T: Default + DeserializeOwned>(&self, path: &str) -> Result<T> {
326        self.do_json(Method::GET, path, Option::<&()>::None).await
327    }
328
329    /// Sends GET and checks only for success.
330    pub async fn get_without_response(&self, path: &str) -> Result<()> {
331        self.do_empty(Method::GET, path, Option::<&()>::None).await
332    }
333
334    /// Sends POST with a JSON body and decodes a JSON response.
335    pub async fn post<B: Serialize, T: Default + DeserializeOwned>(
336        &self,
337        path: &str,
338        body: &B,
339    ) -> Result<T> {
340        self.do_json(Method::POST, path, Some(body)).await
341    }
342
343    /// Sends POST with a JSON body and checks only for success.
344    pub async fn post_without_response<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
345        self.do_empty(Method::POST, path, Some(body)).await
346    }
347
348    /// Sends PUT with a JSON body and decodes a JSON response.
349    pub async fn put<B: Serialize, T: Default + DeserializeOwned>(
350        &self,
351        path: &str,
352        body: &B,
353    ) -> Result<T> {
354        self.do_json(Method::PUT, path, Some(body)).await
355    }
356
357    /// Sends PUT with a JSON body and checks only for success.
358    pub async fn put_without_response<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
359        self.do_empty(Method::PUT, path, Some(body)).await
360    }
361
362    /// Sends PATCH with a JSON body and decodes a JSON response.
363    pub async fn patch<B: Serialize, T: Default + DeserializeOwned>(
364        &self,
365        path: &str,
366        body: &B,
367    ) -> Result<T> {
368        self.do_json(Method::PATCH, path, Some(body)).await
369    }
370
371    /// Sends PATCH with a JSON body and checks only for success.
372    pub async fn patch_without_response<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
373        self.do_empty(Method::PATCH, path, Some(body)).await
374    }
375
376    /// Sends DELETE and checks for success.
377    pub async fn delete(&self, path: &str) -> Result<()> {
378        self.do_empty(Method::DELETE, path, Option::<&()>::None)
379            .await
380    }
381
382    /// Sends DELETE with a JSON body and checks for success.
383    pub async fn delete_with_body<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
384        self.do_empty(Method::DELETE, path, Some(body)).await
385    }
386
387    /// Sends GET and returns decoded JSON plus the ETag header.
388    pub async fn get_etag<T: Default + DeserializeOwned>(&self, path: &str) -> Result<(T, String)> {
389        let response = self.send_get_status_only_retry(path).await?;
390        let etag = response
391            .headers()
392            .get(header::ETAG)
393            .and_then(|value| value.to_str().ok())
394            .unwrap_or_default()
395            .to_owned();
396        let value = self.decode_json_response(response, "GET", path).await?;
397        Ok((value, etag))
398    }
399
400    /// Sends GET and returns only the ETag header after checking success.
401    pub async fn get_etag_without_response(&self, path: &str) -> Result<String> {
402        let response = self.send_get_status_only_retry(path).await?;
403        let etag = response
404            .headers()
405            .get(header::ETAG)
406            .and_then(|value| value.to_str().ok())
407            .unwrap_or_default()
408            .to_owned();
409        self.ensure_success_response(response, "GET", path).await?;
410        Ok(etag)
411    }
412
413    /// Sends PUT with `If-Match` and decodes a JSON response.
414    pub async fn put_if_match<B: Serialize, T: Default + DeserializeOwned>(
415        &self,
416        path: &str,
417        body: &B,
418        etag: &str,
419    ) -> Result<T> {
420        let response = self.send_put_if_match(path, body, etag).await?;
421        self.decode_json_response(response, "PUT", path).await
422    }
423
424    /// Sends PUT with `If-Match` and checks only for success.
425    pub async fn put_if_match_without_response<B: Serialize>(
426        &self,
427        path: &str,
428        body: &B,
429        etag: &str,
430    ) -> Result<()> {
431        let response = self.send_put_if_match(path, body, etag).await?;
432        self.ensure_success_response(response, "PUT", path).await
433    }
434
435    /// Streams a raw GET response body into a writer.
436    pub async fn get_raw(&self, path: &str, writer: &mut dyn Write) -> Result<()> {
437        let response = self.send_get_raw_status_only_retry(path).await?;
438        let (status, bytes) = self
439            .read_and_log_response(response, "GET", path, false)
440            .await?;
441        if status.is_client_error() || status.is_server_error() {
442            return Err(
443                parse_error_body(status, &String::from_utf8_lossy(&bytes), "GET", path).into(),
444            );
445        }
446        writer.write_all(&bytes)?;
447        Ok(())
448    }
449
450    /// Sends GET and returns the raw response body as bytes.
451    pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
452        let response = self.send_get_raw_status_only_retry(path).await?;
453        let (status, bytes) = self
454            .read_and_log_response(response, "GET", path, false)
455            .await?;
456        if status.is_client_error() || status.is_server_error() {
457            return Err(
458                parse_error_body(status, &String::from_utf8_lossy(&bytes), "GET", path).into(),
459            );
460        }
461        Ok(bytes.to_vec())
462    }
463
464    /// Sends POST and streams the raw response body into a writer.
465    pub async fn post_raw<B: Serialize>(
466        &self,
467        path: &str,
468        body: Option<&B>,
469        writer: &mut dyn Write,
470    ) -> Result<()> {
471        let response = self.send_post_raw_once(path, body).await?;
472        let (status, bytes) = self
473            .read_and_log_response(response, "POST", path, false)
474            .await?;
475        if status.is_client_error() || status.is_server_error() {
476            return Err(
477                parse_error_body(status, &String::from_utf8_lossy(&bytes), "POST", path).into(),
478            );
479        }
480        writer.write_all(&bytes)?;
481        Ok(())
482    }
483
484    /// Sends a raw-body request and decodes a JSON response.
485    pub async fn do_raw<T: Default + DeserializeOwned>(
486        &self,
487        method: Method,
488        path: &str,
489        content_type: &str,
490        body: impl Into<Vec<u8>>,
491    ) -> Result<T> {
492        self.do_raw_optional_body(method, path, content_type, Some(body.into()))
493            .await
494    }
495
496    /// Sends an optional raw-body request and decodes a JSON response.
497    pub async fn do_raw_optional_body<T: Default + DeserializeOwned>(
498        &self,
499        method: Method,
500        path: &str,
501        content_type: &str,
502        body: Option<Vec<u8>>,
503    ) -> Result<T> {
504        let method_text = method.as_str().to_owned();
505        let response = self.send_raw_once(method, path, content_type, body).await?;
506        self.decode_json_response(response, &method_text, path)
507            .await
508    }
509
510    /// Sends a raw-body request and checks only for success.
511    pub async fn do_raw_without_response(
512        &self,
513        method: Method,
514        path: &str,
515        content_type: &str,
516        body: impl Into<Vec<u8>>,
517    ) -> Result<()> {
518        self.do_raw_optional_body_without_response(method, path, content_type, Some(body.into()))
519            .await
520    }
521
522    /// Sends an optional raw-body request and checks only for success.
523    pub async fn do_raw_optional_body_without_response(
524        &self,
525        method: Method,
526        path: &str,
527        content_type: &str,
528        body: Option<Vec<u8>>,
529    ) -> Result<()> {
530        let method_text = method.as_str().to_owned();
531        let response = self.send_raw_once(method, path, content_type, body).await?;
532        self.ensure_success_response(response, &method_text, path)
533            .await
534    }
535
536    /// Sends a multipart file upload and decodes a JSON response.
537    pub async fn post_multipart<T: Default + DeserializeOwned>(
538        &self,
539        path: &str,
540        field_name: &str,
541        file_path: &Path,
542    ) -> Result<T> {
543        self.post_multipart_with_fields(path, field_name, file_path, &BTreeMap::new())
544            .await
545    }
546
547    /// Sends a multipart file upload and checks only for success.
548    pub async fn post_multipart_without_response(
549        &self,
550        path: &str,
551        field_name: &str,
552        file_path: &Path,
553    ) -> Result<()> {
554        self.post_multipart_with_fields_without_response(
555            path,
556            field_name,
557            file_path,
558            &BTreeMap::new(),
559        )
560        .await
561    }
562
563    /// Sends a multipart file upload with fields and decodes a JSON response.
564    pub async fn post_multipart_with_fields<T: Default + DeserializeOwned>(
565        &self,
566        path: &str,
567        file_field: &str,
568        file_path: &Path,
569        fields: &BTreeMap<String, String>,
570    ) -> Result<T> {
571        let form = self.multipart_form(file_field, file_path, fields).await?;
572        self.send_multipart(path, form).await
573    }
574
575    async fn multipart_form(
576        &self,
577        file_field: &str,
578        file_path: &Path,
579        fields: &BTreeMap<String, String>,
580    ) -> Result<reqwest::multipart::Form> {
581        let mut form = reqwest::multipart::Form::new();
582        for (key, value) in fields {
583            form = form.text(key.clone(), value.clone());
584        }
585        let file_name = file_path
586            .file_name()
587            .and_then(|name| name.to_str())
588            .unwrap_or("file")
589            .to_owned();
590        let bytes = tokio::fs::read(file_path)
591            .await
592            .map_err(|err| CliCoreError::message(format!("transport: open file: {err}")))?;
593        let part = reqwest::multipart::Part::bytes(bytes).file_name(file_name);
594        form = form.part(file_field.to_owned(), part);
595        Ok(form)
596    }
597
598    /// Sends a multipart file upload with fields and checks only for success.
599    pub async fn post_multipart_with_fields_without_response(
600        &self,
601        path: &str,
602        file_field: &str,
603        file_path: &Path,
604        fields: &BTreeMap<String, String>,
605    ) -> Result<()> {
606        let form = self.multipart_form(file_field, file_path, fields).await?;
607        self.send_multipart_without_response(path, form).await
608    }
609
610    /// Sends multipart form fields without a file and decodes a JSON response.
611    pub async fn post_multipart_fields<T: Default + DeserializeOwned>(
612        &self,
613        path: &str,
614        fields: &BTreeMap<String, String>,
615    ) -> Result<T> {
616        let mut form = reqwest::multipart::Form::new();
617        for (key, value) in fields {
618            form = form.text(key.clone(), value.clone());
619        }
620        self.send_multipart(path, form).await
621    }
622
623    /// Sends multipart form fields without a file and checks only for success.
624    pub async fn post_multipart_fields_without_response(
625        &self,
626        path: &str,
627        fields: &BTreeMap<String, String>,
628    ) -> Result<()> {
629        let mut form = reqwest::multipart::Form::new();
630        for (key, value) in fields {
631            form = form.text(key.clone(), value.clone());
632        }
633        self.send_multipart_without_response(path, form).await
634    }
635
636    /// Sends a GraphQL request and decodes the `data` envelope into a value.
637    pub async fn post_graphql<T: DeserializeOwned + Default>(
638        &self,
639        path: &str,
640        query: &str,
641        variables: BTreeMap<String, Value>,
642    ) -> Result<T> {
643        self.post_graphql_optional_variables(path, query, Some(variables))
644            .await
645    }
646
647    /// Sends a GraphQL request with optional variables and decodes `data`.
648    pub async fn post_graphql_optional_variables<T: DeserializeOwned + Default>(
649        &self,
650        path: &str,
651        query: &str,
652        variables: Option<BTreeMap<String, Value>>,
653    ) -> Result<T> {
654        let mut result = T::default();
655        self.post_graphql_optional_variables_into(path, query, variables, &mut result)
656            .await?;
657        Ok(result)
658    }
659
660    /// Sends a GraphQL request and checks only for GraphQL/HTTP success.
661    pub async fn post_graphql_without_response(
662        &self,
663        path: &str,
664        query: &str,
665        variables: BTreeMap<String, Value>,
666    ) -> Result<()> {
667        self.post_graphql_optional_variables_without_response(path, query, Some(variables))
668            .await
669    }
670
671    /// Sends a GraphQL request with optional variables and checks only for success.
672    pub async fn post_graphql_optional_variables_without_response(
673        &self,
674        path: &str,
675        query: &str,
676        variables: Option<BTreeMap<String, Value>>,
677    ) -> Result<()> {
678        self.post_graphql_response_envelope(path, query, variables)
679            .await?;
680        Ok(())
681    }
682
683    /// Sends a GraphQL request and decodes `data` into an existing value.
684    pub async fn post_graphql_into<T: DeserializeOwned>(
685        &self,
686        path: &str,
687        query: &str,
688        variables: BTreeMap<String, Value>,
689        result: &mut T,
690    ) -> Result<()> {
691        self.post_graphql_optional_variables_into(path, query, Some(variables), result)
692            .await
693    }
694
695    /// Sends a GraphQL request with optional variables and decodes into an existing value.
696    pub async fn post_graphql_optional_variables_into<T: DeserializeOwned>(
697        &self,
698        path: &str,
699        query: &str,
700        variables: Option<BTreeMap<String, Value>>,
701        result: &mut T,
702    ) -> Result<()> {
703        let envelope = self
704            .post_graphql_response_envelope(path, query, variables)
705            .await?;
706        if let Some(data) = envelope.data
707            && !data.is_null()
708        {
709            *result = serde_json::from_value(data).map_err(|err| {
710                CliCoreError::message(format!("transport: decode graphql data: {err}"))
711            })?;
712        }
713        Ok(())
714    }
715
716    async fn do_json<B: Serialize, T: Default + DeserializeOwned>(
717        &self,
718        method: Method,
719        path: &str,
720        body: Option<&B>,
721    ) -> Result<T> {
722        let method_text = method.as_str().to_owned();
723        let response = self.send_with_retry(method, path, body).await?;
724        self.decode_json_response(response, &method_text, path)
725            .await
726    }
727
728    async fn post_graphql_response_envelope(
729        &self,
730        path: &str,
731        query: &str,
732        variables: Option<BTreeMap<String, Value>>,
733    ) -> Result<GraphQlEnvelope> {
734        #[derive(Serialize)]
735        struct Request<'query> {
736            query: &'query str,
737            variables: Option<BTreeMap<String, Value>>,
738        }
739
740        let envelope: GraphQlEnvelope = self.post(path, &Request { query, variables }).await?;
741        if !envelope.errors.is_empty() {
742            let message = envelope
743                .errors
744                .iter()
745                .map(|error| error.message.as_str())
746                .collect::<Vec<_>>()
747                .join("; ");
748            return Err(CliCoreError::message(format!("graphql: {message}")));
749        }
750        Ok(envelope)
751    }
752
753    async fn send_put_if_match<B: Serialize>(
754        &self,
755        path: &str,
756        body: &B,
757        etag: &str,
758    ) -> Result<reqwest::Response> {
759        let mut request = self
760            .build_request(Method::PUT, path, Some(body))?
761            .header(header::IF_MATCH, etag)
762            .build()
763            .map_err(|err| CliCoreError::message(format!("transport: create request: {err}")))?;
764        self.inject_auth(&mut request).await?;
765        self.log_request(&request);
766        self.base
767            .execute(request)
768            .await
769            .map_err(|err| CliCoreError::message(format!("transport: PUT {path}: {err}")))
770    }
771
772    async fn send_multipart<T: Default + DeserializeOwned>(
773        &self,
774        path: &str,
775        form: reqwest::multipart::Form,
776    ) -> Result<T> {
777        let response = self.send_multipart_response(path, form).await?;
778        self.decode_json_response(response, "POST", path).await
779    }
780
781    async fn send_multipart_without_response(
782        &self,
783        path: &str,
784        form: reqwest::multipart::Form,
785    ) -> Result<()> {
786        let response = self.send_multipart_response(path, form).await?;
787        self.ensure_success_response(response, "POST", path).await
788    }
789
790    async fn send_multipart_response(
791        &self,
792        path: &str,
793        form: reqwest::multipart::Form,
794    ) -> Result<reqwest::Response> {
795        let url = format!("{}{}", self.base_url, path);
796        let mut builder = self
797            .base
798            .post(url)
799            .header(header::USER_AGENT, self.user_agent.clone())
800            .multipart(form);
801        for (key, value) in &self.default_headers {
802            builder = builder.header(key, value);
803        }
804        let mut request = builder
805            .build()
806            .map_err(|err| CliCoreError::message(format!("transport: create request: {err}")))?;
807        self.inject_auth(&mut request).await?;
808        self.log_request(&request);
809        self.base
810            .execute(request)
811            .await
812            .map_err(|err| CliCoreError::message(format!("transport: POST {path}: {err}")))
813    }
814
815    async fn do_empty<B: Serialize>(
816        &self,
817        method: Method,
818        path: &str,
819        body: Option<&B>,
820    ) -> Result<()> {
821        let method_text = method.as_str().to_owned();
822        let response = self.send_with_retry(method, path, body).await?;
823        self.ensure_success_response(response, &method_text, path)
824            .await
825    }
826
827    async fn send_raw_once(
828        &self,
829        method: Method,
830        path: &str,
831        content_type: &str,
832        body: Option<Vec<u8>>,
833    ) -> Result<reqwest::Response> {
834        let url = format!("{}{}", self.base_url, path);
835        let method_text = method.as_str().to_owned();
836        let mut builder = self
837            .base
838            .request(method, url)
839            .header(header::USER_AGENT, self.user_agent.clone());
840        if let Some(body) = body {
841            builder = builder.body(body);
842        }
843        if !content_type.is_empty() {
844            builder = builder.header(header::CONTENT_TYPE, content_type);
845        }
846        for (key, value) in &self.default_headers {
847            builder = builder.header(key, value);
848        }
849        let mut request = builder
850            .build()
851            .map_err(|err| CliCoreError::message(format!("transport: create request: {err}")))?;
852        self.inject_auth(&mut request).await?;
853        self.log_request(&request);
854        self.base
855            .execute(request)
856            .await
857            .map_err(|err| CliCoreError::message(format!("transport: {method_text} {path}: {err}")))
858    }
859
860    async fn send_get_raw_status_only_retry(&self, path: &str) -> Result<reqwest::Response> {
861        let mut last_err = None;
862        for attempt in 0..MAX_RETRIES {
863            if attempt > 0 {
864                let backoff = BASE_BACKOFF * 2_u32.pow(u32::try_from(attempt - 1).unwrap_or(0));
865                time::sleep(backoff).await;
866            }
867
868            match self.send_get_raw_once(path).await {
869                Ok(response) => {
870                    let status = response.status();
871                    if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
872                        self.log_response("GET", path, status, response.headers(), None, None);
873                        last_err = Some(CliCoreError::message(format!(
874                            "transport: GET {}: status {}",
875                            path,
876                            status.as_u16()
877                        )));
878                        continue;
879                    }
880                    return Ok(response);
881                }
882                Err(err) => last_err = Some(err),
883            }
884        }
885        Err(last_err.unwrap_or_else(|| CliCoreError::message("transport: retry failed")))
886    }
887
888    async fn send_get_raw_once(&self, path: &str) -> Result<reqwest::Response> {
889        let url = format!("{}{}", self.base_url, path);
890        let mut builder = self
891            .base
892            .get(url)
893            .header(header::USER_AGENT, self.user_agent.clone());
894        for (key, value) in &self.default_headers {
895            builder = builder.header(key, value);
896        }
897        let mut request = builder
898            .build()
899            .map_err(|err| CliCoreError::message(format!("transport: create request: {err}")))?;
900        self.inject_auth(&mut request).await?;
901        self.log_request(&request);
902        self.base
903            .execute(request)
904            .await
905            .map_err(|err| CliCoreError::message(format!("transport: GET {path}: {err}")))
906    }
907
908    async fn send_post_raw_once<B: Serialize>(
909        &self,
910        path: &str,
911        body: Option<&B>,
912    ) -> Result<reqwest::Response> {
913        let mut request = self
914            .build_request(Method::POST, path, body)?
915            .build()
916            .map_err(|err| CliCoreError::message(format!("transport: create request: {err}")))?;
917        self.inject_auth(&mut request).await?;
918        self.log_request(&request);
919        self.base
920            .execute(request)
921            .await
922            .map_err(|err| CliCoreError::message(format!("transport: POST {path}: {err}")))
923    }
924
925    async fn send_with_retry<B: Serialize>(
926        &self,
927        method: Method,
928        path: &str,
929        body: Option<&B>,
930    ) -> Result<reqwest::Response> {
931        let mut last_err = None;
932        for attempt in 0..MAX_RETRIES {
933            if attempt > 0 {
934                let backoff = BASE_BACKOFF * 2_u32.pow(u32::try_from(attempt - 1).unwrap_or(0));
935                self.log_debug(
936                    "retrying request",
937                    [
938                        ("attempt", (attempt + 1).to_string()),
939                        ("backoff", format!("{backoff:?}")),
940                    ],
941                );
942                time::sleep(backoff).await;
943            }
944
945            match self.send_once(method.clone(), path, body).await {
946                Ok(response) => {
947                    if retryable_status(method.clone(), response.status()) {
948                        last_err = Some(
949                            self.retryable_status_error(response, method.as_str(), path)
950                                .await,
951                        );
952                        continue;
953                    }
954                    return Ok(response);
955                }
956                Err(err) if is_idempotent(&method) => {
957                    last_err = Some(err);
958                }
959                Err(err) => return Err(err),
960            }
961        }
962        Err(last_err.unwrap_or_else(|| CliCoreError::message("transport: retry failed")))
963    }
964
965    async fn send_get_status_only_retry(&self, path: &str) -> Result<reqwest::Response> {
966        let mut last_err = None;
967        for attempt in 0..MAX_RETRIES {
968            if attempt > 0 {
969                let backoff = BASE_BACKOFF * 2_u32.pow(u32::try_from(attempt - 1).unwrap_or(0));
970                self.log_debug(
971                    "retrying request",
972                    [
973                        ("attempt", (attempt + 1).to_string()),
974                        ("backoff", format!("{backoff:?}")),
975                    ],
976                );
977                time::sleep(backoff).await;
978            }
979
980            match self.send_once(Method::GET, path, Option::<&()>::None).await {
981                Ok(response) => {
982                    let status = response.status();
983                    if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
984                        self.log_response("GET", path, status, response.headers(), None, None);
985                        last_err = Some(CliCoreError::message(format!(
986                            "transport: GET {}: status {}",
987                            path,
988                            status.as_u16()
989                        )));
990                        continue;
991                    }
992                    return Ok(response);
993                }
994                Err(err) => last_err = Some(err),
995            }
996        }
997        Err(last_err.unwrap_or_else(|| CliCoreError::message("transport: retry failed")))
998    }
999
1000    async fn send_once<B: Serialize>(
1001        &self,
1002        method: Method,
1003        path: &str,
1004        body: Option<&B>,
1005    ) -> Result<reqwest::Response> {
1006        let mut request = self
1007            .build_request(method.clone(), path, body)?
1008            .build()
1009            .map_err(|err| CliCoreError::message(format!("transport: create request: {err}")))?;
1010        self.inject_auth(&mut request).await?;
1011        let method_text = method.as_str().to_owned();
1012        self.log_request(&request);
1013        self.base
1014            .execute(request)
1015            .await
1016            .map_err(|err| CliCoreError::message(format!("transport: {method_text} {path}: {err}")))
1017    }
1018
1019    fn build_request<B: Serialize>(
1020        &self,
1021        method: Method,
1022        path: &str,
1023        body: Option<&B>,
1024    ) -> Result<reqwest::RequestBuilder> {
1025        let url = format!("{}{}", self.base_url, path);
1026        let mut builder = self
1027            .base
1028            .request(method, url)
1029            .header(header::USER_AGENT, self.user_agent.clone());
1030        if let Some(body) = body {
1031            let body = serde_json::to_vec(body)
1032                .map_err(|err| CliCoreError::message(format!("transport: marshal body: {err}")))?;
1033            builder = builder
1034                .header(header::CONTENT_TYPE, "application/json")
1035                .body(body);
1036        }
1037        for (key, value) in &self.default_headers {
1038            builder = builder.header(key, value);
1039        }
1040        Ok(builder)
1041    }
1042
1043    fn log_debug(
1044        &self,
1045        message: &'static str,
1046        fields: impl IntoIterator<Item = (&'static str, String)>,
1047    ) {
1048        if !self.logger.enabled() {
1049            return;
1050        }
1051        self.logger.debug(&TransportLogEvent {
1052            message,
1053            fields: fields
1054                .into_iter()
1055                .map(|(key, value)| (key.to_owned(), value))
1056                .collect(),
1057            headers: None,
1058            body: None,
1059        });
1060    }
1061
1062    /// Emits an `http request` event capturing the built request's headers and
1063    /// in-memory body. Streaming bodies (e.g. multipart) report no body.
1064    ///
1065    /// Skips capture entirely when the logger is disabled, so the non-debug path
1066    /// does not clone headers or copy request bodies.
1067    fn log_request(&self, request: &reqwest::Request) {
1068        if !self.logger.enabled() {
1069            return;
1070        }
1071        self.logger.debug(&TransportLogEvent {
1072            message: "http request",
1073            fields: BTreeMap::from([
1074                ("method".to_owned(), request.method().as_str().to_owned()),
1075                ("url".to_owned(), request.url().as_str().to_owned()),
1076            ]),
1077            headers: Some(header_pairs(request.headers())),
1078            body: request
1079                .body()
1080                .and_then(reqwest::Body::as_bytes)
1081                .map(<[u8]>::to_vec),
1082        });
1083    }
1084
1085    /// Emits an `http response` event. When `body` is `None`, `body_bytes`
1086    /// records the payload size instead (used for raw/byte-download paths so
1087    /// large responses are not buffered into the log).
1088    fn log_response(
1089        &self,
1090        method: &str,
1091        path: &str,
1092        status: StatusCode,
1093        headers: &header::HeaderMap,
1094        body: Option<&[u8]>,
1095        body_bytes: Option<usize>,
1096    ) {
1097        if !self.logger.enabled() {
1098            return;
1099        }
1100        let mut fields = BTreeMap::from([
1101            ("status".to_owned(), status.as_u16().to_string()),
1102            ("method".to_owned(), method.to_owned()),
1103            ("url".to_owned(), format!("{}{}", self.base_url, path)),
1104        ]);
1105        if let Some(len) = body_bytes {
1106            fields.insert("body_bytes".to_owned(), len.to_string());
1107        }
1108        self.logger.debug(&TransportLogEvent {
1109            message: "http response",
1110            fields,
1111            headers: Some(header_pairs(headers)),
1112            body: body.map(<[u8]>::to_vec),
1113        });
1114    }
1115
1116    /// Reads a response body once, emits the `http response` event, and returns
1117    /// the status and buffered bytes. `include_body` controls whether the body
1118    /// is attached to the log or only its size is reported.
1119    ///
1120    /// Returns the body as [`Bytes`] (a cheap clone of the buffer `reqwest`
1121    /// already owns) so callers decode without an extra copy. When the logger is
1122    /// disabled, response headers are not cloned and no event is built.
1123    async fn read_and_log_response(
1124        &self,
1125        response: reqwest::Response,
1126        method: &str,
1127        path: &str,
1128        include_body: bool,
1129    ) -> Result<(StatusCode, Bytes)> {
1130        let status = response.status();
1131        let logging = self.logger.enabled();
1132        let headers = logging.then(|| response.headers().clone());
1133        let body = response.bytes().await.map_err(|err| {
1134            CliCoreError::message(format!("transport: read response body: {err}"))
1135        })?;
1136        if let Some(headers) = headers {
1137            if include_body {
1138                self.log_response(method, path, status, &headers, Some(&body), None);
1139            } else {
1140                self.log_response(method, path, status, &headers, None, Some(body.len()));
1141            }
1142        }
1143        Ok((status, body))
1144    }
1145
1146    async fn inject_auth(&self, request: &mut reqwest::Request) -> Result<()> {
1147        self.auth
1148            .inject(request)
1149            .await
1150            .map_err(|err| CliCoreError::message(format!("transport: auth inject: {err}")))
1151    }
1152
1153    async fn decode_json_response<T: Default + DeserializeOwned>(
1154        &self,
1155        response: reqwest::Response,
1156        method: &str,
1157        path: &str,
1158    ) -> Result<T> {
1159        let (status, body) = self
1160            .read_and_log_response(response, method, path, true)
1161            .await?;
1162        if status.is_client_error() || status.is_server_error() {
1163            return Err(
1164                parse_error_body(status, &String::from_utf8_lossy(&body), method, path).into(),
1165            );
1166        }
1167        if status == StatusCode::NO_CONTENT {
1168            return Ok(T::default());
1169        }
1170        if body.trim_ascii() == b"null" {
1171            return Ok(T::default());
1172        }
1173        serde_json::from_slice::<T>(&body)
1174            .map_err(|err| CliCoreError::message(format!("transport: decode response: {err}")))
1175    }
1176
1177    async fn ensure_success_response(
1178        &self,
1179        response: reqwest::Response,
1180        method: &str,
1181        path: &str,
1182    ) -> Result<()> {
1183        // A `*_without_response` call discards the body, so the body is only
1184        // needed to build an error message or to feed the logger. When neither
1185        // applies (non-error status, logging disabled), skip buffering it —
1186        // matching the pre-logging behavior of not reading success bodies.
1187        let is_error = response.status().is_client_error() || response.status().is_server_error();
1188        if !is_error && !self.logger.enabled() {
1189            return Ok(());
1190        }
1191        let (status, body) = self
1192            .read_and_log_response(response, method, path, true)
1193            .await?;
1194        if status.is_client_error() || status.is_server_error() {
1195            return Err(
1196                parse_error_body(status, &String::from_utf8_lossy(&body), method, path).into(),
1197            );
1198        }
1199        Ok(())
1200    }
1201
1202    async fn retryable_status_error(
1203        &self,
1204        response: reqwest::Response,
1205        method: &str,
1206        path: &str,
1207    ) -> CliCoreError {
1208        let status = response.status();
1209        let headers = self.logger.enabled().then(|| response.headers().clone());
1210        match response.bytes().await {
1211            Ok(body) => {
1212                if let Some(headers) = &headers {
1213                    self.log_response(method, path, status, headers, Some(&body), None);
1214                }
1215                CliCoreError::message(format!(
1216                    "transport: {method} {path}: status {}: {}",
1217                    status.as_u16(),
1218                    String::from_utf8_lossy(&body)
1219                ))
1220            }
1221            Err(err) => {
1222                if let Some(headers) = &headers {
1223                    self.log_response(method, path, status, headers, None, None);
1224                }
1225                CliCoreError::message(format!(
1226                    "transport: {method} {path}: status {} (body read failed: {err})",
1227                    status.as_u16()
1228                ))
1229            }
1230        }
1231    }
1232}
1233
1234/// Converts a `reqwest` header map into owned name/value pairs for logging.
1235///
1236/// Header values that are not valid UTF-8 are rendered as a byte-count
1237/// placeholder rather than dropped, so the trace still shows the header exists.
1238fn header_pairs(headers: &header::HeaderMap) -> Vec<(String, String)> {
1239    headers
1240        .iter()
1241        .map(|(name, value)| {
1242            let value = value.to_str().map_or_else(
1243                |_| format!("<{} non-utf8 bytes>", value.as_bytes().len()),
1244                str::to_owned,
1245            );
1246            (name.as_str().to_owned(), value)
1247        })
1248        .collect()
1249}
1250
1251/// Converts a non-success HTTP response into the shared transport error shape.
1252///
1253/// If the response body already contains an API-style error document, the
1254/// service message is preserved and the HTTP status is normalized into the
1255/// error code. Otherwise the method, path, status, and response body are folded
1256/// into a readable fallback message.
1257pub async fn parse_error_response(response: reqwest::Response, method: &str, path: &str) -> Error {
1258    let status = response.status();
1259    let body = response.text().await.unwrap_or_default();
1260    parse_error_body(status, &body, method, path)
1261}
1262
1263fn parse_error_body(status: StatusCode, body: &str, method: &str, path: &str) -> Error {
1264    if let Ok(mut api_error) = serde_json::from_str::<Error>(body)
1265        && !api_error.message.is_empty()
1266    {
1267        api_error.code = format!("HTTP_{}", status.as_u16());
1268        return api_error;
1269    }
1270    Error {
1271        code: format!("HTTP_{}", status.as_u16()),
1272        message: format!("{} {}: {} {}", method, path, status.as_u16(), body),
1273        system: String::new(),
1274        request_id: String::new(),
1275    }
1276}
1277
1278fn retryable_status(method: Method, status: StatusCode) -> bool {
1279    status == StatusCode::TOO_MANY_REQUESTS || (status.is_server_error() && is_idempotent(&method))
1280}
1281
1282fn is_idempotent(method: &Method) -> bool {
1283    matches!(*method, Method::GET | Method::HEAD | Method::DELETE)
1284}