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}