Skip to main content

zero_engine_client/
http.rs

1//! HTTP client for the engine REST surface.
2//!
3//! Semantics (spec §7, ADR-002):
4//!
5//! - 8 s per-attempt timeout.
6//! - Retry **once** on transport errors, 502, 503, 504, with a fixed
7//!   500 ms backoff. All other statuses fail immediately with the
8//!   body carried.
9//! - Bearer token applied per request when present.
10//! - All typed helpers go through [`Self::get_json`] so retry / auth
11//!   / error mapping lives in exactly one place.
12
13use std::time::Duration;
14
15use reqwest::StatusCode;
16use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19use thiserror::Error;
20use zero_operator_state::{Event as OperatorEvent, Snapshot as OperatorSnapshot};
21
22use crate::models::{
23    ApproachingFeed, AutoToggleRequest, AutoToggleResponse, Brief, Evaluation, ExecuteRequest,
24    ExecuteResponse, Health, HyperliquidAccount, HyperliquidReconciliation, HyperliquidStatus,
25    ImmuneReport, LiveCanaryPolicy, LiveCertification, LiveCockpit, LiveControlResponse,
26    LiveEvidence, LiveExecutionReceipts, LivePreflight, MarketQuote, OperatorContext,
27    OperatorEventsAccepted, Positions, Pulse, Regime, RejectionsFeed, Risk, Root, RuntimeParity,
28    V2Status,
29};
30use crate::rate_budget::{self, RateBudget};
31
32const TIMEOUT: Duration = Duration::from_secs(8);
33const RETRY_DELAY: Duration = Duration::from_millis(500);
34
35#[derive(Debug, Error)]
36pub enum HttpError {
37    #[error("engine unreachable — {0}")]
38    Unreachable(String),
39    #[error("timeout after {0:?}")]
40    Timeout(Duration),
41    #[error("auth rejected (401/403)")]
42    Unauthorized,
43    #[error("not found: {path}")]
44    NotFound { path: String },
45    #[error("http {status}: {body}")]
46    Status { status: StatusCode, body: String },
47    #[error("decode: {0}")]
48    Decode(String),
49    #[error("url: {0}")]
50    Url(#[from] url::ParseError),
51    /// Either the CLI-side [`RateBudget`] was exhausted before the
52    /// request ran (common case — the operator is typing faster
53    /// than the bucket refills), or the engine's own limiter
54    /// returned 429 (rare case — usually means two CLIs / an Auto
55    /// agent / a Telegram bot are sharing the operator's bucket).
56    ///
57    /// `retry_after` is a floor-rounded `Duration`. `source` is
58    /// one of the two strings `"cli-budget"` or `"engine-429"` so
59    /// logs can differentiate even though the operator-visible
60    /// render is identical — from the operator's seat, both
61    /// failures should read as "rate: exhausted — retry in Ns"
62    /// because telling them "your local bucket vs. the engine's
63    /// bucket" is a distinction without a difference.
64    // `origin` (not `source`) — `thiserror` reserves the field name
65    // `source` for the `Error::source()` chain; a plain data field
66    // by that name gets pulled into trait-bound inference.
67    //
68    // `Display` is operator-targeted, not programmer-targeted. The
69    // command-line handlers forward this through
70    // `format!("{}: {e}", name)` onto a single TUI pane row; the
71    // shape we want the operator to read is "rate: exhausted —
72    // retry in 3s", not a `Duration { secs: 3, nanos: 0 }` dump.
73    // [`format_retry_after`] does the dumb right thing (whole
74    // seconds, or ">1h" when `Duration::MAX`). The origin is
75    // elided from the operator-facing string (logs carry it via
76    // `Debug`) because the CLI-vs-engine distinction is never
77    // actionable for the operator — the correct response is
78    // identical either way (wait).
79    #[error("rate: exhausted — retry in {}", format_retry_after(*.retry_after))]
80    RateBudgetExhausted {
81        retry_after: Duration,
82        origin: RateLimitSource,
83    },
84}
85
86/// Where a [`HttpError::RateBudgetExhausted`] originated. Rendered
87/// as a terse tag in log lines; never user-visible directly.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum RateLimitSource {
90    /// The CLI's local [`RateBudget`] refused the call before it
91    /// left the process. Bucket already debited the `retry_after`
92    /// against its own refill math.
93    CliBudget,
94    /// The engine's limiter returned 429. The client refunded its
95    /// own bucket and re-packaged the engine's `Retry-After` into
96    /// the returned duration.
97    Engine429,
98}
99
100impl std::fmt::Display for RateLimitSource {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(match self {
103            Self::CliBudget => "cli-budget",
104            Self::Engine429 => "engine-429",
105        })
106    }
107}
108
109/// HTTP client bound to one engine.
110///
111/// Holds an optional [`RateBudget`]. When `None`, the client is
112/// unthrottled — appropriate for narrow test paths that want to
113/// exercise transport behavior without budget interference. In
114/// production the client is always built with a budget attached;
115/// the `with_rate_budget` builder method is the canonical on-ramp.
116#[derive(Debug, Clone)]
117pub struct HttpClient {
118    base_url: url::Url,
119    token: Option<String>,
120    inner: reqwest::Client,
121    rate_budget: Option<RateBudget>,
122    /// Engine-mode override attached to every outgoing request
123    /// via the `X-Zero-Mode` header. `None` means "respect the
124    /// engine's launch-time mode" (no header emitted, the legacy
125    /// path). [`Mode::Paper`] / [`Mode::Live`] are emitted verbatim
126    /// — the header is the only per-invocation override surface
127    /// and M2_PLAN §5/§7 pins the exact wire shape.
128    mode: Option<Mode>,
129    /// Optional operator identity headers attached to every request.
130    /// The engine treats these as local audit context, never auth:
131    /// auth remains the bearer token / deployment boundary. This
132    /// lets team runs, coding agents, and design engineers leave a
133    /// clear operator trail without putting secrets into payloads.
134    operator: Option<OperatorRequestContext>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct OperatorRequestContext {
139    pub operator_id: String,
140    pub handle: String,
141    pub role: String,
142    pub scope: String,
143}
144
145impl OperatorRequestContext {
146    #[must_use]
147    pub fn local(handle: impl Into<String>) -> Self {
148        let handle = handle.into();
149        Self {
150            operator_id: handle.clone(),
151            handle,
152            role: "owner".to_string(),
153            scope: "local-private".to_string(),
154        }
155    }
156}
157
158/// Per-invocation engine-mode override. See [`HttpClient::with_mode`].
159///
160/// Named `Mode` rather than `EngineMode` so the import list on
161/// the adapter side stays short (`use zero_engine_client::Mode;`).
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum Mode {
164    Paper,
165    Live,
166}
167
168impl Mode {
169    /// Wire representation used in the `X-Zero-Mode` header.
170    /// Lowercase to match the header-value convention on the
171    /// engine side; deliberately kept narrow so a future
172    /// `Shadow` / `Replay` mode lands with an explicit parser
173    /// rather than a silent `to_ascii_lowercase` extension.
174    #[must_use]
175    pub const fn as_header_value(self) -> &'static str {
176        match self {
177            Self::Paper => "paper",
178            Self::Live => "live",
179        }
180    }
181}
182
183impl HttpClient {
184    /// Build a client for the given base URL. The URL must be
185    /// parseable as an absolute URL (no trailing path needed; joined
186    /// relative paths land under it). The returned client has **no**
187    /// rate budget attached — callers who want one chain
188    /// `.with_rate_budget(...)` (production path) or leave it off
189    /// (narrow test paths that want raw transport behavior).
190    pub fn new(base_url: impl AsRef<str>, token: Option<String>) -> Result<Self, HttpError> {
191        let base_url = url::Url::parse(base_url.as_ref())?;
192        let inner = reqwest::Client::builder()
193            .timeout(TIMEOUT)
194            .user_agent(concat!("zero-cli/", env!("CARGO_PKG_VERSION")))
195            .build()
196            .map_err(|e| HttpError::Unreachable(e.to_string()))?;
197        Ok(Self {
198            base_url,
199            token,
200            inner,
201            rate_budget: None,
202            mode: None,
203            operator: None,
204        })
205    }
206
207    /// Attach a per-invocation engine-mode override. Every
208    /// subsequent request carries `X-Zero-Mode: <value>`; the
209    /// engine honors the header per M2_PLAN §5 / §7. Passing
210    /// `Mode::Live` is explicit (`None` means "respect engine
211    /// launch mode"), so an operator invoking `zero --paper`
212    /// followed by a non-paper command inside the same TUI
213    /// session gets paper → live flipped via the adapter, not
214    /// via a header absence.
215    #[must_use]
216    pub fn with_mode(mut self, mode: Mode) -> Self {
217        self.mode = Some(mode);
218        self
219    }
220
221    /// Attach operator audit context headers to every request.
222    #[must_use]
223    pub fn with_operator_context(mut self, operator: OperatorRequestContext) -> Self {
224        self.operator = Some(operator);
225        self
226    }
227
228    /// Access the attached engine-mode override. The TUI status
229    /// bar + the doctor row use this so the mode breadcrumb is
230    /// rendered off the same source of truth the HTTP layer
231    /// will act on.
232    #[must_use]
233    pub const fn mode(&self) -> Option<Mode> {
234        self.mode
235    }
236
237    /// Attach a [`RateBudget`]. Every subsequent call consults the
238    /// budget (via [`rate_budget::cost_of`] on the request path)
239    /// before the request leaves the process. A `None` budget
240    /// (the default after [`Self::new`]) disables the whole layer.
241    #[must_use]
242    pub fn with_rate_budget(mut self, budget: RateBudget) -> Self {
243        self.rate_budget = Some(budget);
244        self
245    }
246
247    /// Access the attached [`RateBudget`], if any. The doctor row
248    /// and the status-bar widget use this to read a
249    /// [`crate::BudgetSnapshot`]; holding the reference rather
250    /// than cloning lets callers take a fresh snapshot on every
251    /// render.
252    #[must_use]
253    pub const fn rate_budget(&self) -> Option<&RateBudget> {
254        self.rate_budget.as_ref()
255    }
256
257    #[must_use]
258    pub fn base_url(&self) -> &url::Url {
259        &self.base_url
260    }
261
262    #[must_use]
263    pub fn has_token(&self) -> bool {
264        self.token.is_some()
265    }
266
267    fn url_for(&self, path: &str) -> Result<url::Url, HttpError> {
268        let path = path.trim_start_matches('/');
269        Ok(self.base_url.join(path)?)
270    }
271
272    fn auth_headers(&self) -> HeaderMap {
273        let mut headers = HeaderMap::new();
274        if let Some(token) = &self.token
275            && let Ok(v) = HeaderValue::from_str(&format!("Bearer {token}"))
276        {
277            headers.insert(AUTHORIZATION, v);
278        }
279        // M2 §5: `--paper` and `/auto` adapters plumb a
280        // per-invocation engine-mode override through this header.
281        // `HeaderValue::from_static` is safe here — every arm of
282        // `Mode::as_header_value` is ASCII.
283        if let Some(mode) = self.mode {
284            headers.insert(
285                HeaderName::from_static("x-zero-mode"),
286                HeaderValue::from_static(mode.as_header_value()),
287            );
288        }
289        if let Some(operator) = &self.operator {
290            insert_header_str(&mut headers, "x-zero-operator-id", &operator.operator_id);
291            insert_header_str(&mut headers, "x-zero-operator-handle", &operator.handle);
292            insert_header_str(&mut headers, "x-zero-operator-role", &operator.role);
293            insert_header_str(&mut headers, "x-zero-operator-scope", &operator.scope);
294        }
295        headers
296    }
297
298    /// Consult the attached [`RateBudget`] (if any) for `path`. On
299    /// exhaustion returns [`HttpError::RateBudgetExhausted`] shaped
300    /// as `CliBudget`; on success the bucket has been debited and
301    /// the caller may proceed to the network.
302    ///
303    /// Cost is resolved via [`rate_budget::cost_of`] so the client
304    /// and every out-of-band consumer (doctor row, status bar)
305    /// agree on pricing by construction.
306    fn check_rate_budget(&self, path: &str) -> Result<(), HttpError> {
307        let Some(budget) = &self.rate_budget else {
308            return Ok(());
309        };
310        let cost = rate_budget::cost_of(path);
311        budget.try_consume(cost).map_err(|exh| {
312            tracing::debug!(
313                path = %path,
314                cost = cost,
315                retry_after = ?exh.retry_after,
316                "cli rate budget exhausted",
317            );
318            HttpError::RateBudgetExhausted {
319                retry_after: exh.retry_after,
320                origin: RateLimitSource::CliBudget,
321            }
322        })
323    }
324
325    /// Refund the cost associated with `path` to the attached
326    /// [`RateBudget`] (if any). Called on the engine-429 path so
327    /// the local bucket is not double-charged when the engine's
328    /// own limiter fires.
329    fn refund_rate_budget(&self, path: &str) {
330        if let Some(budget) = &self.rate_budget {
331            budget.refund(rate_budget::cost_of(path));
332        }
333    }
334
335    /// GET a path and decode the JSON body into `T`.
336    ///
337    /// Order of operations:
338    /// 1. **Consult the rate budget.** Exhausted bucket → typed
339    ///    error, no network call. An operator hammering `/status`
340    ///    reads a typed refusal, not a silent stall.
341    /// 2. **Send once.** On retryable failure (502/503/504/
342    ///    transport/timeout): sleep `RETRY_DELAY`, send again. One
343    ///    retry only.
344    /// 3. **On 429 (engine's limiter, not ours):** refund the
345    ///    local bucket (we debited it in step 1) and return
346    ///    [`HttpError::RateBudgetExhausted`] shaped as `Engine429`
347    ///    with the engine's own `Retry-After` value parsed out.
348    ///
349    /// Auth failures (401 / 403) and 404 are mapped to dedicated
350    /// variants because the TUI renders them differently.
351    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
352        self.check_rate_budget(path)?;
353        let url = self.url_for(path)?;
354        let headers = self.auth_headers();
355
356        match self.send_once::<T>(url.clone(), headers.clone()).await {
357            Ok(t) => Ok(t),
358            Err(e) if is_retryable(&e) => {
359                tracing::debug!(%url, error = %e, "retrying after transient failure");
360                tokio::time::sleep(RETRY_DELAY).await;
361                match self.send_once::<T>(url, headers).await {
362                    Ok(t) => Ok(t),
363                    Err(e2) => Err(self.maybe_refund_for_429(path, e2)),
364                }
365            }
366            Err(e) => Err(self.maybe_refund_for_429(path, e)),
367        }
368    }
369
370    /// If `err` is an engine-originated 429 (already normalized
371    /// by `send_once`), refund the local bucket for `path` so the
372    /// operator is not double-charged — we debited our bucket in
373    /// `check_rate_budget` before the send, and the engine just
374    /// refused the request, meaning no work landed on their side.
375    /// Unrelated errors pass through unchanged.
376    fn maybe_refund_for_429(&self, path: &str, err: HttpError) -> HttpError {
377        if matches!(
378            err,
379            HttpError::RateBudgetExhausted {
380                origin: RateLimitSource::Engine429,
381                ..
382            }
383        ) {
384            self.refund_rate_budget(path);
385            tracing::debug!(
386                path = %path,
387                "engine returned 429; refunded local bucket",
388            );
389        }
390        err
391    }
392
393    async fn send_once<T: DeserializeOwned>(
394        &self,
395        url: url::Url,
396        headers: HeaderMap,
397    ) -> Result<T, HttpError> {
398        let resp = self
399            .inner
400            .get(url.clone())
401            .headers(headers)
402            .send()
403            .await
404            .map_err(|e| map_transport(&e))?;
405
406        let status = resp.status();
407        if status.is_success() {
408            return resp
409                .json::<T>()
410                .await
411                .map_err(|e| HttpError::Decode(e.to_string()));
412        }
413
414        match status {
415            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(HttpError::Unauthorized),
416            StatusCode::NOT_FOUND => Err(HttpError::NotFound {
417                path: url.path().to_string(),
418            }),
419            StatusCode::TOO_MANY_REQUESTS => {
420                // Pull the Retry-After header *before* consuming
421                // the body (reqwest can consume either in any
422                // order, but mixing them in one expression makes
423                // the dependency hard to see). Engine may send it
424                // as a number-of-seconds or an HTTP-date; either
425                // shape parses via `parse_retry_after`.
426                let retry_after = resp
427                    .headers()
428                    .get(reqwest::header::RETRY_AFTER)
429                    .and_then(|v| v.to_str().ok())
430                    .and_then(parse_retry_after)
431                    .unwrap_or(Duration::from_secs(1));
432                Err(HttpError::RateBudgetExhausted {
433                    retry_after,
434                    origin: RateLimitSource::Engine429,
435                })
436            }
437            _ => {
438                let body = resp.text().await.unwrap_or_default();
439                Err(HttpError::Status {
440                    status,
441                    body: truncate(&body, 512),
442                })
443            }
444        }
445    }
446
447    /// POST a JSON body to a path and decode the JSON response into `R`.
448    ///
449    /// Retry semantics mirror [`Self::get_json`]: one retry on 502/503/504/
450    /// transport/timeout with a 500 ms backoff. POSTs to `/operator/events`
451    /// are idempotent at the bus-adapter layer (the event-log is append-
452    /// only and the classifier replay is deterministic), so a retried
453    /// duplicate is a no-op in the worst case — an extra benign duplicate
454    /// in the event-log rather than a phantom trade. Any endpoint added
455    /// later that is **not** idempotent must not route through this
456    /// helper; the entire M2 spec's POST surface today is idempotent.
457    pub async fn post_json<B, R>(&self, path: &str, body: &B) -> Result<R, HttpError>
458    where
459        B: Serialize + ?Sized,
460        R: DeserializeOwned,
461    {
462        self.check_rate_budget(path)?;
463        let url = self.url_for(path)?;
464        let mut headers = self.auth_headers();
465        // Explicit `content-type` avoids a future reqwest behavior
466        // change silently flipping this to `application/octet-stream`.
467        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
468        let payload = serde_json::to_vec(body).map_err(|e| HttpError::Decode(e.to_string()))?;
469
470        match self
471            .post_once::<R>(url.clone(), headers.clone(), payload.clone())
472            .await
473        {
474            Ok(v) => Ok(v),
475            Err(e) if is_retryable(&e) => {
476                tracing::debug!(%url, error = %e, "retrying POST after transient failure");
477                tokio::time::sleep(RETRY_DELAY).await;
478                match self.post_once::<R>(url, headers, payload).await {
479                    Ok(v) => Ok(v),
480                    Err(e2) => Err(self.maybe_refund_for_429(path, e2)),
481                }
482            }
483            Err(e) => Err(self.maybe_refund_for_429(path, e)),
484        }
485    }
486
487    async fn post_once<R: DeserializeOwned>(
488        &self,
489        url: url::Url,
490        headers: HeaderMap,
491        body: Vec<u8>,
492    ) -> Result<R, HttpError> {
493        let resp = self
494            .inner
495            .post(url.clone())
496            .headers(headers)
497            .body(body)
498            .send()
499            .await
500            .map_err(|e| map_transport(&e))?;
501
502        let status = resp.status();
503        if status.is_success() {
504            return resp
505                .json::<R>()
506                .await
507                .map_err(|e| HttpError::Decode(e.to_string()));
508        }
509
510        match status {
511            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(HttpError::Unauthorized),
512            StatusCode::NOT_FOUND => Err(HttpError::NotFound {
513                path: url.path().to_string(),
514            }),
515            StatusCode::TOO_MANY_REQUESTS => {
516                let retry_after = resp
517                    .headers()
518                    .get(reqwest::header::RETRY_AFTER)
519                    .and_then(|v| v.to_str().ok())
520                    .and_then(parse_retry_after)
521                    .unwrap_or(Duration::from_secs(1));
522                Err(HttpError::RateBudgetExhausted {
523                    retry_after,
524                    origin: RateLimitSource::Engine429,
525                })
526            }
527            _ => {
528                let body = resp.text().await.unwrap_or_default();
529                Err(HttpError::Status {
530                    status,
531                    body: truncate(&body, 512),
532                })
533            }
534        }
535    }
536
537    /// POST a JSON body **without** any retry budget, optionally
538    /// attaching an `X-Idempotency-Key` header.
539    ///
540    /// Retry semantics (M2_PLAN §7):
541    ///
542    /// > `POST` endpoints never auto-retry (idempotency key
543    /// > compensates, but a silent retry of a live composition
544    /// > change is the single worst failure mode a trading CLI can
545    /// > have). Tests pin the no-retry rule against 5xx + timeout.
546    ///
547    /// Used by [`Self::post_execute`], [`Self::post_auto_toggle`],
548    /// and the `/live/*` control endpoints — every surface where a
549    /// silent duplicate would change operator or exchange state.
550    /// The contrast with [`Self::post_json`] (idempotent `POST
551    /// /operator/events`, retry-once is safe) is deliberate: any
552    /// future POST surface must pick its bucket explicitly.
553    ///
554    /// `idempotency_key`, when `Some`, lands as an
555    /// `X-Idempotency-Key: <value>` header **in addition to**
556    /// whatever shape the body carries. Engine-side proxies that
557    /// redact bodies but log headers still see the dedupe key;
558    /// callers who want to skip the header entirely pass `None`.
559    pub async fn post_json_no_retry<B, R>(
560        &self,
561        path: &str,
562        body: &B,
563        idempotency_key: Option<&str>,
564    ) -> Result<R, HttpError>
565    where
566        B: Serialize + ?Sized,
567        R: DeserializeOwned,
568    {
569        self.check_rate_budget(path)?;
570        let url = self.url_for(path)?;
571        let mut headers = self.auth_headers();
572        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
573        if let Some(key) = idempotency_key
574            && let Ok(v) = HeaderValue::from_str(key)
575        {
576            // Lowercase form matches every other header we emit
577            // (`authorization`, `content-type`, `x-zero-mode`) so
578            // log pattern-matching in the engine is consistent.
579            headers.insert(HeaderName::from_static("x-idempotency-key"), v);
580        }
581        let payload = serde_json::to_vec(body).map_err(|e| HttpError::Decode(e.to_string()))?;
582
583        // Single send, no retry even on 502/503/504/timeout. The
584        // error surfaces verbatim — the operator (or the TUI
585        // shell) decides whether a human retry is appropriate.
586        // Silent retry would re-open the exact failure mode
587        // this entire rule exists to prevent.
588        match self.post_once::<R>(url, headers, payload).await {
589            Ok(v) => Ok(v),
590            Err(e) => Err(self.maybe_refund_for_429(path, e)),
591        }
592    }
593
594    // ── Typed endpoints ────────────────────────────────────────────
595
596    /// `GET /` — unauthenticated version probe.
597    pub async fn root(&self) -> Result<Root, HttpError> {
598        self.get_json("/").await
599    }
600
601    /// `GET /health` — unauthenticated component heartbeat rollup.
602    pub async fn health(&self) -> Result<Health, HttpError> {
603        self.get_json("/health").await
604    }
605
606    /// `GET /hl/status[?symbol=...]` — read-only Hyperliquid info adapter status.
607    pub async fn hyperliquid_status(
608        &self,
609        symbol: Option<&str>,
610    ) -> Result<HyperliquidStatus, HttpError> {
611        match symbol {
612            Some(s) => {
613                let path = format!("/hl/status?symbol={}", urlencoding(s));
614                self.get_json(&path).await
615            }
616            None => self.get_json("/hl/status").await,
617        }
618    }
619
620    /// `GET /hl/account` — read-only Hyperliquid account truth.
621    pub async fn hyperliquid_account(&self) -> Result<HyperliquidAccount, HttpError> {
622        self.get_json("/hl/account").await
623    }
624
625    /// `GET /hl/reconcile` — local runtime versus Hyperliquid account state.
626    pub async fn hyperliquid_reconciliation(&self) -> Result<HyperliquidReconciliation, HttpError> {
627        self.get_json("/hl/reconcile").await
628    }
629
630    /// `GET /market/quote?symbol=...` — active quote source feeding paper mode.
631    pub async fn market_quote(&self, symbol: &str) -> Result<MarketQuote, HttpError> {
632        let path = format!("/market/quote?symbol={}", urlencoding(symbol));
633        self.get_json(&path).await
634    }
635
636    /// `GET /live/preflight` — non-secret live readiness gate.
637    pub async fn live_preflight(&self) -> Result<LivePreflight, HttpError> {
638        self.get_json("/live/preflight").await
639    }
640
641    /// `GET /live/certification` — dry-run live certification drills.
642    pub async fn live_certification(&self) -> Result<LiveCertification, HttpError> {
643        self.get_json("/live/certification").await
644    }
645
646    /// `GET /live/cockpit` — consolidated live-readiness operator packet.
647    pub async fn live_cockpit(&self) -> Result<LiveCockpit, HttpError> {
648        self.get_json("/live/cockpit").await
649    }
650
651    /// `GET /live/evidence` — public-safe hash-only live evidence bundle.
652    pub async fn live_evidence(&self) -> Result<LiveEvidence, HttpError> {
653        self.get_json("/live/evidence").await
654    }
655
656    /// `GET /live/canary-policy` — live canary readiness and proof policy.
657    pub async fn live_canary_policy(&self) -> Result<LiveCanaryPolicy, HttpError> {
658        self.get_json("/live/canary-policy").await
659    }
660
661    /// `GET /runtime/parity` — paper OODA plus disabled live-shadow parity report.
662    pub async fn runtime_parity(&self) -> Result<RuntimeParity, HttpError> {
663        self.get_json("/runtime/parity").await
664    }
665
666    /// `GET /live/receipts` — public-safe local execution receipt bundle.
667    pub async fn live_receipts(&self) -> Result<LiveExecutionReceipts, HttpError> {
668        self.get_json("/live/receipts").await
669    }
670
671    /// `GET /operator/context` — current operator audit identity.
672    pub async fn operator_context(&self) -> Result<OperatorContext, HttpError> {
673        self.get_json("/operator/context").await
674    }
675
676    /// `GET /immune` — risk-blocking immune and circuit-breaker state.
677    pub async fn immune(&self) -> Result<ImmuneReport, HttpError> {
678        self.get_json("/immune").await
679    }
680
681    /// `POST /live/heartbeat` — refresh the exchange-side dead-man switch.
682    pub async fn post_live_heartbeat(&self) -> Result<LiveControlResponse, HttpError> {
683        self.post_json_no_retry::<serde_json::Value, LiveControlResponse>(
684            "/live/heartbeat",
685            &serde_json::json!({}),
686            None,
687        )
688        .await
689    }
690
691    /// `POST /live/pause` — stop new risk-increasing live entries.
692    pub async fn post_live_pause(&self) -> Result<LiveControlResponse, HttpError> {
693        self.post_json_no_retry::<serde_json::Value, LiveControlResponse>(
694            "/live/pause",
695            &serde_json::json!({}),
696            None,
697        )
698        .await
699    }
700
701    /// `POST /live/resume` — resume risk-increasing live entries.
702    pub async fn post_live_resume(&self) -> Result<LiveControlResponse, HttpError> {
703        self.post_json_no_retry::<serde_json::Value, LiveControlResponse>(
704            "/live/resume",
705            &serde_json::json!({}),
706            None,
707        )
708        .await
709    }
710
711    /// `POST /live/kill` — activate kill switch and cancel open exchange orders.
712    pub async fn post_live_kill(&self) -> Result<LiveControlResponse, HttpError> {
713        self.post_json_no_retry::<serde_json::Value, LiveControlResponse>(
714            "/live/kill",
715            &serde_json::json!({}),
716            None,
717        )
718        .await
719    }
720
721    /// `POST /live/flatten` — submit reduce-only close orders for open positions.
722    pub async fn post_live_flatten(&self) -> Result<LiveControlResponse, HttpError> {
723        self.post_json_no_retry::<serde_json::Value, LiveControlResponse>(
724            "/live/flatten",
725            &serde_json::json!({}),
726            None,
727        )
728        .await
729    }
730
731    /// `GET /v2/status` — condensed engine summary for the status bar.
732    pub async fn v2_status(&self) -> Result<V2Status, HttpError> {
733        self.get_json("/v2/status").await
734    }
735
736    /// `GET /positions` — open positions for the authenticated operator.
737    pub async fn positions(&self) -> Result<Positions, HttpError> {
738        self.get_json("/positions").await
739    }
740
741    /// `GET /risk` — risk guardrail summary.
742    pub async fn risk(&self) -> Result<Risk, HttpError> {
743        self.get_json("/risk").await
744    }
745
746    /// `GET /regime` (whole-market) or `/regime?coin={coin}` (per-coin).
747    pub async fn regime(&self, coin: Option<&str>) -> Result<Regime, HttpError> {
748        match coin {
749            Some(c) => {
750                // The engine accepts `?coin=...`; we url-encode to
751                // tolerate any exotic ticker forms in the future.
752                let path = format!("/regime?coin={}", urlencoding(c));
753                self.get_json(&path).await
754            }
755            None => self.get_json("/regime").await,
756        }
757    }
758
759    /// `GET /brief` — morning / midday briefing.
760    pub async fn brief(&self) -> Result<Brief, HttpError> {
761        self.get_json("/brief").await
762    }
763
764    /// `GET /evaluate/{coin}` — per-coin gate verdict.
765    pub async fn evaluate(&self, coin: &str) -> Result<Evaluation, HttpError> {
766        let path = format!("/evaluate/{}", urlencoding(coin));
767        self.get_json(&path).await
768    }
769
770    /// `GET /pulse?limit=...` — live engine pulse feed.
771    pub async fn pulse(&self, limit: u32) -> Result<Pulse, HttpError> {
772        let limit = limit.clamp(1, 100);
773        let path = format!("/pulse?limit={limit}");
774        self.get_json(&path).await
775    }
776
777    /// `GET /approaching` — coins approaching entry gates.
778    pub async fn approaching(&self) -> Result<ApproachingFeed, HttpError> {
779        self.get_json("/approaching").await
780    }
781
782    /// `GET /operator/state` — operator behavioral state snapshot
783    /// (ADR-016). The classifier runs on the engine host; this call
784    /// is the CLI's only window into it. Returned payload is a
785    /// `zero_operator_state::Snapshot`.
786    pub async fn operator_state(&self) -> Result<OperatorSnapshot, HttpError> {
787        self.get_json("/operator/state").await
788    }
789
790    /// `POST /operator/events` — append one operator-state event to
791    /// the engine-side classifier log (ADR-016).
792    ///
793    /// The wire format is the canonical [`zero_operator_state::Event`]
794    /// tagged-union pinned by the cross-language golden-vector test
795    /// (`crates/zero-operator-state/tests/golden_vectors.rs`). Sending
796    /// via the typed `Event` rather than a hand-rolled JSON map is what
797    /// keeps operators honest: a future schema change breaks the
798    /// compile, not the runtime.
799    ///
800    /// The engine response carries the post-ingest classifier snapshot
801    /// so CLI callers can (a) confirm the event landed and (b) reflect
802    /// any resulting label/friction change without a second round trip.
803    /// Callers that only want a fire-and-forget tag can discard the
804    /// returned `OperatorEventsAccepted`.
805    ///
806    /// Retries are safe — see [`Self::post_json`] on idempotency.
807    pub async fn post_operator_event(
808        &self,
809        event: &OperatorEvent,
810    ) -> Result<OperatorEventsAccepted, HttpError> {
811        self.post_json("/operator/events", event).await
812    }
813
814    /// `POST /execute` — composition change (live-trade surface).
815    ///
816    /// Mints a fresh v4 idempotency key per call, embeds it into
817    /// the body, and mirrors it into an `X-Idempotency-Key` HTTP
818    /// header. The server-side dedupe window suppresses a second
819    /// `/execute` with the same key so a CLI retry after a
820    /// spurious timeout does not double-compose.
821    ///
822    /// **Never retries.** The caller sees the raw error. See
823    /// [`Self::post_json_no_retry`] for the policy rationale;
824    /// the short version is "silent retry of a live composition
825    /// change is the single worst failure mode a trading CLI can
826    /// have" (M2_PLAN §7).
827    ///
828    /// Paper vs. live is controlled by the `X-Zero-Mode` header,
829    /// which is attached automatically when the client was built
830    /// with [`Self::with_mode`]. The response's `simulated` flag
831    /// is engine-asserted — the CLI suffixes the operator-visible
832    /// line with `(paper)` when the engine says the fill was
833    /// simulated, not when the CLI "thinks" it's in paper mode.
834    // `side` vs. `size` is a pedantic similar-names trip, but the
835    // wire shape pins both names — renaming either one would make
836    // the call site read unfamiliarly vs. the engine-side handler.
837    #[allow(clippy::similar_names)]
838    pub async fn post_execute(
839        &self,
840        coin: &str,
841        side: crate::models::ExecuteSide,
842        size: f64,
843    ) -> Result<ExecuteResponse, HttpError> {
844        let idempotency_key = mint_idempotency_key();
845        let body = ExecuteRequest {
846            coin: coin.to_string(),
847            side,
848            size,
849            idempotency_key: idempotency_key.clone(),
850        };
851        self.post_json_no_retry::<ExecuteRequest, ExecuteResponse>(
852            "/execute",
853            &body,
854            Some(idempotency_key.as_str()),
855        )
856        .await
857    }
858
859    /// `POST /auto/toggle` — flip the engine's Auto-mode flag.
860    ///
861    /// **Never retries.** Same rationale as [`Self::post_execute`]:
862    /// the engine treats this as a composition-affecting call
863    /// because it changes whether subsequent `/plan` outputs
864    /// auto-accept. The response's `state` is the engine's
865    /// post-call truth, not the requested state — friction may
866    /// have refused the flip.
867    ///
868    /// The body is the small [`AutoToggleRequest`] envelope; no
869    /// idempotency key is emitted because the engine treats the
870    /// endpoint as naturally idempotent (flipping `on` twice is
871    /// a no-op). The no-retry rule still holds — a network
872    /// failure mid-flight leaves the state ambiguous, and the
873    /// correct response is an operator-visible alert, not a
874    /// silent duplicate.
875    pub async fn post_auto_toggle(&self, enabled: bool) -> Result<AutoToggleResponse, HttpError> {
876        let body = AutoToggleRequest { enabled };
877        self.post_json_no_retry::<AutoToggleRequest, AutoToggleResponse>(
878            "/auto/toggle",
879            &body,
880            None,
881        )
882        .await
883    }
884
885    /// `GET /rejections?limit=...[&coin=...]`.
886    pub async fn rejections(
887        &self,
888        limit: u32,
889        coin: Option<&str>,
890    ) -> Result<RejectionsFeed, HttpError> {
891        let limit = limit.clamp(1, 500);
892        let path = match coin {
893            Some(c) => format!("/rejections?limit={limit}&coin={}", urlencoding(c)),
894            None => format!("/rejections?limit={limit}"),
895        };
896        self.get_json(&path).await
897    }
898}
899
900/// Minimal URL-component encoder. We only need to escape the handful
901/// of characters that appear in symbols and operator-typed strings;
902/// pulling in `urlencoding` for this is overkill.
903fn urlencoding(s: &str) -> String {
904    use std::fmt::Write as _;
905    let mut out = String::with_capacity(s.len());
906    for b in s.bytes() {
907        match b {
908            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
909                out.push(char::from(b));
910            }
911            _ => {
912                // `write!` to `String` is infallible.
913                let _ = write!(out, "%{b:02X}");
914            }
915        }
916    }
917    out
918}
919
920fn map_transport(e: &reqwest::Error) -> HttpError {
921    if e.is_timeout() {
922        HttpError::Timeout(TIMEOUT)
923    } else {
924        HttpError::Unreachable(e.to_string())
925    }
926}
927
928fn is_retryable(e: &HttpError) -> bool {
929    // 429 is explicitly **never** retried automatically. The
930    // engine is saying "wait N seconds"; looping through that wait
931    // would either ignore it (wasted traffic, worse 429s) or
932    // freeze the caller silently — the exact mystery stall
933    // `RateBudgetExhausted` exists to prevent. It falls out of
934    // this function as part of the default-false arm, but calling
935    // it out in a doc line so a future reader doesn't try to
936    // "fix" the omission.
937    match e {
938        HttpError::Timeout(_) | HttpError::Unreachable(_) => true,
939        HttpError::Status { status, .. } => matches!(
940            *status,
941            StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT
942        ),
943        _ => false,
944    }
945}
946
947/// Parse an HTTP `Retry-After` header value.
948///
949/// Per RFC 9110 §10.2.3 the value is one of:
950/// - A delta-seconds integer (e.g. `120`).
951/// - An HTTP-date (RFC 7231 IMF-fixdate, e.g. `Fri, 31 Dec 1999
952///   23:59:59 GMT`), in which case the returned duration is the
953///   difference between that date and **now** (clamped to zero).
954///
955/// Unparseable values return `None`; the caller substitutes a
956/// safe default (today: 1 second) so a malformed header from an
957/// unknown upstream proxy cannot freeze the CLI.
958///
959/// Both shapes are exercised by unit tests in this module; the
960/// HTTP-date path uses `chrono::DateTime::parse_from_rfc2822` for
961/// the IMF-fixdate format.
962/// Render a `Duration` for operator consumption. Whole seconds
963/// only (sub-second precision is noise on a CLI); `Duration::MAX`
964/// (or anything longer than an hour — permanently-blocked shape)
965/// renders as `">1h"` so an operator does not stare at a 7-digit
966/// number trying to convert to wall time.
967#[must_use]
968pub(crate) fn format_retry_after(d: Duration) -> String {
969    let secs = d.as_secs();
970    if secs > 3600 {
971        ">1h".to_string()
972    } else {
973        format!("{secs}s")
974    }
975}
976
977#[must_use]
978pub(crate) fn parse_retry_after(value: &str) -> Option<Duration> {
979    let trimmed = value.trim();
980    if let Ok(secs) = trimmed.parse::<u64>() {
981        return Some(Duration::from_secs(secs));
982    }
983    // HTTP-date path — chrono's RFC-2822 parser accepts the
984    // RFC-7231 IMF-fixdate shape because IMF-fixdate is a strict
985    // subset of RFC-2822 with the GMT timezone pinned.
986    let target = chrono::DateTime::parse_from_rfc2822(trimmed).ok()?;
987    let now = chrono::Utc::now();
988    let delta = target.with_timezone(&chrono::Utc) - now;
989    // `to_std` fails if the delta is negative — in that case the
990    // date is in the past, so the effective retry-after is zero.
991    Some(delta.to_std().unwrap_or(Duration::ZERO))
992}
993
994fn truncate(s: &str, max: usize) -> String {
995    if s.len() <= max {
996        s.to_string()
997    } else {
998        format!("{}…", &s[..max])
999    }
1000}
1001
1002fn insert_header_str(headers: &mut HeaderMap, name: &'static str, value: &str) {
1003    if let Ok(v) = HeaderValue::from_str(value) {
1004        headers.insert(HeaderName::from_static(name), v);
1005    }
1006}
1007
1008/// Mint a fresh v4 UUID for use as an `/execute` idempotency key.
1009///
1010/// v4 (random) over v1 (time-based) deliberately: we want two
1011/// CLIs firing `/execute` at the same millisecond from the same
1012/// host to produce distinct keys without coordinating on a
1013/// counter. The engine-side dedupe window is short (seconds);
1014/// collision probability at that horizon is astronomically low
1015/// even across a fleet of operators, and v4 keeps the key a
1016/// pure random string with no embedded host / time signal.
1017///
1018/// Exposed at the module boundary (pub-in-crate) so the
1019/// integration tests can exercise the shape independently of the
1020/// `/execute` call path; the `/execute` helper is the sole
1021/// production caller.
1022#[must_use]
1023pub(crate) fn mint_idempotency_key() -> String {
1024    uuid::Uuid::new_v4().to_string()
1025}
1026
1027#[must_use]
1028pub const fn retry_delay() -> Duration {
1029    RETRY_DELAY
1030}
1031
1032#[must_use]
1033pub const fn timeout() -> Duration {
1034    TIMEOUT
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040
1041    #[test]
1042    fn retry_after_parses_plain_seconds() {
1043        assert_eq!(parse_retry_after("30"), Some(Duration::from_secs(30)));
1044        assert_eq!(parse_retry_after("  30  "), Some(Duration::from_secs(30)));
1045        assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1046    }
1047
1048    #[test]
1049    fn retry_after_parses_http_date_in_the_future() {
1050        // A date well in the future must yield a non-zero duration.
1051        // We don't pin the exact value because `chrono::Utc::now()`
1052        // is unavoidable here — just that the result is positive and
1053        // comfortably less than a year.
1054        let one_year_ahead = chrono::Utc::now() + chrono::Duration::days(365);
1055        let formatted = one_year_ahead
1056            .format("%a, %d %b %Y %H:%M:%S GMT")
1057            .to_string();
1058        let d = parse_retry_after(&formatted).expect("parseable");
1059        assert!(d > Duration::from_secs(86_400));
1060        assert!(d < Duration::from_secs(366 * 86_400));
1061    }
1062
1063    #[test]
1064    fn retry_after_clamps_past_date_to_zero() {
1065        let past = chrono::Utc::now() - chrono::Duration::days(3);
1066        let formatted = past.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
1067        assert_eq!(parse_retry_after(&formatted), Some(Duration::ZERO));
1068    }
1069
1070    #[test]
1071    fn retry_after_unparseable_returns_none() {
1072        assert_eq!(parse_retry_after("not-a-date"), None);
1073        assert_eq!(parse_retry_after(""), None);
1074    }
1075
1076    #[test]
1077    fn rate_limit_source_display_is_stable() {
1078        // Log consumers grep on these tags; rename = breaking.
1079        assert_eq!(format!("{}", RateLimitSource::CliBudget), "cli-budget");
1080        assert_eq!(format!("{}", RateLimitSource::Engine429), "engine-429");
1081    }
1082
1083    #[test]
1084    fn rate_budget_exhausted_display_is_terse_and_seconds() {
1085        // Copy-tested shape. Widened renders (origin tags, longer
1086        // nouns) belong in logs, not in the operator's pane row.
1087        let e = HttpError::RateBudgetExhausted {
1088            retry_after: Duration::from_secs(3),
1089            origin: RateLimitSource::CliBudget,
1090        };
1091        assert_eq!(format!("{e}"), "rate: exhausted — retry in 3s");
1092
1093        let e429 = HttpError::RateBudgetExhausted {
1094            retry_after: Duration::from_secs(45),
1095            origin: RateLimitSource::Engine429,
1096        };
1097        // CLI-vs-engine origin must be invisible to the operator.
1098        assert_eq!(format!("{e429}"), "rate: exhausted — retry in 45s");
1099
1100        let perma = HttpError::RateBudgetExhausted {
1101            retry_after: Duration::MAX,
1102            origin: RateLimitSource::CliBudget,
1103        };
1104        assert_eq!(format!("{perma}"), "rate: exhausted — retry in >1h");
1105    }
1106
1107    #[test]
1108    fn mode_header_value_is_stable() {
1109        // Engine-side log ingestion greps on these literal strings
1110        // via `X-Zero-Mode`. Any rename lands on the wire as a
1111        // mode regression — the test locks the exact bytes.
1112        assert_eq!(Mode::Paper.as_header_value(), "paper");
1113        assert_eq!(Mode::Live.as_header_value(), "live");
1114    }
1115
1116    #[test]
1117    fn with_mode_attaches_header_on_auth_headers() {
1118        // The `auth_headers` helper is the single request-assembly
1119        // site (verified by its call-sites in `get` / `post`); a
1120        // mode override on the client must surface there so every
1121        // request carries the header without an opt-in per call.
1122        let client = HttpClient::new("https://example.test", None)
1123            .expect("client")
1124            .with_mode(Mode::Paper);
1125        assert_eq!(client.mode(), Some(Mode::Paper));
1126        let headers = client.auth_headers();
1127        let got = headers
1128            .get("x-zero-mode")
1129            .expect("x-zero-mode header attached");
1130        assert_eq!(got.to_str().unwrap(), "paper");
1131
1132        // Default client emits no mode header — absence is how the
1133        // engine reads "respect launch-time mode."
1134        let unset = HttpClient::new("https://example.test", None).expect("client");
1135        assert!(unset.mode().is_none());
1136        assert!(unset.auth_headers().get("x-zero-mode").is_none());
1137    }
1138
1139    #[test]
1140    fn operator_context_attaches_audit_headers() {
1141        let client = HttpClient::new("https://example.test", None)
1142            .expect("client")
1143            .with_operator_context(OperatorRequestContext {
1144                operator_id: "team-alpha:alice".to_string(),
1145                handle: "alice".to_string(),
1146                role: "trader".to_string(),
1147                scope: "team-private".to_string(),
1148            });
1149
1150        let headers = client.auth_headers();
1151        assert_eq!(
1152            headers
1153                .get("x-zero-operator-id")
1154                .and_then(|v| v.to_str().ok()),
1155            Some("team-alpha:alice"),
1156        );
1157        assert_eq!(
1158            headers
1159                .get("x-zero-operator-handle")
1160                .and_then(|v| v.to_str().ok()),
1161            Some("alice"),
1162        );
1163        assert_eq!(
1164            headers
1165                .get("x-zero-operator-role")
1166                .and_then(|v| v.to_str().ok()),
1167            Some("trader"),
1168        );
1169        assert_eq!(
1170            headers
1171                .get("x-zero-operator-scope")
1172                .and_then(|v| v.to_str().ok()),
1173            Some("team-private"),
1174        );
1175    }
1176
1177    #[test]
1178    fn mint_idempotency_key_is_unique_per_call() {
1179        // Pins the "fresh key per call" rule at the unit level so a
1180        // future refactor that accidentally caches the key (e.g.
1181        // `OnceCell`) breaks here, not in a flaky integration test
1182        // that only sometimes trips on the dedupe window.
1183        let a = mint_idempotency_key();
1184        let b = mint_idempotency_key();
1185        assert_ne!(a, b, "successive calls must mint distinct keys");
1186        assert_eq!(a.len(), 36, "UUID v4 stringifies to 36 chars");
1187        assert_eq!(a.matches('-').count(), 4, "four hyphens in v4 form");
1188    }
1189
1190    #[test]
1191    fn is_retryable_never_retries_rate_budget_exhausted() {
1192        // Explicit negative: looping on 429 instead of surfacing
1193        // the typed refusal is the exact failure mode the
1194        // exhausted variant exists to prevent.
1195        let err = HttpError::RateBudgetExhausted {
1196            retry_after: Duration::from_secs(2),
1197            origin: RateLimitSource::Engine429,
1198        };
1199        assert!(!is_retryable(&err));
1200    }
1201}