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
41impl AzureSearchClient {
42    /// Create client from a workspace search connection.
43    pub fn from_connection(
44        conn: &rigg_core::workspace::SearchConnection,
45    ) -> Result<Self, ClientError> {
46        let auth = get_auth_provider()?;
47        let http = Client::builder().timeout(Duration::from_secs(30)).build()?;
48        Ok(Self {
49            http,
50            auth,
51            base_url: conn.url(),
52            api_version: conn
53                .api_version
54                .clone()
55                .unwrap_or_else(|| rigg_core::registry::SEARCH_STABLE_API_VERSION.to_string()),
56            preview_api_version: conn
57                .preview_api_version
58                .clone()
59                .unwrap_or_else(|| rigg_core::registry::SEARCH_PREVIEW_API_VERSION.to_string()),
60        })
61    }
62
63    /// Create client from a specific search service config (legacy).
64    pub fn from_service_config(service: &SearchServiceConfig) -> Result<Self, ClientError> {
65        let auth = get_auth_provider()?;
66        let http = Client::builder().timeout(Duration::from_secs(30)).build()?;
67
68        Ok(Self {
69            http,
70            auth,
71            base_url: service.service_url(),
72            api_version: rigg_core::registry::SEARCH_STABLE_API_VERSION.to_string(),
73            preview_api_version: service.preview_api_version.clone(),
74        })
75    }
76
77    /// Create with a custom auth provider and explicit versions (tests).
78    pub fn with_auth(
79        base_url: String,
80        api_version: String,
81        preview_api_version: String,
82        auth: Box<dyn AuthProvider>,
83    ) -> Result<Self, ClientError> {
84        let http = Client::builder()
85            .timeout(std::time::Duration::from_secs(30))
86            .build()?;
87
88        Ok(Self {
89            http,
90            auth,
91            base_url,
92            api_version,
93            preview_api_version,
94        })
95    }
96
97    /// Get the API version to use for a resource kind, per the registry
98    /// channel: stable kinds use the stable api-version, preview-gated kinds
99    /// the preview one. (Pre-0.18 the client always used preview; GA of
100    /// agentic retrieval in 2026-04-01 makes stable the default.)
101    fn api_version_for(&self, kind: ResourceKind) -> &str {
102        match rigg_core::registry::meta(kind).channel {
103            rigg_core::registry::Channel::Stable => &self.api_version,
104            rigg_core::registry::Channel::Preview => &self.preview_api_version,
105        }
106    }
107
108    /// Build URL for a resource collection
109    fn collection_url(&self, kind: ResourceKind) -> String {
110        format!(
111            "{}/{}?api-version={}",
112            self.base_url,
113            kind.api_path(),
114            self.api_version_for(kind)
115        )
116    }
117
118    /// Build URL for a specific resource
119    fn resource_url(&self, kind: ResourceKind, name: &str) -> String {
120        format!(
121            "{}/{}/{}?api-version={}",
122            self.base_url,
123            kind.api_path(),
124            urlencoding::encode(name),
125            self.api_version_for(kind)
126        )
127    }
128
129    /// Execute an HTTP request
130    async fn request(
131        &self,
132        method: Method,
133        url: &str,
134        body: Option<&Value>,
135    ) -> Result<Option<Value>, ClientError> {
136        let token = self.auth.get_token()?;
137
138        let mut request = self
139            .http
140            .request(method.clone(), url)
141            .header("Authorization", format!("Bearer {}", token))
142            .header("Content-Type", "application/json");
143
144        if let Some(json) = body {
145            request = request.json(json);
146        }
147
148        debug!("Request: {} {}", method, url);
149        let response = request.send().await?;
150        let status = response.status();
151
152        if status == StatusCode::NO_CONTENT {
153            return Ok(None);
154        }
155
156        let body = response.text().await?;
157
158        if status.is_success() {
159            if body.is_empty() {
160                Ok(None)
161            } else {
162                let value: Value = serde_json::from_str(&body)?;
163                Ok(Some(value))
164            }
165        } else {
166            match status {
167                StatusCode::NOT_FOUND => Err(ClientError::NotFound {
168                    kind: "resource".to_string(),
169                    name: url.to_string(),
170                }),
171                StatusCode::CONFLICT => Err(ClientError::AlreadyExists {
172                    kind: "resource".to_string(),
173                    name: url.to_string(),
174                }),
175                StatusCode::TOO_MANY_REQUESTS => {
176                    let retry_after = 60; // Default retry time
177                    Err(ClientError::RateLimited { retry_after })
178                }
179                StatusCode::SERVICE_UNAVAILABLE => Err(ClientError::ServiceUnavailable(body)),
180                _ => Err(ClientError::from_response_with_url(
181                    status.as_u16(),
182                    &body,
183                    Some(url),
184                )),
185            }
186        }
187    }
188
189    /// Execute an HTTP request with retry logic for transient errors.
190    ///
191    /// Retries up to [`MAX_RETRIES`] times for retryable errors (429 and 503).
192    /// Uses exponential backoff (1s, 2s, 4s) for 503 errors and respects the
193    /// `retry_after` value for 429 rate-limiting errors.
194    async fn request_with_retry(
195        &self,
196        method: Method,
197        url: &str,
198        body: Option<&Value>,
199    ) -> Result<Option<Value>, ClientError> {
200        let mut attempt = 0u32;
201        loop {
202            match self.request(method.clone(), url, body).await {
203                Ok(value) => return Ok(value),
204                Err(err) if err.is_retryable() && attempt < MAX_RETRIES => {
205                    let delay = retry_delay(&err, attempt);
206                    warn!(
207                        "Request {} {} failed (attempt {}/{}): {}. Retrying in {:?}",
208                        method,
209                        url,
210                        attempt + 1,
211                        MAX_RETRIES + 1,
212                        err,
213                        delay,
214                    );
215                    tokio::time::sleep(delay).await;
216                    attempt += 1;
217                }
218                Err(err) => return Err(err),
219            }
220        }
221    }
222
223    /// List all resources of a given kind
224    #[instrument(skip(self))]
225    pub async fn list(&self, kind: ResourceKind) -> Result<Vec<Value>, ClientError> {
226        let url = self.collection_url(kind);
227        let response = self.request_with_retry(Method::GET, &url, None).await?;
228
229        match response {
230            Some(value) => {
231                // Azure returns { "value": [...] }
232                let items = value
233                    .get("value")
234                    .and_then(|v| v.as_array())
235                    .cloned()
236                    .unwrap_or_default();
237                Ok(items)
238            }
239            None => Ok(Vec::new()),
240        }
241    }
242
243    /// Get a specific resource
244    #[instrument(skip(self))]
245    pub async fn get(&self, kind: ResourceKind, name: &str) -> Result<Value, ClientError> {
246        let url = self.resource_url(kind, name);
247        let response = self.request_with_retry(Method::GET, &url, None).await?;
248
249        response.ok_or_else(|| ClientError::NotFound {
250            kind: kind.display_name().to_string(),
251            name: name.to_string(),
252        })
253    }
254
255    /// Create or update a resource
256    ///
257    /// Returns the response body if the API returns one. Some APIs (especially
258    /// preview endpoints like Knowledge Sources) return 204 No Content on
259    /// successful update, which yields `Ok(None)`.
260    #[instrument(skip(self, definition))]
261    pub async fn create_or_update(
262        &self,
263        kind: ResourceKind,
264        name: &str,
265        definition: &Value,
266    ) -> Result<Option<Value>, ClientError> {
267        let url = self.resource_url(kind, name);
268        self.request_with_retry(Method::PUT, &url, Some(definition))
269            .await
270    }
271
272    /// Delete a resource
273    #[instrument(skip(self))]
274    pub async fn delete(&self, kind: ResourceKind, name: &str) -> Result<(), ClientError> {
275        let url = self.resource_url(kind, name);
276        self.request_with_retry(Method::DELETE, &url, None).await?;
277        Ok(())
278    }
279
280    /// Check if a resource exists
281    pub async fn exists(&self, kind: ResourceKind, name: &str) -> Result<bool, ClientError> {
282        match self.get(kind, name).await {
283            Ok(_) => Ok(true),
284            Err(ClientError::NotFound { .. }) => Ok(false),
285            Err(e) => Err(e),
286        }
287    }
288
289    /// Get the authentication method being used
290    pub fn auth_method(&self) -> &'static str {
291        self.auth.method_name()
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::auth::{AuthError, AuthProvider};
299
300    struct FakeAuth;
301    impl AuthProvider for FakeAuth {
302        fn get_token(&self) -> Result<String, AuthError> {
303            Ok("fake-token".to_string())
304        }
305        fn method_name(&self) -> &'static str {
306            "Fake"
307        }
308    }
309
310    fn make_client() -> AzureSearchClient {
311        AzureSearchClient::with_auth(
312            "https://test-svc.search.windows.net".to_string(),
313            "2026-04-01".to_string(),
314            "2026-05-01-preview".to_string(),
315            Box::new(FakeAuth),
316        )
317        .unwrap()
318    }
319
320    #[test]
321    fn stable_kinds_use_stable_version() {
322        let client = make_client();
323        let url = client.collection_url(ResourceKind::Index);
324        assert_eq!(
325            url,
326            "https://test-svc.search.windows.net/indexes?api-version=2026-04-01"
327        );
328        // agentic retrieval is GA in 2026-04-01
329        let url = client.collection_url(ResourceKind::KnowledgeBase);
330        assert_eq!(
331            url,
332            "https://test-svc.search.windows.net/knowledgeBases?api-version=2026-04-01"
333        );
334        let url = client.resource_url(ResourceKind::KnowledgeSource, "ks");
335        assert_eq!(
336            url,
337            "https://test-svc.search.windows.net/knowledgeSources/ks?api-version=2026-04-01"
338        );
339    }
340
341    #[test]
342    fn resource_url_percent_encodes_name() {
343        let client = make_client();
344        let url = client.resource_url(ResourceKind::Index, "my index");
345        assert!(url.contains("/indexes/my%20index?"));
346    }
347
348    #[test]
349    fn search_kinds_route_via_registry_channel() {
350        let client = make_client();
351        for kind in ResourceKind::search_kinds() {
352            let url = client.collection_url(kind);
353            let expected = match rigg_core::registry::meta(kind).channel {
354                rigg_core::registry::Channel::Stable => "2026-04-01",
355                rigg_core::registry::Channel::Preview => "2026-05-01-preview",
356            };
357            assert!(
358                url.contains(expected),
359                "{kind:?} should use {expected}, got: {url}"
360            );
361        }
362    }
363
364    #[test]
365    fn test_retry_delay_exponential_backoff_attempt_0() {
366        let err = ClientError::ServiceUnavailable("down".to_string());
367        let delay = retry_delay(&err, 0);
368        assert_eq!(delay, Duration::from_secs(1));
369    }
370
371    #[test]
372    fn test_retry_delay_exponential_backoff_attempt_1() {
373        let err = ClientError::ServiceUnavailable("down".to_string());
374        let delay = retry_delay(&err, 1);
375        assert_eq!(delay, Duration::from_secs(2));
376    }
377
378    #[test]
379    fn test_retry_delay_exponential_backoff_attempt_2() {
380        let err = ClientError::ServiceUnavailable("down".to_string());
381        let delay = retry_delay(&err, 2);
382        assert_eq!(delay, Duration::from_secs(4));
383    }
384
385    #[test]
386    fn test_retry_delay_rate_limited_uses_retry_after() {
387        let err = ClientError::RateLimited { retry_after: 30 };
388        // retry_after should be used regardless of attempt number
389        assert_eq!(retry_delay(&err, 0), Duration::from_secs(30));
390        assert_eq!(retry_delay(&err, 1), Duration::from_secs(30));
391        assert_eq!(retry_delay(&err, 2), Duration::from_secs(30));
392    }
393
394    #[test]
395    fn test_retry_delay_rate_limited_default_retry_after() {
396        let err = ClientError::RateLimited { retry_after: 60 };
397        let delay = retry_delay(&err, 0);
398        assert_eq!(delay, Duration::from_secs(60));
399    }
400
401    #[test]
402    fn test_retry_constants() {
403        assert_eq!(MAX_RETRIES, 3);
404        assert_eq!(INITIAL_BACKOFF_SECS, 1);
405    }
406
407    #[test]
408    fn test_retry_delay_backoff_sequence() {
409        let err = ClientError::ServiceUnavailable("temporarily unavailable".to_string());
410        let delays: Vec<Duration> = (0..MAX_RETRIES).map(|i| retry_delay(&err, i)).collect();
411        assert_eq!(
412            delays,
413            vec![
414                Duration::from_secs(1),
415                Duration::from_secs(2),
416                Duration::from_secs(4),
417            ]
418        );
419    }
420
421    #[test]
422    fn test_non_retryable_error_still_computes_delay() {
423        // retry_delay computes a delay regardless; the caller decides whether to retry.
424        // This verifies the function doesn't panic on non-retryable errors.
425        let err = ClientError::Api {
426            status: 400,
427            message: "bad request".to_string(),
428        };
429        let delay = retry_delay(&err, 0);
430        assert_eq!(delay, Duration::from_secs(1));
431    }
432}