Skip to main content

rigg_client/
client.rs

1//! Azure Search REST API client
2
3use std::time::Duration;
4
5use reqwest::{Client, Method, StatusCode};
6use serde_json::Value;
7use tracing::{debug, instrument, warn};
8
9use rigg_core::config::SearchServiceConfig;
10use rigg_core::resources::ResourceKind;
11
12use crate::auth::{AuthProvider, get_auth_provider};
13use crate::error::ClientError;
14
15/// Maximum number of retry attempts for retryable errors
16const MAX_RETRIES: u32 = 3;
17
18/// Initial backoff delay in seconds
19const INITIAL_BACKOFF_SECS: u64 = 1;
20
21/// Calculate the backoff duration for a given retry attempt.
22///
23/// For `RateLimited` errors with a `retry_after` value, that value is used directly.
24/// For other retryable errors, exponential backoff is applied: 1s, 2s, 4s, etc.
25fn retry_delay(error: &ClientError, attempt: u32) -> Duration {
26    match error {
27        ClientError::RateLimited { retry_after } => Duration::from_secs(*retry_after),
28        _ => Duration::from_secs(INITIAL_BACKOFF_SECS * 2u64.pow(attempt)),
29    }
30}
31
32/// Azure Search API client
33pub struct AzureSearchClient {
34    http: Client,
35    auth: Box<dyn AuthProvider>,
36    base_url: String,
37    api_version: String,
38    preview_api_version: String,
39}
40
41/// Indexer creation validates the data source and AI-services connections
42/// and starts the first run before responding — routinely slower than a
43/// typical management call. 30s produced live false-failure timeouts.
44const HTTP_TIMEOUT_SECS: u64 = 120;
45
46impl AzureSearchClient {
47    /// Create client from a workspace search connection.
48    pub fn from_connection(
49        conn: &rigg_core::workspace::SearchConnection,
50    ) -> Result<Self, ClientError> {
51        let auth = get_auth_provider()?;
52        let http = Client::builder()
53            .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
54            .build()?;
55        Ok(Self {
56            http,
57            auth,
58            base_url: conn.url(),
59            api_version: conn
60                .api_version
61                .clone()
62                .unwrap_or_else(|| rigg_core::registry::SEARCH_STABLE_API_VERSION.to_string()),
63            preview_api_version: conn
64                .preview_api_version
65                .clone()
66                .unwrap_or_else(|| rigg_core::registry::SEARCH_PREVIEW_API_VERSION.to_string()),
67        })
68    }
69
70    /// Create client from a specific search service config (legacy).
71    pub fn from_service_config(service: &SearchServiceConfig) -> Result<Self, ClientError> {
72        let auth = get_auth_provider()?;
73        let http = Client::builder()
74            .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
75            .build()?;
76
77        Ok(Self {
78            http,
79            auth,
80            base_url: service.service_url(),
81            api_version: rigg_core::registry::SEARCH_STABLE_API_VERSION.to_string(),
82            preview_api_version: service.preview_api_version.clone(),
83        })
84    }
85
86    /// Create with a custom auth provider and explicit versions (tests).
87    pub fn with_auth(
88        base_url: String,
89        api_version: String,
90        preview_api_version: String,
91        auth: Box<dyn AuthProvider>,
92    ) -> Result<Self, ClientError> {
93        let http = Client::builder()
94            .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
95            .build()?;
96
97        Ok(Self {
98            http,
99            auth,
100            base_url,
101            api_version,
102            preview_api_version,
103        })
104    }
105
106    /// Get the API version to use for a resource kind, per the registry
107    /// channel: stable kinds use the stable api-version, preview-gated kinds
108    /// the preview one. (Pre-0.18 the client always used preview; GA of
109    /// agentic retrieval in 2026-04-01 makes stable the default.)
110    fn api_version_for(&self, kind: ResourceKind) -> &str {
111        match rigg_core::registry::meta(kind).channel {
112            rigg_core::registry::Channel::Stable => &self.api_version,
113            rigg_core::registry::Channel::Preview => &self.preview_api_version,
114        }
115    }
116
117    /// Build URL for a resource collection
118    fn collection_url(&self, kind: ResourceKind) -> String {
119        format!(
120            "{}/{}?api-version={}",
121            self.base_url,
122            kind.api_path(),
123            self.api_version_for(kind)
124        )
125    }
126
127    /// Build URL for a specific resource
128    fn resource_url(&self, kind: ResourceKind, name: &str) -> String {
129        format!(
130            "{}/{}/{}?api-version={}",
131            self.base_url,
132            kind.api_path(),
133            urlencoding::encode(name),
134            self.api_version_for(kind)
135        )
136    }
137
138    /// Execute an HTTP request
139    async fn request(
140        &self,
141        method: Method,
142        url: &str,
143        body: Option<&Value>,
144    ) -> Result<Option<Value>, ClientError> {
145        let token = self.auth.get_token()?;
146
147        let mut request = self
148            .http
149            .request(method.clone(), url)
150            .header("Authorization", format!("Bearer {}", token))
151            .header("Content-Type", "application/json");
152
153        if let Some(json) = body {
154            request = request.json(json);
155        } else if method == Method::POST {
156            // Azure's front end rejects bodyless POSTs without an explicit
157            // Content-Length (HTTP 411) — indexer run/reset take no body.
158            request = request.header("Content-Length", "0");
159        }
160
161        debug!("Request: {} {}", method, url);
162        let response = request.send().await?;
163        let status = response.status();
164
165        if status == StatusCode::NO_CONTENT {
166            return Ok(None);
167        }
168
169        let body = response.text().await?;
170
171        if status.is_success() {
172            if body.is_empty() {
173                Ok(None)
174            } else {
175                let value: Value = serde_json::from_str(&body)?;
176                Ok(Some(value))
177            }
178        } else {
179            match status {
180                StatusCode::NOT_FOUND => Err(ClientError::NotFound {
181                    kind: "resource".to_string(),
182                    name: url.to_string(),
183                }),
184                StatusCode::CONFLICT => Err(ClientError::AlreadyExists {
185                    kind: "resource".to_string(),
186                    name: url.to_string(),
187                }),
188                StatusCode::TOO_MANY_REQUESTS => {
189                    let retry_after = 60; // Default retry time
190                    Err(ClientError::RateLimited { retry_after })
191                }
192                StatusCode::SERVICE_UNAVAILABLE => Err(ClientError::ServiceUnavailable(body)),
193                _ => Err(ClientError::from_response_with_url(
194                    status.as_u16(),
195                    &body,
196                    Some(url),
197                )),
198            }
199        }
200    }
201
202    /// Execute an HTTP request with retry logic for transient errors.
203    ///
204    /// Retries up to [`MAX_RETRIES`] times for retryable errors (429 and 503).
205    /// Uses exponential backoff (1s, 2s, 4s) for 503 errors and respects the
206    /// `retry_after` value for 429 rate-limiting errors.
207    async fn request_with_retry(
208        &self,
209        method: Method,
210        url: &str,
211        body: Option<&Value>,
212    ) -> Result<Option<Value>, ClientError> {
213        let mut attempt = 0u32;
214        loop {
215            match self.request(method.clone(), url, body).await {
216                Ok(value) => return Ok(value),
217                Err(err) if err.is_retryable() && attempt < MAX_RETRIES => {
218                    let delay = retry_delay(&err, attempt);
219                    warn!(
220                        "Request {} {} failed (attempt {}/{}): {}. Retrying in {:?}",
221                        method,
222                        url,
223                        attempt + 1,
224                        MAX_RETRIES + 1,
225                        err,
226                        delay,
227                    );
228                    tokio::time::sleep(delay).await;
229                    attempt += 1;
230                }
231                Err(err) => return Err(err),
232            }
233        }
234    }
235
236    /// List all resources of a given kind
237    #[instrument(skip(self))]
238    pub async fn list(&self, kind: ResourceKind) -> Result<Vec<Value>, ClientError> {
239        let url = self.collection_url(kind);
240        let response = self.request_with_retry(Method::GET, &url, None).await?;
241
242        match response {
243            Some(value) => {
244                // Azure returns { "value": [...] }
245                let items = value
246                    .get("value")
247                    .and_then(|v| v.as_array())
248                    .cloned()
249                    .unwrap_or_default();
250                Ok(items)
251            }
252            None => Ok(Vec::new()),
253        }
254    }
255
256    /// Get a specific resource
257    #[instrument(skip(self))]
258    pub async fn get(&self, kind: ResourceKind, name: &str) -> Result<Value, ClientError> {
259        let url = self.resource_url(kind, name);
260        let response = self.request_with_retry(Method::GET, &url, None).await?;
261
262        response.ok_or_else(|| ClientError::NotFound {
263            kind: kind.display_name().to_string(),
264            name: name.to_string(),
265        })
266    }
267
268    /// Create or update a resource
269    ///
270    /// Returns the response body if the API returns one. Some APIs (especially
271    /// preview endpoints like Knowledge Sources) return 204 No Content on
272    /// successful update, which yields `Ok(None)`.
273    #[instrument(skip(self, definition))]
274    pub async fn create_or_update(
275        &self,
276        kind: ResourceKind,
277        name: &str,
278        definition: &Value,
279    ) -> Result<Option<Value>, ClientError> {
280        let url = self.resource_url(kind, name);
281        self.request_with_retry(Method::PUT, &url, Some(definition))
282            .await
283    }
284
285    /// Delete a resource
286    #[instrument(skip(self))]
287    pub async fn delete(&self, kind: ResourceKind, name: &str) -> Result<(), ClientError> {
288        let url = self.resource_url(kind, name);
289        self.request_with_retry(Method::DELETE, &url, None).await?;
290        Ok(())
291    }
292
293    // ------------------------------------------------------------------
294    // Runtime operations (rigg az)
295    // ------------------------------------------------------------------
296
297    /// Trigger an indexer run now (202 Accepted, no body).
298    #[instrument(skip(self))]
299    pub async fn indexer_run(&self, name: &str) -> Result<(), ClientError> {
300        let url = format!(
301            "{}/indexers/{}/run?api-version={}",
302            self.base_url,
303            urlencoding::encode(name),
304            self.api_version_for(ResourceKind::Indexer)
305        );
306        self.request_with_retry(Method::POST, &url, None).await?;
307        Ok(())
308    }
309
310    /// Clear an indexer's change-tracking state (the next run reprocesses
311    /// every document).
312    #[instrument(skip(self))]
313    pub async fn indexer_reset(&self, name: &str) -> Result<(), ClientError> {
314        let url = format!(
315            "{}/indexers/{}/reset?api-version={}",
316            self.base_url,
317            urlencoding::encode(name),
318            self.api_version_for(ResourceKind::Indexer)
319        );
320        self.request_with_retry(Method::POST, &url, None).await?;
321        Ok(())
322    }
323
324    /// Execution state and history for an indexer.
325    #[instrument(skip(self))]
326    pub async fn indexer_status(&self, name: &str) -> Result<Value, ClientError> {
327        let url = format!(
328            "{}/indexers/{}/status?api-version={}",
329            self.base_url,
330            urlencoding::encode(name),
331            self.api_version_for(ResourceKind::Indexer)
332        );
333        self.request_with_retry(Method::GET, &url, None)
334            .await?
335            .ok_or_else(|| ClientError::NotFound {
336                kind: "indexer status".to_string(),
337                name: name.to_string(),
338            })
339    }
340
341    /// Document count and storage size for an index.
342    #[instrument(skip(self))]
343    pub async fn index_stats(&self, name: &str) -> Result<Value, ClientError> {
344        let url = format!(
345            "{}/indexes/{}/stats?api-version={}",
346            self.base_url,
347            urlencoding::encode(name),
348            self.api_version_for(ResourceKind::Index)
349        );
350        self.request_with_retry(Method::GET, &url, None)
351            .await?
352            .ok_or_else(|| ClientError::NotFound {
353                kind: "index stats".to_string(),
354                name: name.to_string(),
355            })
356    }
357
358    /// Run a search query against an index's documents.
359    #[instrument(skip(self, body))]
360    pub async fn search_docs(&self, index: &str, body: &Value) -> Result<Value, ClientError> {
361        let url = format!(
362            "{}/indexes/{}/docs/search?api-version={}",
363            self.base_url,
364            urlencoding::encode(index),
365            self.api_version_for(ResourceKind::Index)
366        );
367        self.request_with_retry(Method::POST, &url, Some(body))
368            .await?
369            .ok_or_else(|| ClientError::Api {
370                status: 0,
371                message: "search returned no body".to_string(),
372            })
373    }
374
375    /// Agentic retrieval against a knowledge base (the `retrieve` action).
376    /// A 206 Partial response (some knowledge sources errored) still carries
377    /// a usable body and is returned as success.
378    #[instrument(skip(self, body))]
379    pub async fn kb_retrieve(&self, kb: &str, body: &Value) -> Result<Value, ClientError> {
380        let url = format!(
381            "{}/knowledgebases('{}')/retrieve?api-version={}",
382            self.base_url,
383            urlencoding::encode(kb),
384            self.api_version_for(ResourceKind::KnowledgeBase)
385        );
386        self.request_with_retry(Method::POST, &url, Some(body))
387            .await?
388            .ok_or_else(|| ClientError::Api {
389                status: 0,
390                message: "retrieve returned no body".to_string(),
391            })
392    }
393
394    /// Check if a resource exists
395    pub async fn exists(&self, kind: ResourceKind, name: &str) -> Result<bool, ClientError> {
396        match self.get(kind, name).await {
397            Ok(_) => Ok(true),
398            Err(ClientError::NotFound { .. }) => Ok(false),
399            Err(e) => Err(e),
400        }
401    }
402
403    /// Get the authentication method being used
404    pub fn auth_method(&self) -> &'static str {
405        self.auth.method_name()
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::auth::{AuthError, AuthProvider};
413
414    struct FakeAuth;
415    impl AuthProvider for FakeAuth {
416        fn get_token(&self) -> Result<String, AuthError> {
417            Ok("fake-token".to_string())
418        }
419        fn method_name(&self) -> &'static str {
420            "Fake"
421        }
422    }
423
424    fn make_client() -> AzureSearchClient {
425        AzureSearchClient::with_auth(
426            "https://test-svc.search.windows.net".to_string(),
427            "2026-04-01".to_string(),
428            "2026-05-01-preview".to_string(),
429            Box::new(FakeAuth),
430        )
431        .unwrap()
432    }
433
434    #[test]
435    fn stable_kinds_use_stable_version() {
436        let client = make_client();
437        let url = client.collection_url(ResourceKind::Index);
438        assert_eq!(
439            url,
440            "https://test-svc.search.windows.net/indexes?api-version=2026-04-01"
441        );
442        // agentic retrieval is GA in 2026-04-01
443        let url = client.collection_url(ResourceKind::KnowledgeBase);
444        assert_eq!(
445            url,
446            "https://test-svc.search.windows.net/knowledgeBases?api-version=2026-04-01"
447        );
448        let url = client.resource_url(ResourceKind::KnowledgeSource, "ks");
449        assert_eq!(
450            url,
451            "https://test-svc.search.windows.net/knowledgeSources/ks?api-version=2026-04-01"
452        );
453    }
454
455    #[test]
456    fn resource_url_percent_encodes_name() {
457        let client = make_client();
458        let url = client.resource_url(ResourceKind::Index, "my index");
459        assert!(url.contains("/indexes/my%20index?"));
460    }
461
462    #[test]
463    fn search_kinds_route_via_registry_channel() {
464        let client = make_client();
465        for kind in ResourceKind::search_kinds() {
466            let url = client.collection_url(kind);
467            let expected = match rigg_core::registry::meta(kind).channel {
468                rigg_core::registry::Channel::Stable => "2026-04-01",
469                rigg_core::registry::Channel::Preview => "2026-05-01-preview",
470            };
471            assert!(
472                url.contains(expected),
473                "{kind:?} should use {expected}, got: {url}"
474            );
475        }
476    }
477
478    #[test]
479    fn test_retry_delay_exponential_backoff_attempt_0() {
480        let err = ClientError::ServiceUnavailable("down".to_string());
481        let delay = retry_delay(&err, 0);
482        assert_eq!(delay, Duration::from_secs(1));
483    }
484
485    #[test]
486    fn test_retry_delay_exponential_backoff_attempt_1() {
487        let err = ClientError::ServiceUnavailable("down".to_string());
488        let delay = retry_delay(&err, 1);
489        assert_eq!(delay, Duration::from_secs(2));
490    }
491
492    #[test]
493    fn test_retry_delay_exponential_backoff_attempt_2() {
494        let err = ClientError::ServiceUnavailable("down".to_string());
495        let delay = retry_delay(&err, 2);
496        assert_eq!(delay, Duration::from_secs(4));
497    }
498
499    #[test]
500    fn test_retry_delay_rate_limited_uses_retry_after() {
501        let err = ClientError::RateLimited { retry_after: 30 };
502        // retry_after should be used regardless of attempt number
503        assert_eq!(retry_delay(&err, 0), Duration::from_secs(30));
504        assert_eq!(retry_delay(&err, 1), Duration::from_secs(30));
505        assert_eq!(retry_delay(&err, 2), Duration::from_secs(30));
506    }
507
508    #[test]
509    fn test_retry_delay_rate_limited_default_retry_after() {
510        let err = ClientError::RateLimited { retry_after: 60 };
511        let delay = retry_delay(&err, 0);
512        assert_eq!(delay, Duration::from_secs(60));
513    }
514
515    #[test]
516    fn test_retry_constants() {
517        assert_eq!(MAX_RETRIES, 3);
518        assert_eq!(INITIAL_BACKOFF_SECS, 1);
519    }
520
521    #[test]
522    fn test_retry_delay_backoff_sequence() {
523        let err = ClientError::ServiceUnavailable("temporarily unavailable".to_string());
524        let delays: Vec<Duration> = (0..MAX_RETRIES).map(|i| retry_delay(&err, i)).collect();
525        assert_eq!(
526            delays,
527            vec![
528                Duration::from_secs(1),
529                Duration::from_secs(2),
530                Duration::from_secs(4),
531            ]
532        );
533    }
534
535    #[test]
536    fn test_non_retryable_error_still_computes_delay() {
537        // retry_delay computes a delay regardless; the caller decides whether to retry.
538        // This verifies the function doesn't panic on non-retryable errors.
539        let err = ClientError::Api {
540            status: 400,
541            message: "bad request".to_string(),
542        };
543        let delay = retry_delay(&err, 0);
544        assert_eq!(delay, Duration::from_secs(1));
545    }
546}