Skip to main content

perfgate_client/
client.rs

1//! Baseline client implementation.
2//!
3//! This module provides the main client for interacting with the baseline service.
4
5use crate::config::ClientConfig;
6use crate::error::ClientError;
7use crate::types::*;
8use reqwest::{Client, Response, StatusCode};
9use tracing::{debug, warn};
10use url::Url;
11
12/// Client for interacting with the baseline service.
13#[derive(Debug)]
14pub struct BaselineClient {
15    config: ClientConfig,
16    http: Client,
17    base_url: Url,
18}
19
20impl BaselineClient {
21    /// Creates a new baseline client with the given configuration.
22    pub fn new(config: ClientConfig) -> Result<Self, ClientError> {
23        config.validate().map_err(ClientError::ValidationError)?;
24
25        let http = Client::builder()
26            .timeout(config.timeout)
27            .user_agent(format!("perfgate-client/{}", env!("CARGO_PKG_VERSION")))
28            .build()
29            .map_err(ClientError::RequestError)?;
30
31        let mut base_url = Url::parse(&config.server_url)?;
32        if !base_url.path().ends_with('/') {
33            let mut path = base_url.path().to_string();
34            path.push('/');
35            base_url.set_path(&path);
36        }
37
38        Ok(Self {
39            config,
40            http,
41            base_url,
42        })
43    }
44
45    /// Creates a client with default configuration for the given server URL.
46    pub fn with_server_url(server_url: impl Into<String>) -> Result<Self, ClientError> {
47        Self::new(ClientConfig::new(server_url))
48    }
49
50    /// Uploads a new baseline to the server.
51    ///
52    /// # Arguments
53    /// * `project` - The project/namespace identifier.
54    /// * `request` - The upload request containing baseline data.
55    pub async fn upload_baseline(
56        &self,
57        project: &str,
58        request: &UploadBaselineRequest,
59    ) -> Result<UploadBaselineResponse, ClientError> {
60        let url = self.url(&format!("projects/{}/baselines", project));
61        debug!(url = %url, "Uploading baseline");
62
63        let response = self
64            .execute_with_retry(|| {
65                let mut builder = self.http.post(url.clone()).json(request);
66                if let Some(header) = self.config.auth.header_value() {
67                    builder = builder.header("Authorization", header);
68                }
69                builder
70            })
71            .await?;
72
73        let status = response.status();
74        let body = response.text().await.map_err(ClientError::RequestError)?;
75
76        if status == StatusCode::CREATED {
77            serde_json::from_str(&body).map_err(ClientError::ParseError)
78        } else {
79            Err(ClientError::from_http(status.as_u16(), &body))
80        }
81    }
82
83    /// Gets the latest baseline for a benchmark.
84    ///
85    /// # Arguments
86    /// * `project` - The project/namespace identifier.
87    /// * `benchmark` - The benchmark name.
88    pub async fn get_latest_baseline(
89        &self,
90        project: &str,
91        benchmark: &str,
92    ) -> Result<BaselineRecord, ClientError> {
93        let url = self.url(&format!(
94            "projects/{}/baselines/{}/latest",
95            project, benchmark
96        ));
97        debug!(url = %url, "Getting latest baseline");
98
99        let response = self
100            .execute_with_retry(|| {
101                let mut builder = self.http.get(url.clone());
102                if let Some(header) = self.config.auth.header_value() {
103                    builder = builder.header("Authorization", header);
104                }
105                builder
106            })
107            .await?;
108
109        let status = response.status();
110        let body = response.text().await.map_err(ClientError::RequestError)?;
111
112        if status == StatusCode::OK {
113            serde_json::from_str(&body).map_err(ClientError::ParseError)
114        } else {
115            Err(ClientError::from_http(status.as_u16(), &body))
116        }
117    }
118
119    /// Gets a specific baseline version.
120    ///
121    /// # Arguments
122    /// * `project` - The project/namespace identifier.
123    /// * `benchmark` - The benchmark name.
124    /// * `version` - The version identifier (string, not u64).
125    pub async fn get_baseline_version(
126        &self,
127        project: &str,
128        benchmark: &str,
129        version: &str,
130    ) -> Result<BaselineRecord, ClientError> {
131        let url = self.url(&format!(
132            "projects/{}/baselines/{}/versions/{}",
133            project, benchmark, version
134        ));
135        debug!(url = %url, "Getting baseline version");
136
137        let response = self
138            .execute_with_retry(|| {
139                let mut builder = self.http.get(url.clone());
140                if let Some(header) = self.config.auth.header_value() {
141                    builder = builder.header("Authorization", header);
142                }
143                builder
144            })
145            .await?;
146
147        let status = response.status();
148        let body = response.text().await.map_err(ClientError::RequestError)?;
149
150        if status == StatusCode::OK {
151            serde_json::from_str(&body).map_err(ClientError::ParseError)
152        } else {
153            Err(ClientError::from_http(status.as_u16(), &body))
154        }
155    }
156
157    /// Lists baselines with optional filtering.
158    ///
159    /// # Arguments
160    /// * `project` - The project/namespace identifier.
161    /// * `query` - Query parameters for filtering and pagination.
162    pub async fn list_baselines(
163        &self,
164        project: &str,
165        query: &ListBaselinesQuery,
166    ) -> Result<ListBaselinesResponse, ClientError> {
167        let mut url = self.url(&format!("projects/{}/baselines", project));
168
169        // Add query parameters
170        {
171            let mut pairs = url.query_pairs_mut();
172            for (key, value) in query.to_query_params() {
173                pairs.append_pair(&key, &value);
174            }
175        }
176
177        debug!(url = %url, "Listing baselines");
178
179        let response = self
180            .execute_with_retry(|| {
181                let mut builder = self.http.get(url.clone());
182                if let Some(header) = self.config.auth.header_value() {
183                    builder = builder.header("Authorization", header);
184                }
185                builder
186            })
187            .await?;
188
189        let status = response.status();
190        let body = response.text().await.map_err(ClientError::RequestError)?;
191
192        if status == StatusCode::OK {
193            serde_json::from_str(&body).map_err(ClientError::ParseError)
194        } else {
195            Err(ClientError::from_http(status.as_u16(), &body))
196        }
197    }
198
199    /// Deletes a baseline version.
200    ///
201    /// # Arguments
202    /// * `project` - The project/namespace identifier.
203    /// * `benchmark` - The benchmark name.
204    /// * `version` - The version identifier (string, not u64).
205    pub async fn delete_baseline(
206        &self,
207        project: &str,
208        benchmark: &str,
209        version: &str,
210    ) -> Result<(), ClientError> {
211        let url = self.url(&format!(
212            "projects/{}/baselines/{}/versions/{}",
213            project, benchmark, version
214        ));
215        debug!(url = %url, "Deleting baseline");
216
217        let response = self
218            .execute_with_retry(|| {
219                let mut builder = self.http.delete(url.clone());
220                if let Some(header) = self.config.auth.header_value() {
221                    builder = builder.header("Authorization", header);
222                }
223                builder
224            })
225            .await?;
226
227        let status = response.status();
228        let body = response.text().await.map_err(ClientError::RequestError)?;
229
230        if status == StatusCode::OK {
231            Ok(())
232        } else {
233            Err(ClientError::from_http(status.as_u16(), &body))
234        }
235    }
236
237    /// Promotes a baseline version.
238    ///
239    /// # Arguments
240    /// * `project` - The project/namespace identifier.
241    /// * `benchmark` - The benchmark name.
242    /// * `request` - The promotion request.
243    pub async fn promote_baseline(
244        &self,
245        project: &str,
246        benchmark: &str,
247        request: &PromoteBaselineRequest,
248    ) -> Result<PromoteBaselineResponse, ClientError> {
249        let url = self.url(&format!(
250            "projects/{}/baselines/{}/promote",
251            project, benchmark
252        ));
253        debug!(url = %url, "Promoting baseline");
254
255        let response = self
256            .execute_with_retry(|| {
257                let mut builder = self.http.post(url.clone()).json(request);
258                if let Some(header) = self.config.auth.header_value() {
259                    builder = builder.header("Authorization", header);
260                }
261                builder
262            })
263            .await?;
264
265        let status = response.status();
266        let body = response.text().await.map_err(ClientError::RequestError)?;
267
268        if status == StatusCode::CREATED {
269            serde_json::from_str(&body).map_err(ClientError::ParseError)
270        } else {
271            Err(ClientError::from_http(status.as_u16(), &body))
272        }
273    }
274
275    /// Checks server health.
276    pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
277        let url = self.url("health");
278        debug!(url = %url, "Checking health");
279
280        let response = self
281            .execute_with_retry(|| self.http.get(url.clone()))
282            .await?;
283
284        let status = response.status();
285        let body = response.text().await.map_err(ClientError::RequestError)?;
286
287        if status == StatusCode::OK {
288            serde_json::from_str(&body).map_err(ClientError::ParseError)
289        } else {
290            Err(ClientError::from_http(status.as_u16(), &body))
291        }
292    }
293
294    /// Returns true if the server is healthy.
295    pub async fn is_healthy(&self) -> bool {
296        self.health_check().await.is_ok()
297    }
298
299    /// Builds a full URL from a path.
300    fn url(&self, path: &str) -> Url {
301        self.base_url.join(path).expect("Invalid URL path")
302    }
303
304    /// Executes a request with retry logic.
305    async fn execute_with_retry<F>(&self, request_fn: F) -> Result<Response, ClientError>
306    where
307        F: Fn() -> reqwest::RequestBuilder,
308    {
309        let retry_config = &self.config.retry;
310        let mut last_error = None;
311
312        for attempt in 0..=retry_config.max_retries {
313            if attempt > 0 {
314                let delay = retry_config.delay_for_attempt(attempt - 1);
315                debug!(attempt, delay_ms = delay.as_millis(), "Retrying request");
316                tokio::time::sleep(delay).await;
317            }
318
319            let builder = request_fn();
320            match builder.send().await {
321                Ok(response) => {
322                    let status = response.status();
323
324                    // Check if we should retry based on status code
325                    if retry_config.retry_status_codes.contains(&status.as_u16())
326                        && attempt < retry_config.max_retries
327                    {
328                        warn!(
329                            attempt,
330                            status = status.as_u16(),
331                            "Request failed with retryable status"
332                        );
333                        last_error = Some(ClientError::from_http(
334                            status.as_u16(),
335                            &format!("HTTP {}", status),
336                        ));
337                        continue;
338                    }
339
340                    return Ok(response);
341                }
342                Err(e) => {
343                    let is_connect_error = e.is_connect() || e.is_timeout() || e.is_request();
344
345                    if is_connect_error {
346                        if attempt < retry_config.max_retries {
347                            warn!(attempt, error = %e, "Request failed, will retry");
348                        }
349                        last_error = Some(ClientError::ConnectionError(e.to_string()));
350                        if attempt < retry_config.max_retries {
351                            continue;
352                        }
353                        // Return ConnectionError on final attempt for connection errors
354                        return Err(ClientError::ConnectionError(e.to_string()));
355                    }
356
357                    return Err(ClientError::RequestError(e));
358                }
359            }
360        }
361
362        Err(ClientError::RetryExhausted {
363            retries: retry_config.max_retries,
364            message: last_error
365                .map(|e| e.to_string())
366                .unwrap_or_else(|| "Unknown error".to_string()),
367        })
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::config::RetryConfig;
375    use wiremock::matchers::{method, path};
376    use wiremock::{Mock, MockServer, ResponseTemplate};
377
378    fn test_config(server_url: &str) -> ClientConfig {
379        ClientConfig::new(server_url)
380            .with_api_key("test-key")
381            .with_retry(RetryConfig {
382                max_retries: 0, // Disable retries for tests
383                ..Default::default()
384            })
385    }
386
387    #[tokio::test]
388    async fn test_health_check() {
389        let mock_server = MockServer::start().await;
390
391        Mock::given(method("GET"))
392            .and(path("/health"))
393            .respond_with(ResponseTemplate::new(200).set_body_json(HealthResponse {
394                status: "healthy".to_string(),
395                version: "2.0.0".to_string(),
396                storage: StorageHealth {
397                    backend: "memory".to_string(),
398                    status: "connected".to_string(),
399                },
400            }))
401            .mount(&mock_server)
402            .await;
403
404        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
405        let health = client.health_check().await.unwrap();
406
407        assert_eq!(health.status, "healthy");
408        assert_eq!(health.version, "2.0.0");
409    }
410
411    #[tokio::test]
412    async fn test_is_healthy() {
413        let mock_server = MockServer::start().await;
414
415        Mock::given(method("GET"))
416            .and(path("/health"))
417            .respond_with(ResponseTemplate::new(200).set_body_json(HealthResponse {
418                status: "healthy".to_string(),
419                version: "2.0.0".to_string(),
420                storage: StorageHealth {
421                    backend: "memory".to_string(),
422                    status: "connected".to_string(),
423                },
424            }))
425            .mount(&mock_server)
426            .await;
427
428        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
429        assert!(client.is_healthy().await);
430    }
431
432    #[tokio::test]
433    async fn test_get_latest_baseline_not_found() {
434        let mock_server = MockServer::start().await;
435
436        Mock::given(method("GET"))
437            .and(path("/projects/my-project/baselines/my-bench/latest"))
438            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
439                "error": {
440                    "code": "NOT_FOUND",
441                    "message": "Baseline 'my-bench/latest' not found"
442                }
443            })))
444            .mount(&mock_server)
445            .await;
446
447        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
448        let result = client.get_latest_baseline("my-project", "my-bench").await;
449
450        assert!(matches!(result, Err(ClientError::NotFoundError(_))));
451    }
452
453    #[tokio::test]
454    async fn test_upload_baseline_success() {
455        let mock_server = MockServer::start().await;
456
457        Mock::given(method("POST"))
458            .and(path("/projects/my-project/baselines"))
459            .respond_with(
460                ResponseTemplate::new(201).set_body_json(UploadBaselineResponse {
461                    id: "bl_123".to_string(),
462                    benchmark: "my-bench".to_string(),
463                    version: "v1.0.0".to_string(),
464                    created_at: chrono::Utc::now(),
465                    etag: "\"sha256:abc123\"".to_string(),
466                }),
467            )
468            .mount(&mock_server)
469            .await;
470
471        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
472
473        // Create a minimal upload request
474        let request = UploadBaselineRequest {
475            benchmark: "my-bench".to_string(),
476            version: Some("v1.0.0".to_string()),
477            git_ref: None,
478            git_sha: None,
479            receipt: perfgate_types::RunReceipt {
480                schema: "perfgate.run.v1".to_string(),
481                tool: perfgate_types::ToolInfo {
482                    name: "perfgate".to_string(),
483                    version: "0.1.0".to_string(),
484                },
485                run: perfgate_types::RunMeta {
486                    id: "test".to_string(),
487                    started_at: "2026-01-01T00:00:00Z".to_string(),
488                    ended_at: "2026-01-01T00:01:00Z".to_string(),
489                    host: perfgate_types::HostInfo {
490                        os: "linux".to_string(),
491                        arch: "x86_64".to_string(),
492                        cpu_count: Some(8),
493                        memory_bytes: Some(16000000000),
494                        hostname_hash: None,
495                    },
496                },
497                bench: perfgate_types::BenchMeta {
498                    name: "my-bench".to_string(),
499                    cwd: None,
500                    command: vec!["./bench.sh".to_string()],
501                    repeat: 5,
502                    warmup: 1,
503                    work_units: None,
504                    timeout_ms: None,
505                },
506                samples: vec![],
507                stats: perfgate_types::Stats {
508                    wall_ms: perfgate_types::U64Summary {
509                        median: 100,
510                        min: 90,
511                        max: 110,
512                    },
513                    cpu_ms: None,
514                    page_faults: None,
515                    ctx_switches: None,
516                    max_rss_kb: None,
517                    binary_bytes: None,
518                    throughput_per_s: None,
519                },
520            },
521            metadata: Default::default(),
522            tags: vec![],
523            normalize: false,
524        };
525
526        let response = client
527            .upload_baseline("my-project", &request)
528            .await
529            .unwrap();
530        assert_eq!(response.id, "bl_123");
531        assert_eq!(response.benchmark, "my-bench");
532    }
533
534    #[tokio::test]
535    async fn test_connection_error() {
536        let client = BaselineClient::new(test_config("http://localhost:59999")).unwrap();
537
538        let result = client.health_check().await;
539        assert!(matches!(result, Err(ClientError::ConnectionError(_))));
540    }
541
542    #[tokio::test]
543    async fn test_get_baseline_version() {
544        let mock_server = MockServer::start().await;
545
546        Mock::given(method("GET"))
547            .and(path(
548                "/projects/my-project/baselines/my-bench/versions/v1.2.3",
549            ))
550            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
551                "schema": "perfgate.baseline.v1",
552                "id": "bl_123",
553                "project": "my-project",
554                "benchmark": "my-bench",
555                "version": "v1.2.3",
556                "receipt": {
557                    "schema": "perfgate.run.v1",
558                    "tool": { "name": "perfgate", "version": "0.1.0" },
559                    "run": {
560                        "id": "test",
561                        "started_at": "2026-01-01T00:00:00Z",
562                        "ended_at": "2026-01-01T00:01:00Z",
563                        "host": { "os": "linux", "arch": "x86_64" }
564                    },
565                    "bench": { "name": "my-bench", "command": ["echo"], "repeat": 1, "warmup": 0 },
566                    "samples": [],
567                    "stats": { "wall_ms": { "median": 100, "min": 100, "max": 100 } }
568                },
569                "metadata": {},
570                "tags": [],
571                "source": "upload",
572                "content_hash": "abc",
573                "deleted": false,
574                "created_at": "2026-01-01T00:00:00Z",
575                "updated_at": "2026-01-01T00:00:00Z"
576            })))
577            .mount(&mock_server)
578            .await;
579
580        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
581        let result = client
582            .get_baseline_version("my-project", "my-bench", "v1.2.3")
583            .await
584            .unwrap();
585
586        assert_eq!(result.id, "bl_123");
587        assert_eq!(result.version, "v1.2.3");
588    }
589
590    #[tokio::test]
591    async fn test_delete_baseline() {
592        let mock_server = MockServer::start().await;
593
594        Mock::given(method("DELETE"))
595            .and(path(
596                "/projects/my-project/baselines/my-bench/versions/v1.2.3",
597            ))
598            .respond_with(ResponseTemplate::new(200))
599            .mount(&mock_server)
600            .await;
601
602        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
603        client
604            .delete_baseline("my-project", "my-bench", "v1.2.3")
605            .await
606            .unwrap();
607    }
608
609    #[tokio::test]
610    async fn test_promote_baseline() {
611        let mock_server = MockServer::start().await;
612
613        Mock::given(method("POST"))
614            .and(path("/projects/my-project/baselines/my-bench/promote"))
615            .respond_with(
616                ResponseTemplate::new(201).set_body_json(PromoteBaselineResponse {
617                    id: "bl_new".to_string(),
618                    benchmark: "my-bench".to_string(),
619                    version: "v2.0.0".to_string(),
620                    promoted_from: "v1.0.0".to_string(),
621                    created_at: chrono::Utc::now(),
622                }),
623            )
624            .mount(&mock_server)
625            .await;
626
627        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
628        let request = PromoteBaselineRequest {
629            to_version: "v2.0.0".to_string(),
630            from_version: "v1.0.0".to_string(),
631            git_ref: None,
632            git_sha: None,
633            normalize: true,
634        };
635        let response = client
636            .promote_baseline("my-project", "my-bench", &request)
637            .await
638            .unwrap();
639
640        assert_eq!(response.version, "v2.0.0");
641        assert_eq!(response.promoted_from, "v1.0.0");
642    }
643
644    #[tokio::test]
645    async fn test_retry_on_503() {
646        let mock_server = MockServer::start().await;
647
648        // Mock that returns 503 for the first 2 requests, then 200
649        struct RetryResponder {
650            count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
651        }
652
653        impl wiremock::Respond for RetryResponder {
654            fn respond(&self, _request: &wiremock::Request) -> ResponseTemplate {
655                let current = self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
656                if current < 2 {
657                    ResponseTemplate::new(503)
658                } else {
659                    ResponseTemplate::new(200).set_body_json(HealthResponse {
660                        status: "healthy".to_string(),
661                        version: "2.0.0".to_string(),
662                        storage: StorageHealth {
663                            backend: "memory".to_string(),
664                            status: "connected".to_string(),
665                        },
666                    })
667                }
668            }
669        }
670
671        let responder = RetryResponder {
672            count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
673        };
674
675        Mock::given(method("GET"))
676            .and(path("/health"))
677            .respond_with(responder)
678            .mount(&mock_server)
679            .await;
680
681        let config = ClientConfig::new(mock_server.uri()).with_retry(RetryConfig {
682            max_retries: 3,
683            retry_status_codes: vec![503],
684            base_delay: std::time::Duration::from_millis(1),
685            max_delay: std::time::Duration::from_millis(10),
686        });
687
688        let client = BaselineClient::new(config).unwrap();
689        let health = client.health_check().await.unwrap();
690        assert_eq!(health.status, "healthy");
691    }
692
693    #[tokio::test]
694    async fn test_auth_error() {
695        let mock_server = MockServer::start().await;
696
697        Mock::given(method("GET"))
698            .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
699                "error": {
700                    "code": "UNAUTHORIZED",
701                    "message": "Invalid API key"
702                }
703            })))
704            .mount(&mock_server)
705            .await;
706
707        let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
708        let result = client.health_check().await;
709
710        assert!(matches!(result, Err(ClientError::AuthError(_))));
711    }
712}