1use 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
15const MAX_RETRIES: u32 = 3;
17
18const INITIAL_BACKOFF_SECS: u64 = 1;
20
21fn 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
32pub 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
41const HTTP_TIMEOUT_SECS: u64 = 120;
45
46impl AzureSearchClient {
47 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 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 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 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 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 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 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 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; 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 let url = client.collection_url(ResourceKind::KnowledgeBase);
445 assert_eq!(
446 url,
447 "https://test-svc.search.windows.net/knowledgeBases?api-version=2026-05-01-preview"
448 );
449 let url = client.resource_url(ResourceKind::KnowledgeSource, "ks");
450 assert_eq!(
451 url,
452 "https://test-svc.search.windows.net/knowledgeSources/ks?api-version=2026-04-01"
453 );
454 }
455
456 #[test]
457 fn resource_url_percent_encodes_name() {
458 let client = make_client();
459 let url = client.resource_url(ResourceKind::Index, "my index");
460 assert!(url.contains("/indexes/my%20index?"));
461 }
462
463 #[test]
464 fn search_kinds_route_via_registry_channel() {
465 let client = make_client();
466 for kind in ResourceKind::search_kinds() {
467 let url = client.collection_url(kind);
468 let expected = match rigg_core::registry::meta(kind).channel {
469 rigg_core::registry::Channel::Stable => "2026-04-01",
470 rigg_core::registry::Channel::Preview => "2026-05-01-preview",
471 };
472 assert!(
473 url.contains(expected),
474 "{kind:?} should use {expected}, got: {url}"
475 );
476 }
477 }
478
479 #[test]
480 fn test_retry_delay_exponential_backoff_attempt_0() {
481 let err = ClientError::ServiceUnavailable("down".to_string());
482 let delay = retry_delay(&err, 0);
483 assert_eq!(delay, Duration::from_secs(1));
484 }
485
486 #[test]
487 fn test_retry_delay_exponential_backoff_attempt_1() {
488 let err = ClientError::ServiceUnavailable("down".to_string());
489 let delay = retry_delay(&err, 1);
490 assert_eq!(delay, Duration::from_secs(2));
491 }
492
493 #[test]
494 fn test_retry_delay_exponential_backoff_attempt_2() {
495 let err = ClientError::ServiceUnavailable("down".to_string());
496 let delay = retry_delay(&err, 2);
497 assert_eq!(delay, Duration::from_secs(4));
498 }
499
500 #[test]
501 fn test_retry_delay_rate_limited_uses_retry_after() {
502 let err = ClientError::RateLimited { retry_after: 30 };
503 assert_eq!(retry_delay(&err, 0), Duration::from_secs(30));
505 assert_eq!(retry_delay(&err, 1), Duration::from_secs(30));
506 assert_eq!(retry_delay(&err, 2), Duration::from_secs(30));
507 }
508
509 #[test]
510 fn test_retry_delay_rate_limited_default_retry_after() {
511 let err = ClientError::RateLimited { retry_after: 60 };
512 let delay = retry_delay(&err, 0);
513 assert_eq!(delay, Duration::from_secs(60));
514 }
515
516 #[test]
517 fn test_retry_constants() {
518 assert_eq!(MAX_RETRIES, 3);
519 assert_eq!(INITIAL_BACKOFF_SECS, 1);
520 }
521
522 #[test]
523 fn test_retry_delay_backoff_sequence() {
524 let err = ClientError::ServiceUnavailable("temporarily unavailable".to_string());
525 let delays: Vec<Duration> = (0..MAX_RETRIES).map(|i| retry_delay(&err, i)).collect();
526 assert_eq!(
527 delays,
528 vec![
529 Duration::from_secs(1),
530 Duration::from_secs(2),
531 Duration::from_secs(4),
532 ]
533 );
534 }
535
536 #[test]
537 fn test_non_retryable_error_still_computes_delay() {
538 let err = ClientError::Api {
541 status: 400,
542 message: "bad request".to_string(),
543 };
544 let delay = retry_delay(&err, 0);
545 assert_eq!(delay, Duration::from_secs(1));
546 }
547}