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        }
156
157        debug!("Request: {} {}", method, url);
158        let response = request.send().await?;
159        let status = response.status();
160
161        if status == StatusCode::NO_CONTENT {
162            return Ok(None);
163        }
164
165        let body = response.text().await?;
166
167        if status.is_success() {
168            if body.is_empty() {
169                Ok(None)
170            } else {
171                let value: Value = serde_json::from_str(&body)?;
172                Ok(Some(value))
173            }
174        } else {
175            match status {
176                StatusCode::NOT_FOUND => Err(ClientError::NotFound {
177                    kind: "resource".to_string(),
178                    name: url.to_string(),
179                }),
180                StatusCode::CONFLICT => Err(ClientError::AlreadyExists {
181                    kind: "resource".to_string(),
182                    name: url.to_string(),
183                }),
184                StatusCode::TOO_MANY_REQUESTS => {
185                    let retry_after = 60; // Default retry time
186                    Err(ClientError::RateLimited { retry_after })
187                }
188                StatusCode::SERVICE_UNAVAILABLE => Err(ClientError::ServiceUnavailable(body)),
189                _ => Err(ClientError::from_response_with_url(
190                    status.as_u16(),
191                    &body,
192                    Some(url),
193                )),
194            }
195        }
196    }
197
198    /// Execute an HTTP request with retry logic for transient errors.
199    ///
200    /// Retries up to [`MAX_RETRIES`] times for retryable errors (429 and 503).
201    /// Uses exponential backoff (1s, 2s, 4s) for 503 errors and respects the
202    /// `retry_after` value for 429 rate-limiting errors.
203    async fn request_with_retry(
204        &self,
205        method: Method,
206        url: &str,
207        body: Option<&Value>,
208    ) -> Result<Option<Value>, ClientError> {
209        let mut attempt = 0u32;
210        loop {
211            match self.request(method.clone(), url, body).await {
212                Ok(value) => return Ok(value),
213                Err(err) if err.is_retryable() && attempt < MAX_RETRIES => {
214                    let delay = retry_delay(&err, attempt);
215                    warn!(
216                        "Request {} {} failed (attempt {}/{}): {}. Retrying in {:?}",
217                        method,
218                        url,
219                        attempt + 1,
220                        MAX_RETRIES + 1,
221                        err,
222                        delay,
223                    );
224                    tokio::time::sleep(delay).await;
225                    attempt += 1;
226                }
227                Err(err) => return Err(err),
228            }
229        }
230    }
231
232    /// List all resources of a given kind
233    #[instrument(skip(self))]
234    pub async fn list(&self, kind: ResourceKind) -> Result<Vec<Value>, ClientError> {
235        let url = self.collection_url(kind);
236        let response = self.request_with_retry(Method::GET, &url, None).await?;
237
238        match response {
239            Some(value) => {
240                // Azure returns { "value": [...] }
241                let items = value
242                    .get("value")
243                    .and_then(|v| v.as_array())
244                    .cloned()
245                    .unwrap_or_default();
246                Ok(items)
247            }
248            None => Ok(Vec::new()),
249        }
250    }
251
252    /// Get a specific resource
253    #[instrument(skip(self))]
254    pub async fn get(&self, kind: ResourceKind, name: &str) -> Result<Value, ClientError> {
255        let url = self.resource_url(kind, name);
256        let response = self.request_with_retry(Method::GET, &url, None).await?;
257
258        response.ok_or_else(|| ClientError::NotFound {
259            kind: kind.display_name().to_string(),
260            name: name.to_string(),
261        })
262    }
263
264    /// Create or update a resource
265    ///
266    /// Returns the response body if the API returns one. Some APIs (especially
267    /// preview endpoints like Knowledge Sources) return 204 No Content on
268    /// successful update, which yields `Ok(None)`.
269    #[instrument(skip(self, definition))]
270    pub async fn create_or_update(
271        &self,
272        kind: ResourceKind,
273        name: &str,
274        definition: &Value,
275    ) -> Result<Option<Value>, ClientError> {
276        let url = self.resource_url(kind, name);
277        self.request_with_retry(Method::PUT, &url, Some(definition))
278            .await
279    }
280
281    /// Delete a resource
282    #[instrument(skip(self))]
283    pub async fn delete(&self, kind: ResourceKind, name: &str) -> Result<(), ClientError> {
284        let url = self.resource_url(kind, name);
285        self.request_with_retry(Method::DELETE, &url, None).await?;
286        Ok(())
287    }
288
289    /// Check if a resource exists
290    pub async fn exists(&self, kind: ResourceKind, name: &str) -> Result<bool, ClientError> {
291        match self.get(kind, name).await {
292            Ok(_) => Ok(true),
293            Err(ClientError::NotFound { .. }) => Ok(false),
294            Err(e) => Err(e),
295        }
296    }
297
298    /// Get the authentication method being used
299    pub fn auth_method(&self) -> &'static str {
300        self.auth.method_name()
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::auth::{AuthError, AuthProvider};
308
309    struct FakeAuth;
310    impl AuthProvider for FakeAuth {
311        fn get_token(&self) -> Result<String, AuthError> {
312            Ok("fake-token".to_string())
313        }
314        fn method_name(&self) -> &'static str {
315            "Fake"
316        }
317    }
318
319    fn make_client() -> AzureSearchClient {
320        AzureSearchClient::with_auth(
321            "https://test-svc.search.windows.net".to_string(),
322            "2026-04-01".to_string(),
323            "2026-05-01-preview".to_string(),
324            Box::new(FakeAuth),
325        )
326        .unwrap()
327    }
328
329    #[test]
330    fn stable_kinds_use_stable_version() {
331        let client = make_client();
332        let url = client.collection_url(ResourceKind::Index);
333        assert_eq!(
334            url,
335            "https://test-svc.search.windows.net/indexes?api-version=2026-04-01"
336        );
337        // agentic retrieval is GA in 2026-04-01
338        let url = client.collection_url(ResourceKind::KnowledgeBase);
339        assert_eq!(
340            url,
341            "https://test-svc.search.windows.net/knowledgeBases?api-version=2026-04-01"
342        );
343        let url = client.resource_url(ResourceKind::KnowledgeSource, "ks");
344        assert_eq!(
345            url,
346            "https://test-svc.search.windows.net/knowledgeSources/ks?api-version=2026-04-01"
347        );
348    }
349
350    #[test]
351    fn resource_url_percent_encodes_name() {
352        let client = make_client();
353        let url = client.resource_url(ResourceKind::Index, "my index");
354        assert!(url.contains("/indexes/my%20index?"));
355    }
356
357    #[test]
358    fn search_kinds_route_via_registry_channel() {
359        let client = make_client();
360        for kind in ResourceKind::search_kinds() {
361            let url = client.collection_url(kind);
362            let expected = match rigg_core::registry::meta(kind).channel {
363                rigg_core::registry::Channel::Stable => "2026-04-01",
364                rigg_core::registry::Channel::Preview => "2026-05-01-preview",
365            };
366            assert!(
367                url.contains(expected),
368                "{kind:?} should use {expected}, got: {url}"
369            );
370        }
371    }
372
373    #[test]
374    fn test_retry_delay_exponential_backoff_attempt_0() {
375        let err = ClientError::ServiceUnavailable("down".to_string());
376        let delay = retry_delay(&err, 0);
377        assert_eq!(delay, Duration::from_secs(1));
378    }
379
380    #[test]
381    fn test_retry_delay_exponential_backoff_attempt_1() {
382        let err = ClientError::ServiceUnavailable("down".to_string());
383        let delay = retry_delay(&err, 1);
384        assert_eq!(delay, Duration::from_secs(2));
385    }
386
387    #[test]
388    fn test_retry_delay_exponential_backoff_attempt_2() {
389        let err = ClientError::ServiceUnavailable("down".to_string());
390        let delay = retry_delay(&err, 2);
391        assert_eq!(delay, Duration::from_secs(4));
392    }
393
394    #[test]
395    fn test_retry_delay_rate_limited_uses_retry_after() {
396        let err = ClientError::RateLimited { retry_after: 30 };
397        // retry_after should be used regardless of attempt number
398        assert_eq!(retry_delay(&err, 0), Duration::from_secs(30));
399        assert_eq!(retry_delay(&err, 1), Duration::from_secs(30));
400        assert_eq!(retry_delay(&err, 2), Duration::from_secs(30));
401    }
402
403    #[test]
404    fn test_retry_delay_rate_limited_default_retry_after() {
405        let err = ClientError::RateLimited { retry_after: 60 };
406        let delay = retry_delay(&err, 0);
407        assert_eq!(delay, Duration::from_secs(60));
408    }
409
410    #[test]
411    fn test_retry_constants() {
412        assert_eq!(MAX_RETRIES, 3);
413        assert_eq!(INITIAL_BACKOFF_SECS, 1);
414    }
415
416    #[test]
417    fn test_retry_delay_backoff_sequence() {
418        let err = ClientError::ServiceUnavailable("temporarily unavailable".to_string());
419        let delays: Vec<Duration> = (0..MAX_RETRIES).map(|i| retry_delay(&err, i)).collect();
420        assert_eq!(
421            delays,
422            vec![
423                Duration::from_secs(1),
424                Duration::from_secs(2),
425                Duration::from_secs(4),
426            ]
427        );
428    }
429
430    #[test]
431    fn test_non_retryable_error_still_computes_delay() {
432        // retry_delay computes a delay regardless; the caller decides whether to retry.
433        // This verifies the function doesn't panic on non-retryable errors.
434        let err = ClientError::Api {
435            status: 400,
436            message: "bad request".to_string(),
437        };
438        let delay = retry_delay(&err, 0);
439        assert_eq!(delay, Duration::from_secs(1));
440    }
441}