Skip to main content

pulse_client/
client.rs

1//! The `PulseClient` and its [`PulseClientBuilder`].
2
3use std::sync::Arc;
4use std::sync::RwLock;
5use std::time::Duration;
6
7use reqwest::Method;
8use reqwest::StatusCode;
9use serde::Serialize;
10use serde_json::Value;
11
12use crate::duplex::{derive_ws_url, DuplexChannel};
13use crate::error::PulseError;
14use crate::events::EventsResource;
15use crate::iq::IQResource;
16use crate::resources::{
17    AgentsResource, AuthResource, ConnectorsResource, ModelsResource, PipelinesResource,
18    TemplatesResource, UsersResource, WasmResource,
19};
20use crate::streams::StreamsResource;
21
22const USER_AGENT: &str = "pulse-client-rust/2.7.5";
23const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
24
25/// Async HTTP client for the Pulse REST API.
26///
27/// # Example
28///
29/// ```no_run
30/// use pulse_client::PulseClient;
31///
32/// # async fn run() -> Result<(), pulse_client::PulseError> {
33/// let client = PulseClient::builder()
34///     .base_url("http://localhost:9090")
35///     .build()?;
36///
37/// client.auth().login("alice", "secret").await?;
38///
39/// for pipeline in client.pipelines().list().await? {
40///     println!("{}", pipeline["name"]);
41/// }
42/// # Ok(())
43/// # }
44/// ```
45///
46/// # Thread safety
47///
48/// `PulseClient` is `Clone` and cheap to clone — the underlying reqwest client
49/// pools connections, and the token sits behind an `Arc<RwLock>`. Share a
50/// single instance across tasks.
51#[derive(Clone)]
52pub struct PulseClient {
53    pub(crate) inner: Arc<Inner>,
54}
55
56pub(crate) struct Inner {
57    pub(crate) base_url: String,
58    pub(crate) http: reqwest::Client,
59    pub(crate) token: RwLock<Option<String>>,
60    pub(crate) retry: RetryPolicy,
61}
62
63/// Opt-in automatic-retry policy. The default ([`RetryPolicy::default`]) has
64/// `max_retries == 0`, i.e. retries are **off** — the client makes exactly one
65/// attempt per request. Enable with [`PulseClientBuilder::retry`].
66#[derive(Clone, Debug)]
67pub struct RetryPolicy {
68    /// Maximum number of retries after the first attempt. `0` = off (default).
69    pub max_retries: u32,
70    /// Base backoff; the per-attempt ceiling is `backoff * 2^attempt`.
71    pub backoff: Duration,
72    /// Per-attempt backoff cap.
73    pub max_backoff: Duration,
74    /// Retryable 5xx statuses (default `502, 503, 504`).
75    pub on_status: Vec<u16>,
76    /// When `true`, also retries non-idempotent methods (POST/PATCH) on
77    /// 5xx/transport. Default `false` → only GET/HEAD/PUT/DELETE are retried on
78    /// those, so a POST create is never silently duplicated.
79    pub retry_non_idempotent: bool,
80}
81
82impl Default for RetryPolicy {
83    fn default() -> Self {
84        Self {
85            max_retries: 0, // off
86            backoff: Duration::from_millis(200),
87            max_backoff: Duration::from_secs(10),
88            on_status: vec![502, 503, 504],
89            retry_non_idempotent: false,
90        }
91    }
92}
93
94impl RetryPolicy {
95    /// A policy that retries up to `max_retries` times with otherwise-default
96    /// backoff (200ms base, 10s cap) and the default retryable statuses.
97    pub fn with_max_retries(max_retries: u32) -> Self {
98        Self {
99            max_retries,
100            ..Self::default()
101        }
102    }
103}
104
105fn is_idempotent(method: &Method) -> bool {
106    *method == Method::GET
107        || *method == Method::HEAD
108        || *method == Method::PUT
109        || *method == Method::DELETE
110        || *method == Method::OPTIONS
111}
112
113/// Full-jitter exponential backoff: a uniform delay in `[0, min(max_backoff,
114/// backoff * 2^attempt)]`. Uses sub-second wall-clock nanos as entropy so no
115/// `rand` dependency is added.
116fn backoff_delay(policy: &RetryPolicy, attempt: u32) -> Duration {
117    let base_ms = policy.backoff.as_millis() as u64;
118    let factor = 1u64 << attempt.min(20); // cap the shift to avoid overflow
119    let ceiling_ms = base_ms
120        .saturating_mul(factor)
121        .min(policy.max_backoff.as_millis() as u64);
122    if ceiling_ms == 0 {
123        return Duration::ZERO;
124    }
125    let entropy = std::time::SystemTime::now()
126        .duration_since(std::time::UNIX_EPOCH)
127        .map(|d| d.subsec_nanos() as u64)
128        .unwrap_or(0);
129    Duration::from_millis(entropy % (ceiling_ms + 1))
130}
131
132impl PulseClient {
133    pub fn builder() -> PulseClientBuilder {
134        PulseClientBuilder::default()
135    }
136
137    /// Returns the current bearer token, or `None` if none is set.
138    pub fn token(&self) -> Option<String> {
139        self.inner.token.read().ok().and_then(|guard| guard.clone())
140    }
141
142    /// Updates the bearer token used by subsequent authenticated requests.
143    /// Safe to call from multiple tasks concurrently.
144    pub fn set_token<S: Into<String>>(&self, token: S) {
145        if let Ok(mut guard) = self.inner.token.write() {
146            *guard = Some(token.into());
147        }
148    }
149
150    /// Clears the bearer token, effectively logging out the client.
151    pub fn clear_token(&self) {
152        if let Ok(mut guard) = self.inner.token.write() {
153            *guard = None;
154        }
155    }
156
157    // ------------------------------------------------------------------
158    // Resource accessors
159    // ------------------------------------------------------------------
160    pub fn auth(&self) -> AuthResource<'_> {
161        AuthResource { client: self }
162    }
163
164    pub fn pipelines(&self) -> PipelinesResource<'_> {
165        PipelinesResource { client: self }
166    }
167
168    pub fn agents(&self) -> AgentsResource<'_> {
169        AgentsResource { client: self }
170    }
171
172    pub fn templates(&self) -> TemplatesResource<'_> {
173        TemplatesResource { client: self }
174    }
175
176    pub fn users(&self) -> UsersResource<'_> {
177        UsersResource { client: self }
178    }
179
180    /// `client.models()` — B-112 embedded ML model registry (upload / list /
181    /// get / delete ONNX models scored by the streaming `ml_predict` operator).
182    pub fn models(&self) -> ModelsResource<'_> {
183        ModelsResource { client: self }
184    }
185
186    /// `client.wasm()` — B-110 sandboxed WASM module registry (upload / list /
187    /// get / delete WebAssembly modules run by the streaming `wasm` operator).
188    pub fn wasm(&self) -> WasmResource<'_> {
189        WasmResource { client: self }
190    }
191
192    /// `client.connectors()` — the connector catalogue (B-093 family + every
193    /// native / bridged connector); use a `subType` as a pipeline node `type`.
194    pub fn connectors(&self) -> ConnectorsResource<'_> {
195        ConnectorsResource { client: self }
196    }
197
198    pub fn events(&self) -> EventsResource<'_> {
199        EventsResource { client: self }
200    }
201
202    pub fn iq(&self) -> IQResource<'_> {
203        IQResource { client: self }
204    }
205
206    /// `client.streams()` — B-107 Kafka-Streams-like declarative DSL.
207    pub fn streams(&self) -> StreamsResource<'_> {
208        StreamsResource { client: self }
209    }
210
211    /// B-114 — open a bidirectional duplex channel to an agent.
212    ///
213    /// Streams events IN and receives the agent's correlated outputs OUT on a
214    /// single WebSocket — the synchronous-decision path (fraud, pricing, A/B
215    /// assignment). The endpoint runs on the Pulse WebSocket port (REST port
216    /// + 1); the URL is derived from this client's `base_url` + token.
217    ///
218    /// ```no_run
219    /// # use pulse_client::PulseClient;
220    /// # use serde_json::json;
221    /// # async fn run(client: &PulseClient) -> Result<(), pulse_client::PulseError> {
222    /// let mut ch = client.duplex("fraud-detector").await?;
223    /// let cid = ch.send(&json!({ "amount": 5000 }), Some("tx-1")).await?;
224    /// let output = ch.recv().await?;
225    /// assert_eq!(output.correlation_id, Some(cid));
226    /// ch.close().await?;
227    /// # Ok(())
228    /// # }
229    /// ```
230    ///
231    /// # Errors
232    ///
233    /// - [`PulseError::InvalidConfig`] if `agent_id` is blank.
234    /// - [`PulseError::Duplex`] on a WebSocket handshake / transport failure.
235    /// - [`PulseError::Validation`] if the server rejects the agent with an
236    ///   `error` frame on open.
237    pub async fn duplex(&self, agent_id: &str) -> Result<DuplexChannel, PulseError> {
238        if agent_id.trim().is_empty() {
239            return Err(PulseError::InvalidConfig(
240                "agent_id must be a non-empty string".to_string(),
241            ));
242        }
243        let token = self.token();
244        let url = derive_ws_url(&self.inner.base_url, agent_id, token.as_deref());
245        DuplexChannel::connect(url).await
246    }
247
248    /// Open a duplex channel at an explicit WebSocket URL, bypassing the
249    /// REST-port-+-1 derivation. Useful when the WebSocket endpoint sits
250    /// behind a separate gateway / hostname.
251    pub async fn duplex_at(&self, ws_url: impl Into<String>) -> Result<DuplexChannel, PulseError> {
252        DuplexChannel::connect(ws_url.into()).await
253    }
254
255    /// `GET /api/pulse/version` — public, no JWT required. Returns the
256    /// Pulse server's build + version metadata.
257    pub async fn version(&self) -> Result<Value, PulseError> {
258        self.request(Method::GET, "/api/pulse/version", None::<&()>, false)
259            .await
260    }
261
262    // ------------------------------------------------------------------
263    // Internal: request execution + error translation
264    // ------------------------------------------------------------------
265    /// Runs [`request_once`](Self::request_once) under the opt-in retry policy.
266    /// With retries off (the default) this is exactly one attempt.
267    pub(crate) async fn request<B: Serialize + ?Sized>(
268        &self,
269        method: Method,
270        path: &str,
271        body: Option<&B>,
272        authenticated: bool,
273    ) -> Result<Value, PulseError> {
274        let mut attempt: u32 = 0;
275        loop {
276            let result = self
277                .request_once(method.clone(), path, body, authenticated)
278                .await;
279            let err = match &result {
280                Ok(_) => return result,
281                Err(e) => e,
282            };
283            if attempt >= self.inner.retry.max_retries {
284                return result;
285            }
286            match self.retry_delay(&method, err, attempt) {
287                Some(delay) => {
288                    tokio::time::sleep(delay).await;
289                    attempt += 1;
290                }
291                None => return result,
292            }
293        }
294    }
295
296    /// Returns `Some(delay)` when `err` is retryable for `method` at this
297    /// attempt, else `None`. 429 → any method (honour Retry-After); `on_status`
298    /// 5xx + transport → idempotent methods only (unless `retry_non_idempotent`).
299    fn retry_delay(&self, method: &Method, err: &PulseError, attempt: u32) -> Option<Duration> {
300        let policy = &self.inner.retry;
301        if let PulseError::RateLimit {
302            retry_after_seconds,
303            ..
304        } = err
305        {
306            // 429: rejected, never processed → safe to retry any method.
307            return Some(
308                retry_after_seconds
309                    .map(|s| Duration::from_secs(s as u64))
310                    .unwrap_or_else(|| backoff_delay(policy, attempt)),
311            );
312        }
313        if !is_idempotent(method) && !policy.retry_non_idempotent {
314            return None;
315        }
316        match err {
317            PulseError::Api { status, .. } if policy.on_status.contains(status) => {
318                Some(backoff_delay(policy, attempt))
319            }
320            PulseError::Transport(_) => Some(backoff_delay(policy, attempt)),
321            _ => None,
322        }
323    }
324
325    async fn request_once<B: Serialize + ?Sized>(
326        &self,
327        method: Method,
328        path: &str,
329        body: Option<&B>,
330        authenticated: bool,
331    ) -> Result<Value, PulseError> {
332        let url = format!("{}{path}", self.inner.base_url);
333        let mut req = self.inner.http.request(method, url);
334
335        if authenticated {
336            match self.token() {
337                Some(token) if !token.is_empty() => {
338                    req = req.bearer_auth(token);
339                }
340                _ => {
341                    return Err(PulseError::NoToken {
342                        path: path.to_string(),
343                    });
344                }
345            }
346        }
347
348        if let Some(payload) = body {
349            req = req.json(payload);
350        }
351
352        let response = req.send().await?;
353        let status = response.status();
354
355        if status == StatusCode::NO_CONTENT {
356            return Ok(Value::Object(Default::default()));
357        }
358
359        if status.is_success() {
360            // Read body; empty body → empty object so callers can `.get()`
361            let bytes = response.bytes().await?;
362            if bytes.is_empty() {
363                return Ok(Value::Object(Default::default()));
364            }
365            return Ok(serde_json::from_slice(&bytes)?);
366        }
367
368        // Non-success — translate to a typed error
369        let retry_after_header = response
370            .headers()
371            .get(reqwest::header::RETRY_AFTER)
372            .and_then(|v| v.to_str().ok())
373            .and_then(|s| s.trim().parse::<u32>().ok());
374
375        let bytes = response.bytes().await?;
376        let parsed_body: Option<Value> = if bytes.is_empty() {
377            None
378        } else {
379            match serde_json::from_slice::<Value>(&bytes) {
380                Ok(v) => Some(v),
381                Err(_) => {
382                    let raw = String::from_utf8_lossy(&bytes);
383                    let trimmed = if raw.len() > 200 { &raw[..200] } else { &raw };
384                    Some(serde_json::json!({ "error": trimmed }))
385                }
386            }
387        };
388
389        Err(translate_error(
390            status,
391            path,
392            parsed_body,
393            retry_after_header,
394        ))
395    }
396
397    /// B-112 — issue a `multipart/form-data` POST (the ML model-upload path).
398    ///
399    /// Shares the auth + error-translation logic of [`request`](Self::request)
400    /// but sends a pre-built [`reqwest::multipart::Form`] instead of a JSON
401    /// body. Always authenticated.
402    pub(crate) async fn request_multipart(
403        &self,
404        path: &str,
405        form: reqwest::multipart::Form,
406    ) -> Result<Value, PulseError> {
407        let url = format!("{}{path}", self.inner.base_url);
408        let token = match self.token() {
409            Some(token) if !token.is_empty() => token,
410            _ => {
411                return Err(PulseError::NoToken {
412                    path: path.to_string(),
413                });
414            }
415        };
416
417        let response = self
418            .inner
419            .http
420            .request(Method::POST, url)
421            .bearer_auth(token)
422            .multipart(form)
423            .send()
424            .await?;
425        let status = response.status();
426
427        if status == StatusCode::NO_CONTENT {
428            return Ok(Value::Object(Default::default()));
429        }
430        if status.is_success() {
431            let bytes = response.bytes().await?;
432            if bytes.is_empty() {
433                return Ok(Value::Object(Default::default()));
434            }
435            return Ok(serde_json::from_slice(&bytes)?);
436        }
437
438        let retry_after_header = response
439            .headers()
440            .get(reqwest::header::RETRY_AFTER)
441            .and_then(|v| v.to_str().ok())
442            .and_then(|s| s.trim().parse::<u32>().ok());
443        let bytes = response.bytes().await?;
444        let parsed_body: Option<Value> = if bytes.is_empty() {
445            None
446        } else {
447            match serde_json::from_slice::<Value>(&bytes) {
448                Ok(v) => Some(v),
449                Err(_) => {
450                    let raw = String::from_utf8_lossy(&bytes);
451                    let trimmed = if raw.len() > 200 { &raw[..200] } else { &raw };
452                    Some(serde_json::json!({ "error": trimmed }))
453                }
454            }
455        };
456        Err(translate_error(
457            status,
458            path,
459            parsed_body,
460            retry_after_header,
461        ))
462    }
463}
464
465fn translate_error(
466    status: StatusCode,
467    path: &str,
468    body: Option<Value>,
469    retry_after_header: Option<u32>,
470) -> PulseError {
471    let path = path.to_string();
472    match status {
473        StatusCode::UNAUTHORIZED => PulseError::Auth { path, body },
474        StatusCode::NOT_FOUND => PulseError::NotFound { path, body },
475        StatusCode::BAD_REQUEST => PulseError::Validation { path, body },
476        StatusCode::TOO_MANY_REQUESTS => {
477            let retry_from_body = body
478                .as_ref()
479                .and_then(|v| v.get("retryAfterSeconds"))
480                .and_then(|v| v.as_u64())
481                .map(|n| n as u32);
482            PulseError::RateLimit {
483                path,
484                body,
485                retry_after_seconds: retry_from_body.or(retry_after_header),
486            }
487        }
488        other => PulseError::Api {
489            status: other.as_u16(),
490            path,
491            body,
492        },
493    }
494}
495
496fn strip_trailing_slash(url: &str) -> String {
497    let mut s = url.to_string();
498    while s.len() > 1 && s.ends_with('/') {
499        s.pop();
500    }
501    s
502}
503
504// ----------------------------------------------------------------------
505// Builder
506// ----------------------------------------------------------------------
507
508/// Fluent builder for [`PulseClient`].
509#[derive(Default, Debug)]
510pub struct PulseClientBuilder {
511    base_url: Option<String>,
512    token: Option<String>,
513    timeout: Option<Duration>,
514    http: Option<reqwest::Client>,
515    retry: Option<RetryPolicy>,
516}
517
518impl PulseClientBuilder {
519    /// Required — the Pulse server URL (e.g. `http://localhost:9090`).
520    pub fn base_url<S: Into<String>>(mut self, base_url: S) -> Self {
521        self.base_url = Some(base_url.into());
522        self
523    }
524
525    /// Optional — pre-minted JWT to attach as `Authorization: Bearer <token>`.
526    pub fn token<S: Into<String>>(mut self, token: S) -> Self {
527        self.token = Some(token.into());
528        self
529    }
530
531    /// Optional — per-request timeout. Default 30 seconds.
532    pub fn timeout(mut self, timeout: Duration) -> Self {
533        self.timeout = Some(timeout);
534        self
535    }
536
537    /// Optional — bring-your-own [`reqwest::Client`] (shared connection pools,
538    /// custom TLS / proxy / mTLS config, tracing middleware).
539    pub fn http_client(mut self, http: reqwest::Client) -> Self {
540        self.http = Some(http);
541        self
542    }
543
544    /// Optional — enable opt-in, bounded, full-jitter exponential-backoff
545    /// retries. Off by default. 429 (rate limited) is always retried for any
546    /// method (honouring Retry-After); `on_status` 5xx and transport errors are
547    /// retried only for idempotent methods unless `retry_non_idempotent` is set.
548    /// Terminal 4xx are never retried.
549    pub fn retry(mut self, policy: RetryPolicy) -> Self {
550        self.retry = Some(policy);
551        self
552    }
553
554    pub fn build(self) -> Result<PulseClient, PulseError> {
555        let base_url = self
556            .base_url
557            .ok_or_else(|| PulseError::InvalidConfig("base_url is required".to_string()))?;
558        if base_url.is_empty() {
559            return Err(PulseError::InvalidConfig(
560                "base_url cannot be empty".to_string(),
561            ));
562        }
563
564        let http = match self.http {
565            Some(c) => c,
566            None => reqwest::Client::builder()
567                .timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
568                .user_agent(USER_AGENT)
569                .build()
570                .map_err(PulseError::Transport)?,
571        };
572
573        Ok(PulseClient {
574            inner: Arc::new(Inner {
575                base_url: strip_trailing_slash(&base_url),
576                http,
577                token: RwLock::new(self.token),
578                retry: self.retry.unwrap_or_default(),
579            }),
580        })
581    }
582}