1use 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#[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#[derive(Clone, Debug)]
67pub struct RetryPolicy {
68 pub max_retries: u32,
70 pub backoff: Duration,
72 pub max_backoff: Duration,
74 pub on_status: Vec<u16>,
76 pub retry_non_idempotent: bool,
80}
81
82impl Default for RetryPolicy {
83 fn default() -> Self {
84 Self {
85 max_retries: 0, 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 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
113fn backoff_delay(policy: &RetryPolicy, attempt: u32) -> Duration {
117 let base_ms = policy.backoff.as_millis() as u64;
118 let factor = 1u64 << attempt.min(20); 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 pub fn token(&self) -> Option<String> {
139 self.inner.token.read().ok().and_then(|guard| guard.clone())
140 }
141
142 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 pub fn clear_token(&self) {
152 if let Ok(mut guard) = self.inner.token.write() {
153 *guard = None;
154 }
155 }
156
157 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 pub fn models(&self) -> ModelsResource<'_> {
183 ModelsResource { client: self }
184 }
185
186 pub fn wasm(&self) -> WasmResource<'_> {
189 WasmResource { client: self }
190 }
191
192 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 pub fn streams(&self) -> StreamsResource<'_> {
208 StreamsResource { client: self }
209 }
210
211 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 pub async fn duplex_at(&self, ws_url: impl Into<String>) -> Result<DuplexChannel, PulseError> {
252 DuplexChannel::connect(ws_url.into()).await
253 }
254
255 pub async fn version(&self) -> Result<Value, PulseError> {
258 self.request(Method::GET, "/api/pulse/version", None::<&()>, false)
259 .await
260 }
261
262 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 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 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 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 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 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#[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 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 pub fn token<S: Into<String>>(mut self, token: S) -> Self {
527 self.token = Some(token.into());
528 self
529 }
530
531 pub fn timeout(mut self, timeout: Duration) -> Self {
533 self.timeout = Some(timeout);
534 self
535 }
536
537 pub fn http_client(mut self, http: reqwest::Client) -> Self {
540 self.http = Some(http);
541 self
542 }
543
544 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}